mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
183
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efc0ab7126 | ||
|
|
ef99893fc8 | ||
|
|
f8dc10268e | ||
|
|
5d34e803ff | ||
|
|
a40e4d984a | ||
|
|
dd144b6b64 | ||
|
|
4851df716c | ||
|
|
faa8cd99dd | ||
|
|
279bb8beb6 | ||
|
|
52aedde3c5 | ||
|
|
7f832ede43 | ||
|
|
58275ccd52 | ||
|
|
11114c80d6 | ||
|
|
5d907d18c9 | ||
|
|
2d5e037599 | ||
|
|
8d3a1ccc2b | ||
|
|
a1fe14cbd5 | ||
|
|
a195605003 | ||
|
|
1e39936d12 | ||
|
|
1390a87280 | ||
|
|
6d47db9358 | ||
|
|
2e57214bb3 | ||
|
|
7ac2f2e247 | ||
|
|
0982e696a5 | ||
|
|
fea89c91b8 | ||
|
|
489a7c0129 | ||
|
|
e5ba469ffc | ||
|
|
2a3c181657 | ||
|
|
4881264c88 | ||
|
|
c7c3f21e4b | ||
|
|
7401fe0b09 | ||
|
|
61b713438d | ||
|
|
7ceaf69651 | ||
|
|
c00f693ec6 | ||
|
|
dace77c212 | ||
|
|
8e1233d82a | ||
|
|
722c25e0dc | ||
|
|
9e449e635b | ||
|
|
ba7011b5bb | ||
|
|
25d5843d03 | ||
|
|
4e6d655f4c | ||
|
|
bd1dd6564e | ||
|
|
69a3e918a7 | ||
|
|
aded055212 | ||
|
|
e54750492f | ||
|
|
406fe4dcfc | ||
|
|
2e085c0b75 | ||
|
|
b95fb870a5 | ||
|
|
28be1851f9 | ||
|
|
ff83360272 | ||
|
|
b19fde242f | ||
|
|
c2b38786f7 | ||
|
|
7c6c0f2d39 | ||
|
|
5ac41f90b6 | ||
|
|
c024e007aa | ||
|
|
eeedeb817f | ||
|
|
ca79f01edd | ||
|
|
55ba45e159 | ||
|
|
e8688d5025 | ||
|
|
10f10029c2 | ||
|
|
11a94ed608 | ||
|
|
5c35f8d053 | ||
|
|
c5a4b1f3b5 | ||
|
|
179ad990d6 | ||
|
|
ff8dde6895 | ||
|
|
d587ffa428 | ||
|
|
0fe68dae30 | ||
|
|
eb1ad240f2 | ||
|
|
d40cc499f5 | ||
|
|
06ef9506fa | ||
|
|
5513c23042 | ||
|
|
8aade99cc8 | ||
|
|
6f9719fd65 | ||
|
|
352a2fbabd | ||
|
|
59700519a6 | ||
|
|
8482ff2167 | ||
|
|
b42f2acf06 | ||
|
|
1b6de39a13 | ||
|
|
1aa7caf44c | ||
|
|
40253ae12b | ||
|
|
3b4945f018 | ||
|
|
fe58213bb5 | ||
|
|
6c35780d59 | ||
|
|
415c179770 | ||
|
|
feb0937b6d | ||
|
|
f880544ebe | ||
|
|
25a574b57d | ||
|
|
389fd26bee | ||
|
|
d77ebadfe6 | ||
|
|
7e78a4feef | ||
|
|
7aa4d91ae9 | ||
|
|
5f7ab80721 | ||
|
|
b3dbfdbd7b | ||
|
|
60dfff3b0b | ||
|
|
5a83c4a045 | ||
|
|
fe8acf007c | ||
|
|
8010c9fc94 | ||
|
|
bbf375db14 | ||
|
|
fe70e7c6ba | ||
|
|
f0d7a69dd4 | ||
|
|
5ce131517b | ||
|
|
49b38f3482 | ||
|
|
241fb7be8a | ||
|
|
0167a9d10e | ||
|
|
5a879a4346 | ||
|
|
c13bff0563 | ||
|
|
2f94ee1088 | ||
|
|
3068059058 | ||
|
|
7ee0cbfac9 | ||
|
|
dfa6f4effc | ||
|
|
20c63b6282 | ||
|
|
dd44c0704a | ||
|
|
681b9cd463 | ||
|
|
9fb66137a3 | ||
|
|
057aa116a4 | ||
|
|
1cea1b2a0a | ||
|
|
6c78d2f9e9 | ||
|
|
e8aa12ace5 | ||
|
|
f1964778b4 | ||
|
|
c8f7b1ceda | ||
|
|
0411a92f4e | ||
|
|
5cfd4d1f53 | ||
|
|
e1a36215f6 | ||
|
|
6f13b1f457 | ||
|
|
996d85148e | ||
|
|
09da68424e | ||
|
|
7bccde40a8 | ||
|
|
cf5d2c60a5 | ||
|
|
b7a0276337 | ||
|
|
bbc056cc49 | ||
|
|
e2f6c2ed05 | ||
|
|
745b3a4318 | ||
|
|
2dfd93687f | ||
|
|
91554a137e | ||
|
|
52da7b571c | ||
|
|
9e1ccf7ff3 | ||
|
|
8f94514692 | ||
|
|
2ad49e1040 | ||
|
|
57349a6982 | ||
|
|
ac0de9df44 | ||
|
|
7d8711bf5b | ||
|
|
af13c33678 | ||
|
|
5cf13547f2 | ||
|
|
e181a25f95 | ||
|
|
5e376bc0c8 | ||
|
|
fceaee6372 | ||
|
|
47a6122c37 | ||
|
|
9fabd1d5b3 | ||
|
|
32b28c1994 | ||
|
|
21625de22e | ||
|
|
c79b3edf4f | ||
|
|
d366861321 | ||
|
|
f850155411 | ||
|
|
c8998075ea | ||
|
|
759f1a798d | ||
|
|
b57fef8c9d | ||
|
|
7c0cf6f6a1 | ||
|
|
7f93166560 | ||
|
|
21846192d1 | ||
|
|
b9f0c8601d | ||
|
|
18f5c9f2c7 | ||
|
|
f0f97a08cf | ||
|
|
24aac2f6d2 | ||
|
|
1eb4e36cf4 | ||
|
|
3160b99f11 | ||
|
|
589a98b12c | ||
|
|
ec3ad1cb2f | ||
|
|
c7cc64e45c | ||
|
|
0c67499dc2 | ||
|
|
34d74d36bd | ||
|
|
0bdc2197b0 | ||
|
|
68249028ef | ||
|
|
cbcbdc2599 | ||
|
|
3296c1b0d6 | ||
|
|
a10a9d9b93 | ||
|
|
ad5f9b6196 | ||
|
|
65b966c264 | ||
|
|
c701b8d7c0 | ||
|
|
4b41b9cd54 | ||
|
|
ce5f28e844 | ||
|
|
97beffb572 | ||
|
|
03eab894a0 | ||
|
|
53c77c8333 |
No files matched your search
@@ -47,3 +47,13 @@ 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 }}
|
||||
@@ -118,9 +118,13 @@ 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:
|
||||
@@ -129,7 +133,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
repository: ${{ matrix.repository }}
|
||||
|
||||
- name: Set up Node.js
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
|
||||
@@ -184,6 +184,7 @@ 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.
|
||||
@@ -200,6 +201,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). Run `npm run check-translations` before opening the PR.
|
||||
- **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).
|
||||
- **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.
|
||||
+7
-1
@@ -73,6 +73,12 @@ 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
|
||||
@@ -87,4 +93,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"]
|
||||
@@ -350,6 +350,9 @@
|
||||
// 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
|
||||
|
||||
@@ -47,7 +47,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
|
||||
Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated.
|
||||
|
||||
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and the current six-digit authenticator `code`. Missing, malformed, or incorrect codes leave the secret and recovery codes intact. A team's requirement for 2FA still prevents disabling it.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+8
-1
@@ -51,11 +51,18 @@ 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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.
|
||||
+19
-1
@@ -12,7 +12,17 @@ Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker** with the `compose` plugin.
|
||||
- **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.
|
||||
- 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).
|
||||
|
||||
@@ -657,6 +667,14 @@ 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`).
|
||||
|
||||
|
||||
@@ -314,6 +314,41 @@ 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()],
|
||||
|
||||
@@ -307,6 +307,9 @@ 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);
|
||||
@@ -319,7 +322,7 @@ export const handleThumbnailRead = async (
|
||||
entry.thumbnail = await getSignedUrl(
|
||||
presignClient,
|
||||
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
{ expiresIn: 604800 },
|
||||
{ expiresIn: 604800, signingDate },
|
||||
);
|
||||
} else if (thumb.startsWith('data')) {
|
||||
// Move an inline thumbnail into storage. The row only takes the new
|
||||
@@ -373,7 +376,7 @@ export const handleThumbnailRead = async (
|
||||
entry.thumbnail = await getSignedUrl(
|
||||
presignClient,
|
||||
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
{ expiresIn: 604800 },
|
||||
{ expiresIn: 604800, signingDate },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+21
-1
@@ -53,9 +53,29 @@ 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"
|
||||
need docker
|
||||
check_docker
|
||||
need curl
|
||||
need openssl
|
||||
docker compose version >/dev/null 2>&1 \
|
||||
|
||||
Generated
+775
-750
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -54,7 +54,6 @@
|
||||
"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",
|
||||
@@ -79,7 +78,8 @@
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.15",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
"open": "^10.1.0",
|
||||
"sharp": "^0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
|
||||
@@ -108,6 +108,25 @@ 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([
|
||||
@@ -256,6 +275,16 @@ 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('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');
|
||||
|
||||
@@ -138,25 +138,27 @@ 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. The
|
||||
* primary read starts alongside the replica one rather than after it.
|
||||
* Without a replica this is a single `read()`.
|
||||
* 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()`.
|
||||
*/
|
||||
@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 = settle(this.pread(query, params));
|
||||
const primary = race ? settle(this.pread(query, params)) : null;
|
||||
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;
|
||||
const result = await (primary ?? settle(this.pread(query, params)));
|
||||
if ('error' in result) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
@@ -386,6 +388,19 @@ 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)`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,7 +217,8 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return this.configuration === Configuration.REPLICA;
|
||||
}
|
||||
|
||||
// No failover: `tryHardRead` already has a primary read in flight.
|
||||
// No failover: `tryHardRead` falls back to the primary on an empty result
|
||||
// or error (raced alongside by default, after the replica with `race: false`).
|
||||
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 = 90;
|
||||
const CURRENT_SCHEMA_VERSION = 95;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -673,6 +673,38 @@ 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(
|
||||
@@ -680,6 +712,18 @@ 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('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,6 +129,11 @@ 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']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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');
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 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`);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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`);
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- 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`);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 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`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -188,6 +188,47 @@ 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 });
|
||||
|
||||
@@ -252,7 +252,19 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
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;
|
||||
const client = await this.#getDocumentClient();
|
||||
return sendRepairingValues(
|
||||
item,
|
||||
@@ -262,6 +274,23 @@ 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}`,
|
||||
@@ -505,6 +534,8 @@ 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>;
|
||||
@@ -545,6 +576,10 @@ 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,
|
||||
@@ -559,6 +594,9 @@ 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 }
|
||||
: {}),
|
||||
|
||||
@@ -75,6 +75,15 @@ 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': {
|
||||
@@ -137,6 +146,17 @@ 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;
|
||||
@@ -197,7 +217,7 @@ export type EventMap = {
|
||||
*/
|
||||
is_temp?: boolean;
|
||||
ip?: string | null;
|
||||
source?: 'oidc';
|
||||
source?: 'oidc' | 'team_provision';
|
||||
req?: unknown;
|
||||
data?: unknown;
|
||||
abuse?: unknown;
|
||||
@@ -257,7 +277,7 @@ export type EventMap = {
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
reputation?: number | null;
|
||||
source?: 'oidc';
|
||||
source?: 'oidc' | 'team_provision';
|
||||
requires_phone_verification: boolean;
|
||||
requires_card_verification: boolean;
|
||||
accepted: boolean;
|
||||
@@ -389,6 +409,9 @@ 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.
|
||||
@@ -397,6 +420,8 @@ 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 ----
|
||||
@@ -490,13 +515,33 @@ 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 };
|
||||
'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 };
|
||||
|
||||
// ---- Notifications ----
|
||||
/**
|
||||
@@ -513,9 +558,28 @@ 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;
|
||||
};
|
||||
/**
|
||||
* One notification, addressed at whatever sockets each region holds for the
|
||||
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
|
||||
@@ -524,9 +588,31 @@ export type EventMap = {
|
||||
*/
|
||||
'outer.notif.delivery': {
|
||||
userId: number;
|
||||
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
|
||||
wire: 'notif.message' | 'notif.unreads' | 'notif.ack' | 'notif.removed';
|
||||
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
|
||||
@@ -643,6 +729,13 @@ 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
|
||||
@@ -661,6 +754,7 @@ 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 }[];
|
||||
}>;
|
||||
@@ -668,7 +762,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 };
|
||||
'subdomain.delete': { subdomain: string; uid?: string; userId: number };
|
||||
'subdomain.update': { subdomain: string; uid?: string };
|
||||
'site.htmlServed': {
|
||||
subdomain: string;
|
||||
@@ -927,11 +1021,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
@@ -940,6 +1033,22 @@ 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. */
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
EVENTS_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EventsWorkerInvokerClient,
|
||||
INVOKE_CONNECTIONS,
|
||||
outcomeForStatus,
|
||||
type WorkerInvokeRequest,
|
||||
} from './EventsWorkerInvokerClient.js';
|
||||
|
||||
@@ -54,14 +56,22 @@ const stubFetch = (
|
||||
(async () =>
|
||||
new Response(null, { status, headers })) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that never answers until the abort signal fires. */
|
||||
/**
|
||||
* A stub fetch that never answers until the abort signal fires, then rejects
|
||||
* with the signal's reason the way undici does.
|
||||
*/
|
||||
const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('This operation was aborted')),
|
||||
reject(init.signal!.reason),
|
||||
);
|
||||
})) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that fails before any answer, as a refused connection does. */
|
||||
const refusedFetch: FetchImpl = (async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that records the URL it was called with. */
|
||||
const capturingFetch = (
|
||||
calls: string[],
|
||||
@@ -141,13 +151,39 @@ describe('DispatcherInvokeTransport', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('answers null with an error when the request times out', async () => {
|
||||
it('answers null and says so when the request times out', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: hangingFetch,
|
||||
});
|
||||
const result = await transport.send({ ...CALL, timeoutMs: 10 });
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.error).toMatch(/abort/i);
|
||||
expect(result.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('answers null without a timeout when the request fails outright', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: refusedFetch,
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: null,
|
||||
error: 'fetch failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a retry-after in seconds, and ignores a date', async () => {
|
||||
const answer = (value: string) =>
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(500, {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'platform-error',
|
||||
'retry-after': value,
|
||||
}),
|
||||
}).send(CALL);
|
||||
|
||||
expect((await answer('30')).retryAfterMs).toBe(30_000);
|
||||
expect(
|
||||
(await answer('Wed, 21 Oct 2015 07:28:00 GMT')).retryAfterMs,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces the runtime`s own error header alongside its status', async () => {
|
||||
@@ -176,6 +212,97 @@ describe('DispatcherInvokeTransport', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DispatcherInvokeTransport with every connection busy', () => {
|
||||
/** A fetch that answers 200 to one call at a time, when told to. */
|
||||
const heldFetch = () => {
|
||||
const held: Array<{ answer: () => void; signal: AbortSignal }> = [];
|
||||
const fetchImpl = ((_url: string, init: RequestInit) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const signal = init.signal!;
|
||||
signal.addEventListener('abort', () => reject(signal.reason));
|
||||
held.push({
|
||||
signal,
|
||||
answer: () =>
|
||||
resolve(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
}),
|
||||
),
|
||||
});
|
||||
})) as unknown as FetchImpl;
|
||||
return { fetchImpl, held };
|
||||
};
|
||||
|
||||
/** Every connection taken by a call nobody has answered yet. */
|
||||
const saturated = async () => {
|
||||
const { fetchImpl, held } = heldFetch();
|
||||
const transport = new DispatcherInvokeTransport(
|
||||
'http://dispatcher',
|
||||
's',
|
||||
{ fetchImpl },
|
||||
);
|
||||
const busy = Array.from({ length: INVOKE_CONNECTIONS }, () =>
|
||||
transport.send(CALL),
|
||||
);
|
||||
await vi.waitFor(() => expect(held).toHaveLength(INVOKE_CONNECTIONS));
|
||||
const answerAll = async () => {
|
||||
for (const call of held) call.answer();
|
||||
await Promise.all(busy);
|
||||
};
|
||||
return { transport, held, answerAll };
|
||||
};
|
||||
|
||||
it('waits for a free connection, then gives the run its whole timeout', async () => {
|
||||
const { transport, held, answerAll } = await saturated();
|
||||
|
||||
const waiting = transport.send({ ...CALL, timeoutMs: 400 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(held).toHaveLength(INVOKE_CONNECTIONS);
|
||||
|
||||
held[0].answer();
|
||||
await vi.waitFor(() =>
|
||||
expect(held).toHaveLength(INVOKE_CONNECTIONS + 1),
|
||||
);
|
||||
// Past 400 ms from when the call was made, not from when it got its
|
||||
// connection.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(held[INVOKE_CONNECTIONS].signal.aborted).toBe(false);
|
||||
|
||||
await answerAll();
|
||||
await expect(waiting).resolves.toMatchObject({ status: 200 });
|
||||
});
|
||||
|
||||
it('fails a call that never got a connection as Puter`s, not the handler`s', async () => {
|
||||
const { transport, held, answerAll } = await saturated();
|
||||
|
||||
await expect(
|
||||
transport.send({ ...CALL, timeoutMs: 40 }),
|
||||
).resolves.toEqual({
|
||||
status: null,
|
||||
error: 'no free connection to the events dispatcher',
|
||||
});
|
||||
|
||||
const client = new EventsWorkerInvokerClient({
|
||||
events: { invokeTimeoutMs: 40 },
|
||||
} as unknown as IConfig);
|
||||
client.setTransport(transport);
|
||||
await expect(client.invoke(REQUEST)).resolves.toMatchObject({
|
||||
outcome: 'unavailable',
|
||||
});
|
||||
expect(held).toHaveLength(INVOKE_CONNECTIONS);
|
||||
|
||||
// The slots come back once the calls holding them are answered.
|
||||
await answerAll();
|
||||
const after = transport.send(CALL);
|
||||
await vi.waitFor(() =>
|
||||
expect(held).toHaveLength(INVOKE_CONNECTIONS + 1),
|
||||
);
|
||||
held[INVOKE_CONNECTIONS].answer();
|
||||
await expect(after).resolves.toMatchObject({ status: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
const REQUEST: WorkerInvokeRequest = {
|
||||
script: 'evw-test',
|
||||
appUid: 'app-test',
|
||||
@@ -275,7 +402,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'deploy: stale',
|
||||
});
|
||||
@@ -300,7 +427,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
expect(handlerCalls).toBe(1);
|
||||
// The first send plus the one retry — never a third.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(result.outcome).toBe('retriable');
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
expect(result.status).toBeNull();
|
||||
});
|
||||
|
||||
@@ -317,7 +444,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'dispatcher: missing (404)',
|
||||
});
|
||||
@@ -342,7 +469,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
|
||||
expect(handlerCalled).toBe(false);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'dispatcher: forbidden (403)',
|
||||
});
|
||||
@@ -402,9 +529,205 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(deployCalls).toHaveLength(0);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'dispatcher: deploy-timeout (503)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('a script still bound to a rotated key', () => {
|
||||
/** Answers `bad-key` until it sees the deployed header, then settles. */
|
||||
const badKeyUntilDeployedFetch = (
|
||||
headersSeen: Array<Record<string, string>>,
|
||||
): FetchImpl =>
|
||||
(async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
headersSeen.push(headers);
|
||||
return headers[EVENTS_DEPLOYED_HEADER] === '1'
|
||||
? new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
})
|
||||
: new Response(null, {
|
||||
status: 500,
|
||||
headers: {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'bad-key',
|
||||
},
|
||||
});
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
const clientWith = (fetchImpl: FetchImpl) => {
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
return client;
|
||||
};
|
||||
|
||||
it('is redeployed, then retried once with the deployed header', async () => {
|
||||
const headersSeen: Array<Record<string, string>> = [];
|
||||
const client = clientWith(badKeyUntilDeployedFetch(headersSeen));
|
||||
const deploys: Array<[string, string]> = [];
|
||||
client.setMissHandler(async (appUid, script) => {
|
||||
deploys.push([appUid, script]);
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(deploys).toEqual([[REQUEST.appUid, REQUEST.script]]);
|
||||
expect(headersSeen).toHaveLength(2);
|
||||
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
});
|
||||
|
||||
it('backs off, uncounted, when the redeploy is throttled', async () => {
|
||||
const headersSeen: Array<Record<string, string>> = [];
|
||||
const client = clientWith(badKeyUntilDeployedFetch(headersSeen));
|
||||
client.setMissHandler(async () => 'throttled');
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(headersSeen).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'deploy: throttled',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops after the one retry when the old key is still being served', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = clientWith((async () => {
|
||||
calls.push(null);
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
headers: {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'bad-key',
|
||||
},
|
||||
});
|
||||
}) as unknown as FetchImpl);
|
||||
let deploys = 0;
|
||||
client.setMissHandler(async () => {
|
||||
deploys++;
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(deploys).toBe(1);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('deploys nothing for a handler`s own failure', async () => {
|
||||
const client = clientWith(
|
||||
stubFetch(500, {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'handler-threw',
|
||||
}),
|
||||
);
|
||||
let deploys = 0;
|
||||
client.setMissHandler(async () => {
|
||||
deploys++;
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
expect((await client.invoke(REQUEST)).outcome).toBe('retriable');
|
||||
expect(deploys).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a deploy that was throttled as Puter`s failure', async () => {
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: alwaysMissingFetch([]),
|
||||
}),
|
||||
);
|
||||
client.setMissHandler(async () => 'throttled');
|
||||
|
||||
expect((await client.invoke(REQUEST)).outcome).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose failure an invocation was', () => {
|
||||
const invokeWith = (fetchImpl: FetchImpl, timeoutMs?: number) => {
|
||||
const client = new EventsWorkerInvokerClient({
|
||||
events: timeoutMs ? { invokeTimeoutMs: timeoutMs } : {},
|
||||
} as unknown as IConfig);
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
return client.invoke(REQUEST);
|
||||
};
|
||||
|
||||
it('counts a run past the timeout against the handler', async () => {
|
||||
expect((await invokeWith(hangingFetch, 10)).outcome).toBe('retriable');
|
||||
});
|
||||
|
||||
it('puts a request that never got an answer on Puter', async () => {
|
||||
expect((await invokeWith(refusedFetch)).outcome).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('puts an unmarked 5xx, 429 or 4xx on Puter', async () => {
|
||||
for (const status of [500, 502, 429, 404]) {
|
||||
const result = await invokeWith(stubFetch(status));
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
status,
|
||||
error: `unmarked ${status}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('puts a handler failed by a Puter API call on Puter, with its wait', async () => {
|
||||
const result = await invokeWith(
|
||||
stubFetch(500, {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'platform-error',
|
||||
'retry-after': '20',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
status: 500,
|
||||
error: 'platform-error',
|
||||
retryAfterMs: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers unavailable with no dispatcher configured', async () => {
|
||||
const result = await makeClient().invoke(REQUEST);
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcomeForStatus', () => {
|
||||
it.each([
|
||||
[200, true, undefined, 'settled'],
|
||||
[204, false, undefined, 'settled'],
|
||||
[400, true, 'handler-terminal', 'terminal'],
|
||||
[404, true, 'unknown-handler', 'terminal'],
|
||||
[500, true, 'handler-threw', 'retriable'],
|
||||
[500, true, 'handler-broken', 'retriable'],
|
||||
[429, true, undefined, 'retriable'],
|
||||
[500, true, 'bad-key', 'unavailable'],
|
||||
[500, true, 'no-token', 'unavailable'],
|
||||
[500, true, 'platform-error', 'unavailable'],
|
||||
[500, false, undefined, 'unavailable'],
|
||||
[429, false, undefined, 'unavailable'],
|
||||
[403, false, undefined, 'unavailable'],
|
||||
] as const)(
|
||||
'reads %i (handled: %s, %s) as %s',
|
||||
(status, handled, errorCode, expected) => {
|
||||
expect(outcomeForStatus(status, handled, errorCode)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -68,6 +68,23 @@ 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
|
||||
@@ -106,12 +123,19 @@ export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Connections held open to the dispatcher, so a busy fleet is not
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
const INVOKE_CONNECTIONS = 256;
|
||||
export 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. */
|
||||
@@ -139,27 +163,38 @@ export interface EventsInvokeCall {
|
||||
deployed?: boolean;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export interface EventsInvokeTransport {
|
||||
send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
/** Set only by the dispatcher transport, naming its own failure. */
|
||||
dispatchReason?: string;
|
||||
}>;
|
||||
send(call: EventsInvokeCall): Promise<EventsInvokeAnswer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `settled`: handler accepted (2xx). `terminal`: handler refused (marked 4xx),
|
||||
* not retried. `retriable`: no handler answer (5xx, 429, timeout, transport
|
||||
* failure, unmarked 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.
|
||||
*/
|
||||
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
|
||||
export type WorkerInvokeOutcome =
|
||||
'settled' | 'terminal' | 'retriable' | 'unavailable';
|
||||
|
||||
export interface WorkerInvokeResult {
|
||||
outcome: WorkerInvokeOutcome;
|
||||
@@ -167,6 +202,8 @@ export interface WorkerInvokeResult {
|
||||
status: number | null;
|
||||
/** Why it could not answer, when nothing did. */
|
||||
error?: string;
|
||||
/** How long the answer asked to be left alone. */
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
const NO_TRANSPORT =
|
||||
@@ -187,8 +224,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. Set once; `invoke()` calls it at most once per
|
||||
* delivery and never loops.
|
||||
* 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.
|
||||
*/
|
||||
setMissHandler(handler: WorkerMissHandler): void {
|
||||
this.#missHandler = handler;
|
||||
@@ -202,7 +239,11 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
|
||||
const transport = this.#pickTransport();
|
||||
if (!transport)
|
||||
return { outcome: 'retriable', status: null, error: NO_TRANSPORT };
|
||||
return {
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: NO_TRANSPORT,
|
||||
};
|
||||
|
||||
const call: EventsInvokeCall = {
|
||||
script: request.script,
|
||||
@@ -218,48 +259,50 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
if (this.#missHandler && selfDeployable(result)) {
|
||||
const outcome = await this.#missHandler(
|
||||
request.appUid,
|
||||
request.script,
|
||||
);
|
||||
if (outcome !== 'deployed')
|
||||
return {
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
};
|
||||
result = await transport.send({ ...call, deployed: 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 transport.send({ ...call, deployed: true });
|
||||
}
|
||||
|
||||
const { status, handled, error } = result;
|
||||
const { status, handled, error, timedOut, retryAfterMs } = result;
|
||||
// Past the timeout the handler was most likely still running, which
|
||||
// is its own doing; any other missing answer never reached it.
|
||||
if (status === null)
|
||||
return { outcome: 'retriable', status: null, error };
|
||||
return {
|
||||
outcome: timedOut ? 'retriable' : 'unavailable',
|
||||
status: null,
|
||||
error,
|
||||
};
|
||||
|
||||
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 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 reportedError =
|
||||
error ?? (unmarkedFourxx ? 'unmarked 4xx' : undefined);
|
||||
error ?? (unmarked ? `unmarked ${status}` : undefined);
|
||||
return {
|
||||
outcome,
|
||||
status,
|
||||
...(reportedError ? { error: reportedError } : {}),
|
||||
...(retryAfterMs ? { retryAfterMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,6 +334,10 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
readonly #secret: string;
|
||||
readonly #fetchImpl: typeof undiciFetch;
|
||||
#agent: Agent | null = null;
|
||||
/** Calls holding a connection slot. */
|
||||
#active = 0;
|
||||
/** Calls waiting for one, longest-waiting first. */
|
||||
readonly #waiting = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
dispatcherUrl: string,
|
||||
@@ -307,12 +354,14 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
this.#agent = null;
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
dispatchReason?: string;
|
||||
}> {
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
// No `timedOut`: the handler never got this call, so the wait is not
|
||||
// its failure.
|
||||
if (!(await this.#acquire(call.timeoutMs * CONNECTION_WAIT_SHARE)))
|
||||
return {
|
||||
status: null,
|
||||
error: 'no free connection to the events dispatcher',
|
||||
};
|
||||
try {
|
||||
// `new URL(path, base)` resolves an absolute path against the
|
||||
// base's origin, dropping any path prefix `base` already carries.
|
||||
@@ -347,21 +396,45 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
dispatchReason: dispatchError,
|
||||
};
|
||||
|
||||
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 } : {}),
|
||||
};
|
||||
return answerFrom(response);
|
||||
} catch (err) {
|
||||
return {
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
return failedAnswer(err);
|
||||
} finally {
|
||||
this.#release();
|
||||
}
|
||||
}
|
||||
|
||||
/** A connection slot, or false once `waitMs` passes without one. */
|
||||
#acquire(waitMs: number): Promise<boolean> {
|
||||
if (this.#active < INVOKE_CONNECTIONS) {
|
||||
this.#active++;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const granted = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
this.#waiting.delete(granted);
|
||||
resolve(false);
|
||||
}, waitMs);
|
||||
timer.unref?.();
|
||||
this.#waiting.add(granted);
|
||||
});
|
||||
}
|
||||
|
||||
/** Hand the slot to the longest waiter, or give it back. */
|
||||
#release(): void {
|
||||
const next = this.#waiting.values().next().value;
|
||||
if (next) {
|
||||
this.#waiting.delete(next);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
this.#active--;
|
||||
}
|
||||
|
||||
/** One pool per process, built on first use and closed with the server. */
|
||||
#dispatcherFor(): Agent {
|
||||
this.#agent ??= new Agent({
|
||||
@@ -373,18 +446,61 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. 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.
|
||||
* 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.
|
||||
*/
|
||||
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 handled ? 'terminal' : 'retriable';
|
||||
if (status >= 400 && status < 500) return 'terminal';
|
||||
return 'retriable';
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 });
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
adminOnlyGate,
|
||||
requireUserActorGate,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import {
|
||||
configureRateLimit,
|
||||
listConfiguredRateLimitBackends,
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
ROUTES_METADATA_KEY,
|
||||
type CollectedRoute,
|
||||
@@ -49,8 +53,11 @@ import {
|
||||
} from '../../core/http/types.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
import { kvSharePermission } from '../../services/events/kvShares.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
import { kvSharePermission } from '../../services/kvShare/kvShares.js';
|
||||
import {
|
||||
FULL_API_ACCESS,
|
||||
NOTIFICATIONS_PUSH_PERMISSION,
|
||||
} from '../../services/permission/consts.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
|
||||
import { FS_READ_LIMIT } from '../fs/limits.js';
|
||||
@@ -1750,6 +1757,40 @@ describe('AuthController.handleLoginOtp + handleLoginRecoveryCode', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('refuses the sign-in while the used-token store is down', async () => {
|
||||
const { user, secret, otpToken } = await startOtpLogin();
|
||||
const token = await otpToken();
|
||||
|
||||
const { default: backend } = listConfiguredRateLimitBackends();
|
||||
configureRateLimit({
|
||||
default: 'redis',
|
||||
redis: {
|
||||
multi: () => {
|
||||
throw new Error('store down');
|
||||
},
|
||||
},
|
||||
});
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
try {
|
||||
await expect(
|
||||
controller.handleLoginOtp(
|
||||
makeReq({ token, code: liveTotp(user.username, secret) }),
|
||||
res,
|
||||
),
|
||||
).rejects.toThrow('store down');
|
||||
} finally {
|
||||
logged.mockRestore();
|
||||
configureRateLimit({
|
||||
default: backend,
|
||||
redis: server.clients.redis,
|
||||
kv: server.stores.kv,
|
||||
metering: server.services.metering,
|
||||
});
|
||||
}
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a wrong code leaves the token usable for a retry', async () => {
|
||||
const { user, secret, otpToken } = await startOtpLogin();
|
||||
const token = await otpToken();
|
||||
@@ -2151,7 +2192,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `service:tg-app:ii:read`;
|
||||
const permission = `events:background`;
|
||||
// The controller's grant call delegates through PermissionService
|
||||
// (which uses ALS Context.set), so wrap in runWithContext.
|
||||
const res = makeRes();
|
||||
@@ -2179,6 +2220,78 @@ describe('AuthController grant flows', () => {
|
||||
).toContain(permission);
|
||||
});
|
||||
|
||||
it('grant-user-app: grants and revokes notifications:push for an app', async () => {
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `tgp-${uuidv4()}`,
|
||||
title: 'TestGrantPushApp',
|
||||
index_url: 'https://example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const user = await server.stores.user.getById(issuer.id);
|
||||
const appActor = makeActor({
|
||||
user: user!,
|
||||
app: { uid: app.uid, id: app.id },
|
||||
});
|
||||
const hasPush = () =>
|
||||
server.services.permission.check(
|
||||
appActor,
|
||||
NOTIFICATIONS_PUSH_PERMISSION,
|
||||
);
|
||||
expect(await hasPush()).toBe(false);
|
||||
|
||||
// Anything past the bare permission names nothing a prompt describes.
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `${NOTIFICATIONS_PUSH_PERMISSION}:extra`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
expect(await hasPush()).toBe(false);
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: NOTIFICATIONS_PUSH_PERMISSION,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({});
|
||||
expect(await hasPush()).toBe(true);
|
||||
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: NOTIFICATIONS_PUSH_PERMISSION,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
expect(await hasPush()).toBe(false);
|
||||
});
|
||||
|
||||
it('grant-user-app: resolves `origin` to the app when app_uid is omitted', async () => {
|
||||
// The GUI permission dialog and puter.perms.grantOrigin() identify
|
||||
// third-party sites by origin rather than app uid.
|
||||
@@ -2192,7 +2305,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `service:tg-origin-app:ii:read`;
|
||||
const permission = `events:background`;
|
||||
const res = makeRes();
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
@@ -2229,7 +2342,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `service:tr-origin-app:ii:read`;
|
||||
const permission = `events:background`;
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
@@ -2294,7 +2407,7 @@ describe('AuthController grant flows', () => {
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: 'service:x:ii:read' },
|
||||
{ app_uid: app.uid, permission: 'events:background' },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
@@ -2318,12 +2431,12 @@ describe('AuthController grant flows', () => {
|
||||
|
||||
it('grant-user-app: 400 on non-string or oversized origin/app_uid/permission', async () => {
|
||||
const cases = [
|
||||
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
|
||||
{ origin: 'https://a.test', permission: ['service:x:ii:read'] },
|
||||
{ app_uid: 12345, permission: 'service:x:ii:read' },
|
||||
{ origin: { host: 'evil' }, permission: 'events:background' },
|
||||
{ origin: 'https://a.test', permission: ['events:background'] },
|
||||
{ app_uid: 12345, permission: 'events:background' },
|
||||
{
|
||||
origin: `https://${'a'.repeat(5000)}.test`,
|
||||
permission: 'service:x:ii:read',
|
||||
permission: 'events:background',
|
||||
},
|
||||
];
|
||||
for (const body of cases) {
|
||||
@@ -2354,7 +2467,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: target.id },
|
||||
);
|
||||
|
||||
const permission = 'service:squat:ii:read';
|
||||
const permission = 'events:background';
|
||||
for (const handler of [
|
||||
'handleGrantUserApp',
|
||||
'handleRevokeUserApp',
|
||||
@@ -2398,7 +2511,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: target.id },
|
||||
);
|
||||
|
||||
const permission = 'service:squat-beside:ii:read';
|
||||
const permission = 'events:background';
|
||||
for (const handler of [
|
||||
'handleGrantUserApp',
|
||||
'handleRevokeUserApp',
|
||||
@@ -2451,7 +2564,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const permission = 'service:tp-origin:ii:read';
|
||||
const permission = 'events:background';
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
@@ -2487,7 +2600,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = 'service:tm-app:ii:read';
|
||||
const permission = 'events:background';
|
||||
for (const body of [
|
||||
{ app_uid: app.uid, permission, meta: 'nope' },
|
||||
{ app_uid: app.uid, permission, meta: [1, 2] },
|
||||
@@ -2528,7 +2641,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = 'service:tb-app:ii:read';
|
||||
const permission = 'events:background';
|
||||
const oversized = { blob: 'z'.repeat(5000) };
|
||||
for (const body of [
|
||||
{ app_uid: app.uid, permission, extra: oversized },
|
||||
@@ -2605,26 +2718,278 @@ describe('AuthController grant flows', () => {
|
||||
expect(res.body).toEqual({});
|
||||
});
|
||||
|
||||
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
|
||||
// 300 chars is under the 4096 input cap but over `varchar(255)`, so it
|
||||
// used to reach the INSERT and fault on MySQL/Postgres. The check runs
|
||||
// after the rewrite, so this has to be a permission no rewriter
|
||||
// shortens — and it has to reject before the app is resolved, since
|
||||
// this uid names no app.
|
||||
await expect(
|
||||
it('grant-user-app: grants read and write share delegations on a region, never on the namespace', async () => {
|
||||
const appName = `tkvl-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareLevelsApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const root of ['kv-share-read', 'kv-share-write']) {
|
||||
const namespace = `manage:${root}:${issuer.uuid}:${app.uid}`;
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: namespace },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: `${namespace}:team` },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({});
|
||||
}
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ? ORDER BY `permission`',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows.map((r: { permission: string }) => r.permission)).toEqual([
|
||||
`manage:kv-share-read:${issuer.uuid}:${app.uid}:team`,
|
||||
`manage:kv-share-write:${issuer.uuid}:${app.uid}:team`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a share delegation whose region a new share could not name, writing nothing', async () => {
|
||||
const appName = `tkvc-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareCharsApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const region of ['Team', 'team:Abc', 'caf\u00E9', 'a b'])
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `manage:kv-share-read:${issuer.uuid}:${app.uid}:${region}`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a permission outside the app-grantable families, writing nothing', async () => {
|
||||
const appName = `tng-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestNotGrantableApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const other = await server.stores.app.create(
|
||||
{
|
||||
name: `tng-other-${uuidv4()}`,
|
||||
title: 'TestNotGrantableOther',
|
||||
index_url: `https://tng-other-${uuidv4()}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const refused = [
|
||||
['driver:puter-image-generation:generate'],
|
||||
[`user:${target.uuid}:email:read`],
|
||||
// One bad entry refuses the batch.
|
||||
['events:background', 'random-string'],
|
||||
// A share delegation names the app's own namespace only.
|
||||
[`manage:kv-share:${issuer.uuid}:${other.uid}:rooms`],
|
||||
];
|
||||
for (const permissions of refused) {
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permissions },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
}
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT 1 FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a non-canonical spelling of a grantable permission', async () => {
|
||||
const appName = `tnc-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestNonCanonicalApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const permission of [
|
||||
`f\\s:/${issuer.username}/Documents:read`,
|
||||
`app-root-di\\r:${app.uid}:read`,
|
||||
`manag\\e:kv-share:${issuer.uuid}:${app.uid}:rooms`,
|
||||
]) {
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'permission_not_grantable' });
|
||||
}
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a share delegation over a prefix no share could be made of, as a share would be', async () => {
|
||||
const appName = `tkvp-${uuidv4().slice(0, 8)}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvSharePrefixApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const root of ['kv-share', 'kv-share-read', 'kv-share-write']) {
|
||||
// A share grant one character wider than the column.
|
||||
const head = `${root}:${issuer.uuid}:${app.uid}:`;
|
||||
const region = 'a'.repeat(256 - head.length);
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `manage:${head}${region}`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
}
|
||||
});
|
||||
|
||||
it('grant-user-app: a share delegation must name the app the request names, by uid, name or origin', async () => {
|
||||
const appName = `tkvn-${uuidv4()}`;
|
||||
const origin = `https://${appName}.example.test`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareNamedApp',
|
||||
index_url: `${origin}/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const other = await server.stores.app.create(
|
||||
{
|
||||
name: `tkvn-other-${uuidv4()}`,
|
||||
title: 'TestKvShareNamedOther',
|
||||
index_url: `https://tkvn-other-${uuidv4()}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const grant = (requester: Record<string, string>, namespace: string) =>
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: `app-${uuidv4()}`,
|
||||
permission: `service:${'a'.repeat(300)}:ii:read`,
|
||||
...requester,
|
||||
permission: `manage:kv-share:${issuer.uuid}:${namespace}:rooms`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
|
||||
for (const requester of [{ app_uid: app.name }, { origin }]) {
|
||||
await grant(requester, app.uid);
|
||||
await expect(grant(requester, other.uid)).rejects.toMatchObject({
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
}
|
||||
// The app's name is not its namespace.
|
||||
await expect(
|
||||
grant({ app_uid: app.uid }, app.name),
|
||||
).rejects.toMatchObject({ legacyCode: 'permission_not_grantable' });
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows.map((r: { permission: string }) => r.permission)).toEqual([
|
||||
`manage:kv-share:${issuer.uuid}:${app.uid}:rooms`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
|
||||
// Under the 4096 input cap but over `varchar(255)`, so it would reach
|
||||
// the INSERT and fault on MySQL/Postgres. The check runs after the
|
||||
// rewrite, so this has to be a grantable permission no rewriter
|
||||
// shortens.
|
||||
const appName = `tw-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestWidthApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
// The shares it delegates fit, at 255 characters; the consent's own
|
||||
// `manage:` arm takes it past the column.
|
||||
const head = `kv-share:${issuer.uuid}:${app.uid}:`;
|
||||
const permission = `manage:${head}${'a'.repeat(255 - head.length)}`;
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: 'Invalid `permission`',
|
||||
});
|
||||
});
|
||||
|
||||
it('grant-user-app: an oversized permission that a rewriter shortens is accepted', async () => {
|
||||
@@ -2636,6 +3001,8 @@ describe('AuthController grant flows', () => {
|
||||
// on "please try again" for any deeply nested file. Exercised here
|
||||
// through a rewriter of our own, since the real fs rewriter resolves
|
||||
// the path through the fsentry store and this suite has no entries.
|
||||
// It rewrites a share delegation only because that is a grantable
|
||||
// shape no production rewriter touches.
|
||||
const appName = `tlp-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
@@ -2646,13 +3013,15 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const prefix = `tlprw-${uuidv4()}`;
|
||||
const longPermission = `${prefix}:${'a'.repeat(300)}:read`;
|
||||
const shortPermission = `${prefix}:${uuidv4()}:read`;
|
||||
// Over the column only by its `manage:` arm, so the shares it
|
||||
// delegates still fit.
|
||||
const head = `kv-share:${issuer.uuid}:${app.uid}:`;
|
||||
const longPermission = `manage:${head}${'a'.repeat(255 - head.length)}`;
|
||||
const shortPermission = `manage:${head}${uuidv4()}`;
|
||||
expect(longPermission.length).toBeGreaterThan(255);
|
||||
expect(shortPermission.length).toBeLessThanOrEqual(255);
|
||||
server.services.permission.registerRewriter({
|
||||
id: `test-shorten-${prefix}`,
|
||||
id: `test-shorten-${app.uid}`,
|
||||
matches: (permission: string) => permission === longPermission,
|
||||
rewrite: async () => shortPermission,
|
||||
});
|
||||
@@ -2712,7 +3081,8 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const prefix = `conc-${uuidv4()}`;
|
||||
// A grantable shape no production rewriter touches.
|
||||
const prefix = `manage:kv-share:${issuer.uuid}:${app.uid}:conc-${uuidv4()}`;
|
||||
let release: () => void;
|
||||
let entered: () => void;
|
||||
const held = new Promise<void>((r) => {
|
||||
@@ -2740,7 +3110,7 @@ describe('AuthController grant flows', () => {
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `${prefix}:${app.uid}:write`,
|
||||
permission: `${prefix}:write`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
@@ -2781,8 +3151,8 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const prefix = `tardrw-${uuidv4()}`;
|
||||
const pseudoPermission = `${prefix}:${app.uid}:write`;
|
||||
const prefix = `manage:kv-share:${issuer.uuid}:${app.uid}:tardrw-${uuidv4()}`;
|
||||
const pseudoPermission = `${prefix}:write`;
|
||||
const resolvedPermission = `fs:${uuidv4()}:write`;
|
||||
const NOTHING = 'nothing-in-particular';
|
||||
server.services.permission.registerRewriter({
|
||||
@@ -3023,7 +3393,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
// phase A's pre-flight passes, phase C's re-check then fails, so the
|
||||
// leaf (and its auto-created parent) already exist by the time the
|
||||
// request unwinds.
|
||||
const prefix = `rbparent-${uuidv4()}`;
|
||||
const prefix = `manage:kv-share:${user.uuid}:${appUid}:rbparent-${uuidv4()}`;
|
||||
let calls = 0;
|
||||
server.services.permission.registerRewriter({
|
||||
id: prefix,
|
||||
@@ -3189,13 +3559,13 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
});
|
||||
|
||||
it('does not apply `create` to a `manage:` grant — 404 unchanged, nothing created', async () => {
|
||||
it('refuses a `manage:` grant outright, creating nothing', async () => {
|
||||
const p = path(`manage-missing-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `manage:fs:${p}:write` }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
statusCode: 400,
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
expect(await server.stores.fsEntry.getEntryByPath(p)).toBeFalsy();
|
||||
});
|
||||
@@ -3285,7 +3655,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
// (so nothing is rejected before anything is created) but has grown
|
||||
// too wide by the time phase C re-runs it — modelling a grant that
|
||||
// only fails once the fs path from the same batch already exists.
|
||||
const prefix = `rbtest-${uuidv4()}`;
|
||||
const prefix = `manage:kv-share:${user.uuid}:${appUid}:rbtest-${uuidv4()}`;
|
||||
let calls = 0;
|
||||
server.services.permission.registerRewriter({
|
||||
id: `test-rollback-${prefix}`,
|
||||
@@ -3326,6 +3696,37 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('check-permissions answers an app asking about itself through ACL, so a folder grant covers what is inside', async () => {
|
||||
const dir = path(`acl-self-${rand()}`);
|
||||
const child = `${dir}/inner`;
|
||||
await server.services.fs.mkdir(user.id, {
|
||||
path: child,
|
||||
createMissingParents: true,
|
||||
});
|
||||
expect(await checkAsApp(`fs:${child}:read`)).toBe(false);
|
||||
|
||||
await grant({ permission: `fs:${dir}:write`, create: false });
|
||||
expect(await checkAsApp(`fs:${child}:read`)).toBe(true);
|
||||
expect(await checkAsApp(`fs:${child}:write`)).toBe(true);
|
||||
// Another user's home stays out of reach.
|
||||
expect(await checkAsApp(`fs:/${target.username}/Documents:read`)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('check-permissions answers an app asking about itself through ACL for a uid too', async () => {
|
||||
const dir = path(`acl-self-uid-${rand()}`);
|
||||
const child = await server.services.fs.mkdir(user.id, {
|
||||
path: `${dir}/inner`,
|
||||
createMissingParents: true,
|
||||
});
|
||||
expect(await checkAsApp(`fs:${child.uuid}:read`)).toBe(false);
|
||||
|
||||
await grant({ permission: `fs:${dir}:read`, create: false });
|
||||
expect(await checkAsApp(`fs:${child.uuid}:read`)).toBe(true);
|
||||
expect(await checkAsApp(`fs:${child.uuid}:write`)).toBe(false);
|
||||
});
|
||||
|
||||
it('check-permissions on a missing fs path answers false rather than 404ing, without poisoning a mixed batch', async () => {
|
||||
const missingPath = path(`.checkmissing-${rand()}`);
|
||||
// A held fs grant: the owning user holds it implicitly (is-owner), so
|
||||
@@ -4152,9 +4553,9 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', ()
|
||||
['an oversized permission string', ['x'.repeat(4097)]],
|
||||
[
|
||||
'an oversized `extra`',
|
||||
[['service:x:ii:read', { a: 'y'.repeat(5000) }]],
|
||||
[['events:background', { a: 'y'.repeat(5000) }]],
|
||||
],
|
||||
['a non-object `extra`', [['service:x:ii:read', 'nope']]],
|
||||
['a non-object `extra`', [['events:background', 'nope']]],
|
||||
['an empty permission string', ['']],
|
||||
])('rejects %s with 400', async (_label, permissions) => {
|
||||
await expect(
|
||||
@@ -7324,6 +7725,46 @@ describe('AuthController permission revokes', () => {
|
||||
);
|
||||
expect(res.body).toEqual({});
|
||||
});
|
||||
|
||||
it('revoke-user-user: refuses a key-value share grant and leaves it standing', async () => {
|
||||
const { actor: ownerActor, user: owner } = await makeUserAndActor();
|
||||
const { user: holder } = await makeUserAndActor();
|
||||
const permissions = ['kv-share', 'kv-share-read', 'kv-share-write'].map(
|
||||
(root) => `${root}:${owner.uuid}:os-global:notes:${uuidv4()}`,
|
||||
);
|
||||
for (const permission of permissions)
|
||||
await server.stores.permission.upsertUserUserPerm(
|
||||
holder.id,
|
||||
owner.id,
|
||||
permission,
|
||||
{},
|
||||
);
|
||||
|
||||
for (const permission of permissions)
|
||||
await expect(
|
||||
inCtx(ownerActor, () =>
|
||||
controller.handleRevokeUserUser(
|
||||
makeReq(
|
||||
{ target_username: holder.username, permission },
|
||||
{ actor: ownerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('puter.kv.unshare()'),
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await server.stores.permission.findUserUserPermsByIssuer(
|
||||
holder.id,
|
||||
owner.id,
|
||||
permissions,
|
||||
)
|
||||
).sort(),
|
||||
).toEqual([...permissions].sort());
|
||||
});
|
||||
});
|
||||
|
||||
// ── Permission checks + listing ────────────────────────────────────
|
||||
@@ -7365,6 +7806,94 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
]);
|
||||
});
|
||||
|
||||
it('check-permissions: answers false for share grants, which name what a holder is never told, but not for their manage arm', async () => {
|
||||
const { user: owner, actor: ownerActor } = await makeUserAndActor();
|
||||
const { user: holder, actor: holderActor } = await makeUserAndActor();
|
||||
const region = `notes-${uuidv4().slice(0, 8)}`;
|
||||
const grant = `kv-share-read:${owner.uuid}:os-global:${region}`;
|
||||
await inCtx(ownerActor, () =>
|
||||
server.services.permission.grantUserUserPermission(
|
||||
ownerActor,
|
||||
holder.username,
|
||||
grant,
|
||||
),
|
||||
);
|
||||
expect(await server.services.permission.check(holderActor, grant)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const asked = [
|
||||
grant,
|
||||
`kv-share:${owner.uuid}:os-global:${region}:inner`,
|
||||
`kv-share-write:${owner.uuid}:os-global:${region}`,
|
||||
];
|
||||
const res = makeRes();
|
||||
await inCtx(holderActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: asked }, { actor: holderActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
permissions: Object.fromEntries(asked.map((p) => [p, false])),
|
||||
});
|
||||
|
||||
// What `puter.perms.check('kvShare', …)` asks.
|
||||
const manage = `manage:kv-share-read:${owner.uuid}:os-global:${region}`;
|
||||
const ownerRes = makeRes();
|
||||
await inCtx(ownerActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [manage] }, { actor: ownerActor }),
|
||||
ownerRes,
|
||||
),
|
||||
);
|
||||
expect(ownerRes.body).toEqual({ permissions: { [manage]: true } });
|
||||
});
|
||||
|
||||
it('check-permissions: answers false for share grants asked by an app actor too', async () => {
|
||||
const { user: owner, actor: ownerActor } = await makeUserAndActor();
|
||||
const { user: holder, actor: holderActor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `cpk-${uuidv4()}`,
|
||||
title: 'TestCheckPermsKvApp',
|
||||
index_url: 'https://check-perms-kv.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: holder.id },
|
||||
);
|
||||
const grant = `kv-share-read:${owner.uuid}:${app.uid}:notes-${uuidv4().slice(0, 8)}`;
|
||||
await inCtx(ownerActor, () =>
|
||||
server.services.permission.grantUserUserPermission(
|
||||
ownerActor,
|
||||
holder.username,
|
||||
grant,
|
||||
),
|
||||
);
|
||||
// What launching the app records for the holder.
|
||||
await server.services.permission.grantUserAppPermission(
|
||||
holderActor,
|
||||
app.uid,
|
||||
'flag:app-is-authenticated',
|
||||
);
|
||||
const appActor = makeActor({
|
||||
user: holderActor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
});
|
||||
// The holder's app in the share's namespace holds it.
|
||||
expect(await server.services.permission.check(appActor, grant)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [grant] }, { actor: appActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({ permissions: { [grant]: false } });
|
||||
});
|
||||
|
||||
// What lets a permission request settle without a prompt. The second string
|
||||
// is a consent scope nothing implies, so the first's `true` is the grant.
|
||||
it('check-permissions: an app-under-user actor sees a grant made to that app', async () => {
|
||||
@@ -7579,7 +8108,7 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const permission = 'service:tl-app:ii:read';
|
||||
const permission = 'events:background';
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
|
||||
@@ -9288,6 +9817,70 @@ describe('AuthController grant/revoke additional branches', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('revoke-user-app: 400 on `deleteData` without "*" or as a non-boolean', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `tr-delete-data-${uuidv4()}`,
|
||||
title: 'TestDeleteDataApp',
|
||||
index_url: `https://tr-delete-data-${uuidv4()}.example.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id },
|
||||
);
|
||||
for (const body of [
|
||||
{ permission: 'events:background', deleteData: true },
|
||||
{ permission: '*', deleteData: 'yes' },
|
||||
]) {
|
||||
await expect(
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq({ app_uid: app.uid, ...body }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('revoke-user-app `*`: announces the uninstall, with `deleteData` as sent', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `tr-uninstall-${uuidv4()}`,
|
||||
title: 'TestUninstallApp',
|
||||
index_url: `https://tr-uninstall-${uuidv4()}.example.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id },
|
||||
);
|
||||
const seen: unknown[] = [];
|
||||
const listener = (_key: string, data: unknown) => {
|
||||
seen.push(data);
|
||||
};
|
||||
server.clients.event.on('app.uninstalled', listener);
|
||||
try {
|
||||
for (const deleteData of [undefined, true]) {
|
||||
await inCtx(actor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: '*', deleteData },
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
server.clients.event.off('app.uninstalled', listener);
|
||||
}
|
||||
const expected = {
|
||||
userId: actor.user.id,
|
||||
userUuid: actor.user.uuid,
|
||||
appUid: app.uid,
|
||||
};
|
||||
expect(seen).toEqual([
|
||||
{ ...expected, deleteData: false },
|
||||
{ ...expected, deleteData: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('revoke-user-app: 400 when permission is "*" but app_uid is missing', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await expect(
|
||||
@@ -10215,7 +10808,7 @@ describe('AuthController — app-data grants', () => {
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission: 'app-data:app-nope/:kv:read',
|
||||
permission: `app-data:app-${uuidv4()}:kv:read`,
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
@@ -10282,7 +10875,7 @@ describe('AuthController — app-data grants', () => {
|
||||
|
||||
it('leaves unrelated permissions untouched by the new validation', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const permission = 'service:unrelated:ii:read';
|
||||
const permission = 'events:background';
|
||||
await post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission,
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
hasVerifiedPhone,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import { isAppActor, isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
consumeRouteRateLimit,
|
||||
@@ -88,13 +88,16 @@ import {
|
||||
import { promoteToVerifiedGroup } from '../../util/userProvisioning.js';
|
||||
import {
|
||||
assertBoundedManageGrant,
|
||||
assertNewShareSegments,
|
||||
assertPrefixFitsAccess,
|
||||
isKvSharePermission,
|
||||
} from '../../services/events/kvShares.js';
|
||||
} from '../../services/kvShare/kvShares.js';
|
||||
import {
|
||||
APP_DATA_PERMISSION_PREFIX,
|
||||
appDataSharingAllowed,
|
||||
parseAppDataPermission,
|
||||
} from '../../services/permission/appDataScopes.js';
|
||||
import { parseAppGrantablePermission } from '../../services/permission/appGrantable.js';
|
||||
import {
|
||||
assertCreatablePath,
|
||||
isOwnHomePath,
|
||||
@@ -2997,6 +3000,45 @@ 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
|
||||
@@ -3175,6 +3217,7 @@ 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
|
||||
@@ -3187,10 +3230,6 @@ 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!,
|
||||
@@ -3265,6 +3304,14 @@ 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,
|
||||
@@ -3288,7 +3335,8 @@ export class AuthController extends PuterController {
|
||||
})
|
||||
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body ?? {};
|
||||
const { origin, permission, permissions, meta } = req.body ?? {};
|
||||
const { origin, permission, permissions, meta, deleteData } =
|
||||
req.body ?? {};
|
||||
this.#validateAppPermissionParams({
|
||||
app_uid,
|
||||
origin,
|
||||
@@ -3304,20 +3352,25 @@ 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.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,
|
||||
);
|
||||
await this.services.auth.uninstallApp(req.actor!, app_uid, {
|
||||
deleteData: deleteData === true,
|
||||
meta: meta ?? undefined,
|
||||
});
|
||||
} else {
|
||||
for (const entry of list) {
|
||||
await this.services.permission.revokeUserAppPermission(
|
||||
@@ -3397,11 +3450,28 @@ export class AuthController extends PuterController {
|
||||
const unique = [...new Set(permissions)] as string[];
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
// `fs:` reaches down a tree, and only ACL walks that.
|
||||
// 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.
|
||||
const viaAcl = new Set(
|
||||
app_uid ? unique.filter((p) => aclAnswerableFsPermission(p)) : [],
|
||||
app_uid || isAppActor(actor)
|
||||
? answerable.filter((p) => aclAnswerableFsPermission(p))
|
||||
: [],
|
||||
);
|
||||
const viaScan = unique.filter((p) => !viaAcl.has(p));
|
||||
const viaScan = answerable.filter((p) => !viaAcl.has(p));
|
||||
|
||||
let granted: Map<string, boolean>;
|
||||
try {
|
||||
@@ -3748,8 +3818,9 @@ export class AuthController extends PuterController {
|
||||
|
||||
let app = await this.stores.app.getByUid(app_uid);
|
||||
if (!app && resolvedFromOrigin) {
|
||||
// Hosted-subdomain origins get the site owner stamped as the
|
||||
// app's creator at bootstrap; external origins stay unowned.
|
||||
// Hosted-subdomain and custom-domain 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);
|
||||
@@ -3765,9 +3836,10 @@ 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 *this* path can reuse
|
||||
// a uid: `AppStore.create` mints a random uuid4, which no deleted
|
||||
// app can ever hold again.
|
||||
// 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.
|
||||
//
|
||||
// Called directly rather than through `app.changed`: the token is
|
||||
// issued below, so this has to be able to stop that, and
|
||||
@@ -3778,6 +3850,9 @@ 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`, {
|
||||
@@ -4213,7 +4288,27 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
this.#requireAccountTwoFactor(user, req.body?.code);
|
||||
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);
|
||||
}
|
||||
|
||||
await this.stores.user.update(user.id, {
|
||||
otp_enabled: false,
|
||||
@@ -4423,11 +4518,14 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
const token = signStepUpToken(this.services.token, user as never);
|
||||
res.cookie(
|
||||
STEP_UP_COOKIE_NAME,
|
||||
token,
|
||||
stepUpCookieOptions(this.config),
|
||||
);
|
||||
const isApiHost = req.subdomains?.at(-1) === 'api';
|
||||
if (!isApiHost) {
|
||||
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
|
||||
@@ -4436,7 +4534,9 @@ 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 =
|
||||
!!req.token && req.token === req.cookies?.[cookieName];
|
||||
!isApiHost &&
|
||||
!!req.token &&
|
||||
req.token === req.cookies?.[cookieName];
|
||||
res.json(
|
||||
usedSessionCookie ? { elevated: true } : { elevated: true, token },
|
||||
);
|
||||
@@ -4549,13 +4649,19 @@ export class AuthController extends PuterController {
|
||||
await checkRateLimit(`${scope}:${userUid}`, limit, window);
|
||||
}
|
||||
|
||||
/** An `otp-login` JWT completes one sign-in; a second redemption is refused. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.', {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { Context, 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,6 +278,86 @@ 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
|
||||
|
||||
@@ -50,6 +50,7 @@ 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>;
|
||||
|
||||
@@ -259,6 +260,17 @@ 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,
|
||||
@@ -395,10 +407,9 @@ 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 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.
|
||||
// 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.
|
||||
const rateLimitSpec = resolvePerMethod(driverMeta?.rateLimit, method);
|
||||
if (
|
||||
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
|
||||
@@ -430,18 +441,8 @@ export class DriverController extends PuterController {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
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();
|
||||
slot.attach(handle, res);
|
||||
Context.set('keepDriverSlot', (work) => slot.keep(work));
|
||||
}
|
||||
|
||||
// Stash the requested driver name in Context so multi-provider
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,24 @@ 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.
|
||||
*
|
||||
@@ -154,8 +172,9 @@ export class EventsController extends PuterController {
|
||||
// -- Cross-user key-value handles --------------------------------
|
||||
|
||||
/**
|
||||
* POST /events/kv-handles — hand another user a watchable region of this
|
||||
* account's key-value data.
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
@@ -167,12 +186,32 @@ 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);
|
||||
res.json(
|
||||
await this.services.events.mintKvHandle(actor, bodyRecord(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({ handle: share.handle, prefix: share.prefix });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +228,7 @@ export class EventsController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.services.events.listKvHandles(actor, {
|
||||
const page = await this.services.kvShare.listKvHandles(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: KV_HANDLE_LIST_LIMIT_CAP,
|
||||
}),
|
||||
@@ -198,7 +237,10 @@ export class EventsController extends PuterController {
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items,
|
||||
items: page.items.map(({ holderUsername, ...row }) => ({
|
||||
...row,
|
||||
granteeUsername: holderUsername,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
@@ -217,7 +259,10 @@ export class EventsController extends PuterController {
|
||||
async revokeKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.revokeKvHandle(actor, req.params.handle),
|
||||
await this.services.kvShare.revokeKvHandle(
|
||||
actor,
|
||||
req.params.handle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,14 +139,14 @@ describe('the delivery budgets', () => {
|
||||
});
|
||||
|
||||
it('pins the share-handle quotas by plan', () => {
|
||||
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
|
||||
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
|
||||
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(25_000);
|
||||
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(10_000);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(200);
|
||||
).toBe(5_000);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(128);
|
||||
).toBe(2_000);
|
||||
});
|
||||
|
||||
it('keeps `single` well under broadcast — each one costs far more', () => {
|
||||
|
||||
@@ -93,6 +93,18 @@ 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.
|
||||
*
|
||||
@@ -125,6 +137,13 @@ 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.
|
||||
*
|
||||
@@ -155,6 +174,13 @@ 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.
|
||||
*
|
||||
@@ -164,22 +190,67 @@ export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
|
||||
export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
|
||||
|
||||
/**
|
||||
* Live share handles one account may hold out at a time, across every app.
|
||||
*
|
||||
* 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.
|
||||
* `kv.share.received` notices per hour, per owner and holder. A share past it
|
||||
* is still made, without a notice.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
|
||||
export const KV_SHARE_NOTICE_LIMIT = userWindow(
|
||||
'kvShare:notices',
|
||||
5,
|
||||
60 * 60_000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Live share handles one app may hold out for one account.
|
||||
* Live share handles one account may hold out at a time, across every app.
|
||||
*
|
||||
* Separately bounded from the account cap so one namespace cannot consume the
|
||||
* whole budget on free accounts.
|
||||
* 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.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(25_000, 5_000, 0);
|
||||
|
||||
/**
|
||||
* Live share handles one app may hold out for one account, so one namespace
|
||||
* cannot consume the whole account budget.
|
||||
*/
|
||||
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;
|
||||
|
||||
// -- Handler surface -------------------------------------------------
|
||||
|
||||
@@ -277,8 +348,11 @@ export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
|
||||
*/
|
||||
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
|
||||
|
||||
/** Omit all inline KV values when the matched audience exceeds this count. */
|
||||
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Largest inline KV value in serialized bytes. Larger values are omitted;
|
||||
@@ -370,7 +444,9 @@ 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.
|
||||
// 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.
|
||||
|
||||
/** Wait before the first retry of a delivery a handler could not take. */
|
||||
export const EVENTS_RETRY_BASE_MS = 2_000;
|
||||
@@ -379,7 +455,7 @@ export const EVENTS_RETRY_BASE_MS = 2_000;
|
||||
export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Failures in a row before a subscription is suspended. Counted per
|
||||
* Handler 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.
|
||||
*/
|
||||
|
||||
@@ -49,6 +49,8 @@ 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' },
|
||||
@@ -60,16 +62,20 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
clients: { redis },
|
||||
stores: {
|
||||
eventHandler: {
|
||||
allForApp: async (appUid: string) => [
|
||||
{
|
||||
appUid,
|
||||
name: 'h',
|
||||
source: 'async () => {}',
|
||||
sourceHash: SOURCE_HASH,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
],
|
||||
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,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
app: {
|
||||
getByUid: async (appUid: string) => {
|
||||
@@ -104,7 +110,12 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
},
|
||||
},
|
||||
};
|
||||
return { layers: layers as unknown as Layers, created, redis };
|
||||
return {
|
||||
layers: layers as unknown as Layers,
|
||||
created,
|
||||
redis,
|
||||
sourceReads,
|
||||
};
|
||||
};
|
||||
|
||||
describe('EventsWorkerDeployer.ensure', () => {
|
||||
@@ -127,8 +138,10 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
expect(b).toBe('no-owner');
|
||||
});
|
||||
|
||||
it('refuses once the app has deployed past the hourly cap', async () => {
|
||||
const { layers, redis } = makeLayers({ 'app-c': { ownerUserId: 3 } });
|
||||
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 },
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
await redis.set(
|
||||
@@ -137,6 +150,24 @@ 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 () => {
|
||||
|
||||
@@ -26,10 +26,14 @@ import {
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
INTERNAL_DEPLOY_TARGET,
|
||||
} from '../../drivers/workers/WorkerDriver.js';
|
||||
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type {
|
||||
EventsInvokeAnswer,
|
||||
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 {
|
||||
@@ -37,7 +41,10 @@ import {
|
||||
eventsWorkerScope,
|
||||
eventsWorkerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import { generateEventsWorkerSource } from '../../services/events/workerSource.js';
|
||||
import {
|
||||
generateEventsWorkerSource,
|
||||
handlerSetHash,
|
||||
} 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';
|
||||
@@ -113,20 +120,24 @@ export class EventsWorkerDeployer {
|
||||
script: string,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const { stores, drivers } = this.#layers;
|
||||
const scope = eventsWorkerScope(this.#layers.config);
|
||||
|
||||
const handlers = await stores.eventHandler.allForApp(appUid);
|
||||
if (handlers.length === 0) return 'no-handlers';
|
||||
|
||||
const generated = generateEventsWorkerSource(handlers);
|
||||
// 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';
|
||||
// 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(
|
||||
generated.setHash,
|
||||
eventsWorkerScope(this.#layers.config),
|
||||
) !== script
|
||||
)
|
||||
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)
|
||||
return 'stale';
|
||||
if (generated.tooLarge) {
|
||||
console.error(
|
||||
@@ -140,8 +151,6 @@ export class EventsWorkerDeployer {
|
||||
generated.broken.join(', '),
|
||||
);
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
|
||||
|
||||
const owner = await this.#owner(appUid);
|
||||
if (!owner) return 'no-owner';
|
||||
|
||||
@@ -289,11 +298,7 @@ export class LocalEventsInvokeTransport {
|
||||
this.#deployer = deployer;
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
const request = (): Request =>
|
||||
new Request(
|
||||
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
|
||||
@@ -323,10 +328,7 @@ export class LocalEventsInvokeTransport {
|
||||
return { status: null, error: 'script not resident' };
|
||||
return retried;
|
||||
} catch (err) {
|
||||
return {
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
return failedAnswer(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +339,7 @@ export class LocalEventsInvokeTransport {
|
||||
async #dispatch(
|
||||
request: Request,
|
||||
timeoutMs: number,
|
||||
): Promise<{ status: number; handled: boolean } | null> {
|
||||
): Promise<EventsInvokeAnswer | null> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
||||
@@ -348,12 +350,10 @@ export class LocalEventsInvokeTransport {
|
||||
this.#services.localworkerservice.dispatchEventsWorker(request),
|
||||
timeout,
|
||||
]);
|
||||
if (response === 'timeout') throw new Error('invocation timed out');
|
||||
if (response === 'timeout')
|
||||
throw new DOMException('invocation timed out', 'TimeoutError');
|
||||
if (response === null) return null;
|
||||
return {
|
||||
status: response.status,
|
||||
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
|
||||
};
|
||||
return answerFrom(response);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -1335,6 +1335,7 @@ export class FSController extends PuterController {
|
||||
parent.path,
|
||||
{
|
||||
limit,
|
||||
offset,
|
||||
cursor:
|
||||
typeof body.cursor === 'string'
|
||||
? body.cursor
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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,6 +153,34 @@ 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) => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
});
|
||||
@@ -5293,4 +5293,39 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
assertResolvedActor,
|
||||
isAccessTokenActor,
|
||||
isAppActor,
|
||||
isPlainUserActor,
|
||||
makeActor,
|
||||
} from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -95,6 +96,7 @@ import {
|
||||
assertAccess,
|
||||
assertCanCreate,
|
||||
assertCanMoveInto,
|
||||
contextBoundStream,
|
||||
emitGuiItemEvent,
|
||||
expandClientPath,
|
||||
findOverwriteTarget,
|
||||
@@ -102,6 +104,7 @@ import {
|
||||
getString,
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
rangeForEntry,
|
||||
replacedByMove,
|
||||
resolveReadRange,
|
||||
resolveV1Selector,
|
||||
@@ -381,7 +384,9 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
let recent: unknown[] = [];
|
||||
const userId = req.actor?.user?.id;
|
||||
if (userId) {
|
||||
// `recent=0`: a caller that only shows `recommended`.
|
||||
const wantsRecent = req.query.recent !== '0';
|
||||
if (userId && wantsRecent) {
|
||||
const recentUids = await this.stores.app.getRecentAppOpens(
|
||||
userId,
|
||||
{ limit: 10 },
|
||||
@@ -418,9 +423,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() }
|
||||
@@ -801,10 +806,23 @@ export class LegacyFSController extends PuterController {
|
||||
this.stores.fsEntry,
|
||||
body.source,
|
||||
);
|
||||
const destinationParent = await resolveV1Selector(
|
||||
const destination = 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,
|
||||
@@ -829,14 +847,14 @@ export class LegacyFSController extends PuterController {
|
||||
? await findOverwriteTarget(
|
||||
this.stores.fsEntry,
|
||||
destinationParent,
|
||||
getString(body, 'new_name') ?? source.name,
|
||||
newName ?? source.name,
|
||||
)
|
||||
: null;
|
||||
|
||||
const copy = await this.services.fs.copy(userId, {
|
||||
source,
|
||||
destinationParent,
|
||||
newName: getString(body, 'new_name'),
|
||||
newName,
|
||||
overwrite: overwriteRequested,
|
||||
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
|
||||
});
|
||||
@@ -1289,7 +1307,10 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const range = rangeForEntry(
|
||||
entry,
|
||||
resolveReadRange(req.headers.range, query),
|
||||
);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -1790,19 +1811,50 @@ 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 children = await this.services.fs.listDirectory(entry.uuid);
|
||||
const signedChildren = children.map((child) => {
|
||||
const signChild = (child: FSEntry) => {
|
||||
const { write_url: _, ...rest } = signEntry(child, signingCfg, {
|
||||
ttlSeconds: NON_OWNER_SIGNATURE_TTL_SECONDS,
|
||||
});
|
||||
return { ...rest, path: maskEntryPath(child) };
|
||||
});
|
||||
res.json(signedChildren);
|
||||
};
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
// File: stream bytes with Range support.
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const range = rangeForEntry(
|
||||
entry,
|
||||
resolveReadRange(req.headers.range, query),
|
||||
);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -1992,6 +2044,11 @@ 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;
|
||||
@@ -2022,7 +2079,7 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
|
||||
let app: { uid: string } | null = null;
|
||||
let app: { uid: string; id: number } | null = null;
|
||||
if (typeof appRef === 'string') {
|
||||
app =
|
||||
(await this.stores.app.getByUid(appRef)) ??
|
||||
@@ -2036,7 +2093,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Build an actor-under-user shape for the check.
|
||||
const actorForApp = makeActor({
|
||||
user: actor.user,
|
||||
app: { uid: (app as { uid: string }).uid },
|
||||
app: { uid: app.uid, id: app.id },
|
||||
});
|
||||
const allowed = await acl.check(actorForApp, descriptor, mode);
|
||||
res.json({ allowed });
|
||||
@@ -2084,10 +2141,12 @@ export class LegacyFSController extends PuterController {
|
||||
// by default, so opt in by naming the downloader as who pays.
|
||||
req.egressActor = actor;
|
||||
|
||||
const range =
|
||||
const range = rangeForEntry(
|
||||
entry,
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
: undefined,
|
||||
);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
|
||||
@@ -17,15 +17,19 @@
|
||||
* 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,
|
||||
@@ -705,3 +709,50 @@ describe('resolveReadRange', () => {
|
||||
).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,7 +17,9 @@
|
||||
* 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';
|
||||
@@ -150,6 +152,29 @@ export function resolveReadRange(
|
||||
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,6 +17,9 @@
|
||||
* 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';
|
||||
@@ -466,3 +469,63 @@ 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,6 +198,20 @@ 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) },
|
||||
{ subdomain: site.subdomain, uid: String(site.uuid), userId },
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -27,6 +27,7 @@ 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';
|
||||
@@ -58,6 +59,7 @@ export const puterControllers = {
|
||||
broadcast: BroadcastController,
|
||||
notification: NotificationController,
|
||||
events: EventsController,
|
||||
kvShare: KvShareController,
|
||||
share: ShareController,
|
||||
webdav: WebDAVController,
|
||||
oidc: OIDCController,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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 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.
|
||||
* 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.
|
||||
*/
|
||||
describe('notification endpoints over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
@@ -85,4 +85,34 @@ 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,3 +183,74 @@ 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,16 +20,44 @@
|
||||
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 endpoint. Supplements the `puter-notifications`
|
||||
* driver (which handles CRUD via `/drivers/call`) with the one mutation route
|
||||
* the puter desktop client calls directly.
|
||||
* GUI-facing notification endpoints. Supplement the `puter-notifications`
|
||||
* driver (which handles reads via `/drivers/call`) with the mutations the puter
|
||||
* desktop client calls directly.
|
||||
*
|
||||
* It emits `outer.gui.notif.ack` via the NotificationService so other open tabs
|
||||
* for the same user see the state change immediately.
|
||||
* Each goes through the NotificationService, which tells the user's other open
|
||||
* tabs (`notif.ack`, `notif.removed`) so they update immediately.
|
||||
*/
|
||||
@Controller('/notif')
|
||||
export class NotificationController extends PuterController {
|
||||
@@ -37,50 +65,21 @@ export class NotificationController extends PuterController {
|
||||
* POST /notif/mark-ack — user dismissed a notification. Sets `acknowledged`
|
||||
* timestamp; pushes ack event to sockets.
|
||||
*/
|
||||
@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',
|
||||
},
|
||||
})
|
||||
@Post('/mark-ack', MARK_ROUTE_OPTIONS)
|
||||
async markAck(req: Request, res: Response): Promise<void> {
|
||||
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',
|
||||
});
|
||||
const { uid, userId } = markTarget(req);
|
||||
await this.services.notification.markAcknowledged(uid, userId);
|
||||
res.json({});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
res.json({});
|
||||
}
|
||||
}
|
||||
@@ -511,6 +511,31 @@ 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,6 +448,7 @@ export class PuterAIController extends PuterController {
|
||||
}
|
||||
: {}),
|
||||
...openaiCompatProvider(body),
|
||||
...(body.fallback === false ? { fallback: false } : {}),
|
||||
};
|
||||
|
||||
const result = await this.#complete(res, completeArgs);
|
||||
@@ -545,8 +546,7 @@ export class PuterAIController extends PuterController {
|
||||
}
|
||||
} else if (ev.type === 'usage') {
|
||||
usageDetails = ev.usageDetails as
|
||||
| UsageDetails
|
||||
| undefined;
|
||||
UsageDetails | undefined;
|
||||
finishReason = ev.finish_reason as string | undefined;
|
||||
} else if (ev.type === 'error') {
|
||||
res.write(
|
||||
@@ -649,6 +649,7 @@ export class PuterAIController extends PuterController {
|
||||
: {}),
|
||||
...(finiteMaxTokens(body.max_tokens) ?? {}),
|
||||
...openaiCompatProvider(body),
|
||||
...(body.fallback === false ? { fallback: false } : {}),
|
||||
};
|
||||
|
||||
const completionId = `cmpl-${randomId()}`;
|
||||
@@ -694,8 +695,7 @@ export class PuterAIController extends PuterController {
|
||||
sendChunk(ev.text);
|
||||
} else if (ev.type === 'usage') {
|
||||
usageDetails = ev.usageDetails as
|
||||
| UsageDetails
|
||||
| undefined;
|
||||
UsageDetails | undefined;
|
||||
finishReason = ev.finish_reason as string | undefined;
|
||||
} else if (ev.type === 'error') {
|
||||
res.write(
|
||||
@@ -749,8 +749,7 @@ export class PuterAIController extends PuterController {
|
||||
text: extractTextContent(
|
||||
(
|
||||
messageResult.message as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
Record<string, unknown> | undefined
|
||||
)?.content,
|
||||
),
|
||||
index: 0,
|
||||
@@ -869,6 +868,7 @@ export class PuterAIController extends PuterController {
|
||||
}
|
||||
: {}),
|
||||
provider: providerName,
|
||||
...(body.fallback === false ? { fallback: false } : {}),
|
||||
};
|
||||
|
||||
const responseId = generateId('resp');
|
||||
|
||||
@@ -324,6 +324,18 @@ 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,6 +431,7 @@ 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,6 +151,12 @@ 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', () => {
|
||||
@@ -186,6 +192,11 @@ 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, /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, /push-sw.js,
|
||||
* /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,6 +55,12 @@ 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
|
||||
@@ -100,6 +106,11 @@ 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,146 @@
|
||||
/*
|
||||
* 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 { runWithContext } from '../../core/context.js';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
TEST_OTHER_USER_CREDENTIALS,
|
||||
TEST_USER_CREDENTIALS,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
/** PROPFIND has no paging, so a folder must list whole however large. */
|
||||
describe('PROPFIND on a folder larger than one query', () => {
|
||||
let env: PuterTestEnv;
|
||||
const total = 10_050;
|
||||
let dir: string;
|
||||
let dirUuid: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
const { username } = env.users.user;
|
||||
dir = `/${username}/Documents/big-${randomUUID().slice(0, 8)}`;
|
||||
const owner = (await env.server.stores.user.getByUsername(username))!;
|
||||
const parent = (await env.server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
dirUuid = randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const insert = (rows: unknown[][]) =>
|
||||
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(),
|
||||
);
|
||||
await insert([
|
||||
[
|
||||
dirUuid,
|
||||
owner.id,
|
||||
parent.id,
|
||||
parent.uuid,
|
||||
dir.split('/').at(-1),
|
||||
dir,
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
0,
|
||||
],
|
||||
]);
|
||||
const folder = (await env.server.stores.fsEntry.getEntryByPath(dir))!;
|
||||
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(),
|
||||
owner.id,
|
||||
folder.id,
|
||||
folder.uuid,
|
||||
name,
|
||||
`${dir}/${name}`,
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
0,
|
||||
]);
|
||||
}
|
||||
await insert(rows);
|
||||
}
|
||||
|
||||
const actor = { user: owner };
|
||||
await runWithContext({ actor: actor as never }, () =>
|
||||
env.server.services.share.share(actor as never, {
|
||||
uid: dirUuid,
|
||||
recipient: { username: env.users.other.username },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const propfind = async (
|
||||
path: string,
|
||||
credentials: { username: string; password: string },
|
||||
) => {
|
||||
const port = new URL(env.apiOrigin).port;
|
||||
const auth = Buffer.from(
|
||||
`${credentials.username}:${credentials.password}`,
|
||||
).toString('base64');
|
||||
const response = await fetch(
|
||||
`http://dav.puter.localhost:${port}${encodeURI(path)}`,
|
||||
{
|
||||
method: 'PROPFIND',
|
||||
headers: { Authorization: `Basic ${auth}`, Depth: '1' },
|
||||
},
|
||||
);
|
||||
return { status: response.status, body: await response.text() };
|
||||
};
|
||||
|
||||
const hrefsOf = (xml: string) =>
|
||||
[...xml.matchAll(/<D:href>([^<]*)<\/D:href>/g)].map((m) => m[1]!);
|
||||
|
||||
it('lists every child', async () => {
|
||||
const { status, body } = await propfind(dir, TEST_USER_CREDENTIALS);
|
||||
expect(status).toBe(207);
|
||||
expect(body.endsWith('</D:multistatus>')).toBe(true);
|
||||
const hrefs = hrefsOf(body);
|
||||
expect(hrefs).toHaveLength(1 + total);
|
||||
expect(new Set(hrefs).size).toBe(1 + total);
|
||||
expect(body).toContain(`${dir}/f10049.txt`);
|
||||
}, 60_000);
|
||||
|
||||
it("masks every page of a share recipient's listing", async () => {
|
||||
const masked = `/${env.users.user.username}/${dirUuid}/${dir.split('/').at(-1)}`;
|
||||
const { status, body } = await propfind(
|
||||
masked,
|
||||
TEST_OTHER_USER_CREDENTIALS,
|
||||
);
|
||||
expect(status).toBe(207);
|
||||
const hrefs = hrefsOf(body);
|
||||
expect(hrefs).toHaveLength(1 + total);
|
||||
expect(hrefs.every((href) => href.startsWith(masked))).toBe(true);
|
||||
expect(body).not.toContain('/Documents/');
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { posix as pathPosix } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertTeam2fa,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
import { Context } from '../../core/context.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import {
|
||||
contextBoundStream,
|
||||
emitGuiItemEvent,
|
||||
type GuiItemEventName,
|
||||
} from '../fs/legacyFsHelpers.js';
|
||||
@@ -600,19 +602,41 @@ export class WebDAVController extends PuterController {
|
||||
propfindEntry(maskEntryPath(entry), entry, entry.isDir),
|
||||
];
|
||||
|
||||
if (depth !== '0' && entry.isDir) {
|
||||
const children = await this.services.fs.listDirectory(
|
||||
entry.uuid,
|
||||
{},
|
||||
);
|
||||
for (const child of children) {
|
||||
responses.push(
|
||||
propfindEntry(maskEntryPath(child), child, child.isDir),
|
||||
);
|
||||
}
|
||||
if (depth === '0' || !entry.isDir) {
|
||||
this.#sendMultistatus(res, responses);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#sendMultistatus(res, responses);
|
||||
const toXml = (children: FSEntry[]) =>
|
||||
children
|
||||
.map((child) =>
|
||||
propfindEntry(maskEntryPath(child), child, child.isDir),
|
||||
)
|
||||
.join('\n');
|
||||
const pages = this.services.fs.listDirectoryPages(entry.uuid);
|
||||
const first = await pages.next();
|
||||
if (!first.done && first.value.length > 0)
|
||||
responses.push(toXml(first.value));
|
||||
const second = await pages.next();
|
||||
if (second.done) {
|
||||
this.#sendMultistatus(res, responses);
|
||||
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;
|
||||
async function* body() {
|
||||
yield `${MULTISTATUS_OPEN}${responses.join('\n')}`;
|
||||
yield `\n${toXml(secondPage)}`;
|
||||
for await (const page of pages)
|
||||
if (page.length > 0) yield `\n${toXml(page)}`;
|
||||
yield MULTISTATUS_CLOSE;
|
||||
}
|
||||
res.status(207).set({
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
});
|
||||
await pipeToResponse(contextBoundStream(body()), res);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1289,8 +1313,12 @@ function escapeXml(text: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const MULTISTATUS_OPEN =
|
||||
'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n';
|
||||
const MULTISTATUS_CLOSE = '\n</D:multistatus>';
|
||||
|
||||
function wrapMultistatus(inner: string): string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n${inner}\n</D:multistatus>`;
|
||||
return `${MULTISTATUS_OPEN}${inner}${MULTISTATUS_CLOSE}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
makeActor,
|
||||
SYSTEM_ACTOR,
|
||||
userRelatedActor,
|
||||
viaWorkerOf,
|
||||
type Actor,
|
||||
} from './actor';
|
||||
|
||||
@@ -111,3 +112,28 @@ describe('assertResolvedActor', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viaWorkerOf', () => {
|
||||
const user = { uuid: 'u-1', id: 1 };
|
||||
|
||||
it('reads a worker session or an events handler as server-side', () => {
|
||||
expect(
|
||||
viaWorkerOf(
|
||||
makeActor({ user, session: { uid: 's-1', kind: 'worker' } }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(viaWorkerOf(makeActor({ user, handlerDepth: 1 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a browser session, an app or no actor at all as not', () => {
|
||||
expect(
|
||||
viaWorkerOf(
|
||||
makeActor({ user, session: { uid: 's-1', kind: 'web' } }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(viaWorkerOf(makeActor({ user, app: { uid: 'app-1' } }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(viaWorkerOf(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -239,3 +239,10 @@ export const userRelatedActor = (actor: Actor): Actor => {
|
||||
/** How many events handler runs led to a write by this actor. */
|
||||
export const handlerDepthOf = (actor: Actor | undefined | null): number =>
|
||||
actor?.handlerDepth ?? 0;
|
||||
|
||||
/**
|
||||
* Whether a write by this actor was made by server-side code running as the
|
||||
* account: a worker session, or an events handler.
|
||||
*/
|
||||
export const viaWorkerOf = (actor: Actor | undefined | null): boolean =>
|
||||
actor?.session?.kind === 'worker' || handlerDepthOf(actor) > 0;
|
||||
@@ -63,10 +63,17 @@ export interface KnownContextFields {
|
||||
driverName: string;
|
||||
/**
|
||||
* Aborts when the client disconnects before the response has finished (set
|
||||
* by DriverController). Long-running drivers poll it so work nobody will
|
||||
* receive stops early and is never metered.
|
||||
* by DriverController). Some drivers (Gemini, Replicate) stop early on it.
|
||||
* Async video and image jobs keep polling and are metered after a
|
||||
* disconnect, since the provider bills them either way.
|
||||
*/
|
||||
abortSignal: AbortSignal;
|
||||
/**
|
||||
* Keeps the call's concurrency slot until `work` settles (set by
|
||||
* DriverController when the method is capped), for work that outlives the
|
||||
* response.
|
||||
*/
|
||||
keepDriverSlot: (work: Promise<unknown>) => void;
|
||||
/**
|
||||
* Aborts the chat driver's upstream request. A client disconnect doesn't
|
||||
* fire it: the upstream bills a request it has accepted either way, so the
|
||||
@@ -80,6 +87,11 @@ export interface KnownContextFields {
|
||||
* different provider's translation of it.
|
||||
*/
|
||||
strictUpstreamErrors: boolean;
|
||||
/**
|
||||
* Set while Puter files something into an account for it (mail delivery),
|
||||
* so filesystem events never report that write as the request actor's own.
|
||||
*/
|
||||
platformWrite: boolean;
|
||||
}
|
||||
|
||||
// Every key of `KnownContextFields`, checked both ways by the compiler, so
|
||||
@@ -90,8 +102,10 @@ const KNOWN_KEY_FLAGS: Record<keyof KnownContextFields, true> = {
|
||||
requestId: true,
|
||||
driverName: true,
|
||||
abortSignal: true,
|
||||
keepDriverSlot: true,
|
||||
upstreamSignal: true,
|
||||
strictUpstreamErrors: true,
|
||||
platformWrite: true,
|
||||
};
|
||||
const KNOWN_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_KEY_FLAGS));
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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 { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { isHttpError } from '../HttpError';
|
||||
import {
|
||||
createLargeJsonBodyParser,
|
||||
DEFAULT_JSON_LIMIT_BYTES,
|
||||
} from './largeJsonBody';
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
// A complete request with no socket reads as already consumed to body-parser,
|
||||
// so the parsers call straight through, leaving any preset `body` in place.
|
||||
const makeReq = (
|
||||
bytes: number | undefined,
|
||||
user: string | null = 'u1',
|
||||
body?: unknown,
|
||||
) =>
|
||||
({
|
||||
complete: true,
|
||||
body,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(bytes === undefined ? {} : { 'content-length': String(bytes) }),
|
||||
...(user ? { authorization: user } : {}),
|
||||
},
|
||||
}) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const res = new EventEmitter() as EventEmitter & {
|
||||
setHeader: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
res.setHeader = vi.fn();
|
||||
return res;
|
||||
};
|
||||
|
||||
const authProbe = vi.fn<RequestHandler>((req, _res, next) => {
|
||||
if (req.headers.authorization) {
|
||||
(req as { actor?: unknown }).actor = {
|
||||
user: { uuid: req.headers.authorization },
|
||||
};
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
const run = (mw: RequestHandler, req: Request, res = makeRes()) =>
|
||||
new Promise<{ err: unknown; res: ReturnType<typeof makeRes> }>(
|
||||
(resolve) => {
|
||||
mw(
|
||||
req,
|
||||
res as unknown as Response,
|
||||
((err?: unknown) => resolve({ err, res })) as NextFunction,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const statusOf = (err: unknown) => (isHttpError(err) ? err.statusCode : err);
|
||||
|
||||
describe('createLargeJsonBodyParser', () => {
|
||||
const create = ({
|
||||
budgetBytes = 200 * MiB,
|
||||
perUserBytes = 128 * MiB,
|
||||
accepts,
|
||||
}: {
|
||||
budgetBytes?: number;
|
||||
perUserBytes?: number;
|
||||
accepts?: (req: Request) => boolean;
|
||||
} = {}) =>
|
||||
createLargeJsonBodyParser({
|
||||
authProbe,
|
||||
limitBytes: 128 * MiB,
|
||||
budgetBytes,
|
||||
perUserBytes,
|
||||
accepts,
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no Content-Length', undefined],
|
||||
['a body within the default limit', DEFAULT_JSON_LIMIT_BYTES],
|
||||
['a body over the large limit', 128 * MiB + 1],
|
||||
])('leaves %s to the global parser without probing', async (_, bytes) => {
|
||||
authProbe.mockClear();
|
||||
const { err } = await run(create(), makeReq(bytes));
|
||||
expect(err).toBeUndefined();
|
||||
expect(authProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an unauthenticated large body to the global parser', async () => {
|
||||
const mw = create({ budgetBytes: 60 * MiB });
|
||||
const { err } = await run(mw, makeReq(60 * MiB, null));
|
||||
expect(err).toBeUndefined();
|
||||
// Nothing was charged, so a signed-in caller still fits.
|
||||
expect((await run(mw, makeReq(60 * MiB))).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it('charges nothing for a client that left during the probe', async () => {
|
||||
const mw = create({ budgetBytes: 60 * MiB });
|
||||
const gone = Object.assign(makeRes(), { closed: true });
|
||||
expect((await run(mw, makeReq(60 * MiB), gone)).err).toBeUndefined();
|
||||
expect((await run(mw, makeReq(60 * MiB))).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses with 503 past the budget until a response closes', async () => {
|
||||
const mw = create({ budgetBytes: 130 * MiB });
|
||||
const first = await run(mw, makeReq(60 * MiB, 'u1'));
|
||||
await run(mw, makeReq(60 * MiB, 'u2'));
|
||||
|
||||
const refused = await run(mw, makeReq(60 * MiB, 'u3'));
|
||||
expect(statusOf(refused.err)).toBe(503);
|
||||
// Load shedding is recorded, not paged.
|
||||
expect(isHttpError(refused.err) && refused.err.alarmSeverity).toBe(
|
||||
'info',
|
||||
);
|
||||
expect(refused.res.setHeader).toHaveBeenCalledWith('Retry-After', '5');
|
||||
|
||||
first.res.emit('close');
|
||||
expect((await run(mw, makeReq(60 * MiB, 'u3'))).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses one user's bodies with 429 past their share", async () => {
|
||||
const mw = create({ perUserBytes: 100 * MiB });
|
||||
const first = await run(mw, makeReq(60 * MiB, 'u1'));
|
||||
|
||||
const refused = await run(mw, makeReq(60 * MiB, 'u1'));
|
||||
expect(statusOf(refused.err)).toBe(429);
|
||||
expect(refused.res.setHeader).toHaveBeenCalledWith('Retry-After', '5');
|
||||
// Another user isn't held back by it.
|
||||
expect((await run(mw, makeReq(60 * MiB, 'u2'))).err).toBeUndefined();
|
||||
|
||||
first.res.emit('close');
|
||||
expect((await run(mw, makeReq(60 * MiB, 'u1'))).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses with 413 a parsed body `accepts` turns down', async () => {
|
||||
const mw = create({
|
||||
accepts: (req) => (req.body as { ok?: boolean }).ok === true,
|
||||
});
|
||||
const refused = await run(mw, makeReq(60 * MiB, 'u1', { ok: false }));
|
||||
expect(statusOf(refused.err)).toBe(413);
|
||||
expect(
|
||||
(await run(mw, makeReq(60 * MiB, 'u2', { ok: true }))).err,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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 express, { type Request, type RequestHandler } from 'express';
|
||||
import { actorUid } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
|
||||
/** The global JSON parser's limit, which applies before auth. */
|
||||
export const DEFAULT_JSON_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
interface LargeJsonBodyOptions {
|
||||
/** The global auth probe; it skips requests that already carry an actor. */
|
||||
authProbe: RequestHandler;
|
||||
limitBytes: number;
|
||||
/** Declared bytes of large bodies a process holds at once. */
|
||||
budgetBytes: number;
|
||||
/** Declared bytes of large bodies one user holds at once. */
|
||||
perUserBytes: number;
|
||||
/** Checked once the body is parsed; a body it refuses gets a 413. */
|
||||
accepts?: (req: Request) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses JSON bodies over the default limit, up to `limitBytes`, once the
|
||||
* caller's header or cookie credential resolves. Unauthenticated requests and
|
||||
* requests without a Content-Length fall through to the global parser and its
|
||||
* limit.
|
||||
*
|
||||
* The parsed body lives as long as the request, so a body's bytes count against
|
||||
* both budgets until its response closes. Over the user's budget gets a 429,
|
||||
* over the process budget a 503.
|
||||
*/
|
||||
export const createLargeJsonBodyParser = ({
|
||||
authProbe,
|
||||
limitBytes,
|
||||
budgetBytes,
|
||||
perUserBytes,
|
||||
accepts,
|
||||
}: LargeJsonBodyOptions): RequestHandler => {
|
||||
const json = express.json({ limit: limitBytes });
|
||||
const textAsJson = express.json({
|
||||
limit: limitBytes,
|
||||
type: (req) =>
|
||||
req.headers['content-type'] === 'text/plain;actually=json',
|
||||
});
|
||||
let inFlightBytes = 0;
|
||||
const inFlightByUser = new Map<string, number>();
|
||||
|
||||
return (req, res, next) => {
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (!(declared > DEFAULT_JSON_LIMIT_BYTES && declared <= limitBytes)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
void authProbe(req, res, () => {
|
||||
// `res.closed`: the client left during the probe, so `close` has
|
||||
// already fired and would never release a charge.
|
||||
if (!req.actor || res.closed) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const user = req.actor.user.uuid ?? actorUid(req.actor);
|
||||
const userBytes = inFlightByUser.get(user) ?? 0;
|
||||
if (userBytes + declared > perUserBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(
|
||||
429,
|
||||
'Too many large requests in progress for this account',
|
||||
{ legacyCode: 'too_many_requests' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inFlightBytes + declared > budgetBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(503, 'Too many large requests in progress', {
|
||||
code: 'large_body_capacity',
|
||||
// Load shedding, not a fault: record it, don't page.
|
||||
alarmSeverity: 'info',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inFlightBytes += declared;
|
||||
inFlightByUser.set(user, userBytes + declared);
|
||||
res.once('close', () => {
|
||||
inFlightBytes -= declared;
|
||||
const left = (inFlightByUser.get(user) ?? 0) - declared;
|
||||
if (left > 0) inFlightByUser.set(user, left);
|
||||
else inFlightByUser.delete(user);
|
||||
});
|
||||
json(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
textAsJson(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
if (accepts && !accepts(req)) {
|
||||
// The same answer the default limit gives.
|
||||
next(
|
||||
new HttpError(413, 'request entity too large', {
|
||||
legacyCode: 'client_bad_request',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -950,7 +950,8 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts an app-issued access token minted for the target app', async () => {
|
||||
it('ignores an app-issued access token even for the target app', async () => {
|
||||
// A scoped token (e.g. a read URL) is not the account, whoever issued it.
|
||||
const user = await makeUser();
|
||||
const targetUid = `app-target-${uuidv4()}`;
|
||||
const issuer = makeActor({
|
||||
@@ -970,11 +971,11 @@ describe('resolvePrivateIdentity', () => {
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still accepts a user-issued access token, which is portable', async () => {
|
||||
it('ignores a user-issued scoped access token', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
@@ -983,7 +984,46 @@ describe('resolvePrivateIdentity', () => {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a scoped access token when the host has no app', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
});
|
||||
|
||||
it('accepts a full-access token, which holds the account', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer,
|
||||
fullAccess: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { AppStore } from '../../../stores/app/AppStore.js';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { Actor } from '../../actor';
|
||||
import { isAccessTokenActor, isAccountContext, type Actor } from '../../actor';
|
||||
|
||||
/**
|
||||
* Support helpers for the private-app access gate — ported from v1's
|
||||
@@ -460,11 +460,15 @@ export async function resolvePrivateIdentity(opts: {
|
||||
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
|
||||
* an app-issued access token has no `app` of its own and would otherwise pass
|
||||
* as a plain session on any private host.
|
||||
*
|
||||
* A scoped access token (a read URL, whoever issued it) is never the account
|
||||
* here: the hosted cookie minted from it would outlive the token's revocation.
|
||||
*/
|
||||
function actorMatchesExpectedApp(
|
||||
actor: Actor,
|
||||
expectedAppUid: string | undefined,
|
||||
): boolean {
|
||||
if (isAccessTokenActor(actor) && !isAccountContext(actor)) return false;
|
||||
if (!expectedAppUid) return true;
|
||||
const app = actor.effectiveApp;
|
||||
if (!app) return true;
|
||||
@@ -472,9 +476,8 @@ function actorMatchesExpectedApp(
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of `resolvePrivateIdentity` for public hosted apps. Reads the sticky
|
||||
* `puter.public.hosted.actor.token` cookie first, then the same
|
||||
* session/bootstrap fallbacks.
|
||||
* Fresh authenticated identities replace the sticky public billing cookie when
|
||||
* the visitor switches accounts.
|
||||
*/
|
||||
export async function resolvePublicHostedIdentity(opts: {
|
||||
req: Request;
|
||||
@@ -496,37 +499,6 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
const cookies = (req as Request & { cookies?: Record<string, string> })
|
||||
.cookies;
|
||||
|
||||
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
|
||||
HOSTED_ASSET_COOKIES.public;
|
||||
const publicCookieToken =
|
||||
(typeof cookies?.[publicCookieNameV2] === 'string'
|
||||
? cookies[publicCookieNameV2]
|
||||
: null) ??
|
||||
(typeof cookies?.[publicCookieNameLegacy] === 'string'
|
||||
? cookies[publicCookieNameLegacy]
|
||||
: null);
|
||||
if (publicCookieToken) {
|
||||
try {
|
||||
const claims = await authService.verifyHostedAssetToken(
|
||||
'public',
|
||||
publicCookieToken,
|
||||
{
|
||||
appUid: expectedAppUid,
|
||||
subdomain: expectedSubdomain,
|
||||
host: expectedHost,
|
||||
},
|
||||
);
|
||||
return {
|
||||
source: 'public-cookie',
|
||||
userUid: claims.userUid,
|
||||
sessionUuid: claims.sessionUuid,
|
||||
hasValidPublicCookie: true,
|
||||
};
|
||||
} catch {
|
||||
/* fall through — v1-signed cookies land here and get re-minted */
|
||||
}
|
||||
}
|
||||
|
||||
const existingActor = req.actor;
|
||||
if (
|
||||
existingActor?.user?.uuid &&
|
||||
@@ -582,6 +554,37 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
|
||||
HOSTED_ASSET_COOKIES.public;
|
||||
const publicCookieToken =
|
||||
(typeof cookies?.[publicCookieNameV2] === 'string'
|
||||
? cookies[publicCookieNameV2]
|
||||
: null) ??
|
||||
(typeof cookies?.[publicCookieNameLegacy] === 'string'
|
||||
? cookies[publicCookieNameLegacy]
|
||||
: null);
|
||||
if (publicCookieToken) {
|
||||
try {
|
||||
const claims = await authService.verifyHostedAssetToken(
|
||||
'public',
|
||||
publicCookieToken,
|
||||
{
|
||||
appUid: expectedAppUid,
|
||||
subdomain: expectedSubdomain,
|
||||
host: expectedHost,
|
||||
},
|
||||
);
|
||||
return {
|
||||
source: 'public-cookie',
|
||||
userUid: claims.userUid,
|
||||
sessionUuid: claims.sessionUuid,
|
||||
hasValidPublicCookie: true,
|
||||
};
|
||||
} catch {
|
||||
/* fall through — v1-signed cookies land here and get re-minted */
|
||||
}
|
||||
}
|
||||
|
||||
return { source: 'none' };
|
||||
}
|
||||
|
||||
|
||||
@@ -2591,3 +2591,239 @@ describe('createPuterSiteMiddleware — private-app gate decisions', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('public hosted asset billing identity', () => {
|
||||
it.each(['actor', 'session', 'query'] as const)(
|
||||
'replaces an older billing cookie with the current %s identity',
|
||||
async (source) => {
|
||||
const owner = await makeUserWithHome();
|
||||
const previous = await makeUser();
|
||||
const current = await makeUser();
|
||||
const home = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}`,
|
||||
))!;
|
||||
const sub = `switch-${uuidv4().slice(0, 8)}`;
|
||||
const host = `${sub}.site.puter.localhost`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: home.id,
|
||||
});
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`/${owner.username}/index.html`,
|
||||
Buffer.from('hello'),
|
||||
);
|
||||
const app = await server.stores.app.create(
|
||||
{ name: sub, title: sub, index_url: `http://${host}/` },
|
||||
{ ownerUserId: owner.id },
|
||||
);
|
||||
const previousToken = await server.services.auth.getUserAppToken(
|
||||
actorFor(previous),
|
||||
app.uid,
|
||||
);
|
||||
const previousActor =
|
||||
(await server.services.auth.authenticateFromToken(
|
||||
previousToken,
|
||||
))!;
|
||||
const currentToken = await server.services.auth.getUserAppToken(
|
||||
actorFor(current),
|
||||
app.uid,
|
||||
);
|
||||
const oldCookie = await server.services.auth.createHostedAssetToken(
|
||||
'public',
|
||||
{
|
||||
appUid: app.uid,
|
||||
userUid: previous.uuid,
|
||||
sessionUuid: previousActor.session?.uid,
|
||||
subdomain: sub,
|
||||
host,
|
||||
},
|
||||
);
|
||||
const cookieName = HOSTED_ASSET_COOKIES.public.name;
|
||||
const req = makeReq({
|
||||
hostname: host,
|
||||
path: '/index.html',
|
||||
cookies: {
|
||||
[cookieName]: oldCookie,
|
||||
...(source === 'session' ? { session: currentToken } : {}),
|
||||
},
|
||||
query:
|
||||
source === 'query'
|
||||
? { 'puter.auth.token': currentToken }
|
||||
: {},
|
||||
});
|
||||
if (source === 'actor')
|
||||
req.actor =
|
||||
(await server.services.auth.authenticateFromToken(
|
||||
currentToken,
|
||||
))!;
|
||||
const { res, out } = makeRes();
|
||||
await buildMiddleware({ cookie_name: 'session' })(
|
||||
req,
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(req.egressActor?.user?.uuid).toBe(current.uuid);
|
||||
const refreshed = out.cookies[cookieName];
|
||||
expect(refreshed).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
await server.services.auth.verifyHostedAssetToken(
|
||||
'public',
|
||||
refreshed,
|
||||
{ appUid: app.uid, host },
|
||||
)
|
||||
).userUid,
|
||||
).toBe(current.uuid);
|
||||
const nextReq = makeReq({
|
||||
hostname: host,
|
||||
path: '/index.html',
|
||||
cookies: { [cookieName]: refreshed },
|
||||
});
|
||||
await runMiddleware(buildMiddleware(), nextReq);
|
||||
expect(nextReq.egressActor?.user?.uuid).toBe(current.uuid);
|
||||
},
|
||||
);
|
||||
|
||||
it('bills recognized visitors, reuses the parent session, and falls back to the owner', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const visitor = await makeUser();
|
||||
const home = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}`,
|
||||
))!;
|
||||
const sub = `egress-${uuidv4().slice(0, 8)}`;
|
||||
const host = `${sub}.site.puter.localhost`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: home.id,
|
||||
});
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`/${owner.username}/index.html`,
|
||||
Buffer.from('hello'),
|
||||
);
|
||||
const app = await server.stores.app.create(
|
||||
{ name: sub, title: sub, index_url: `http://${host}/` },
|
||||
{ ownerUserId: owner.id },
|
||||
);
|
||||
const token = await server.services.auth.getUserAppToken(
|
||||
actorFor(visitor),
|
||||
app.uid,
|
||||
);
|
||||
const authenticated =
|
||||
await server.services.auth.authenticateFromToken(token);
|
||||
const serve = async (query = {}, cookies = {}, headers = {}) => {
|
||||
const req = makeReq({ hostname: host, path: '/index.html', query });
|
||||
Object.assign(req, { cookies, headers });
|
||||
const { res, out } = makeRes();
|
||||
await buildMiddleware()(req, res, vi.fn());
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(out.statusCode).toBe(200);
|
||||
return { req, out };
|
||||
};
|
||||
const create = vi.spyOn(server.stores.session, 'create');
|
||||
try {
|
||||
const first = await serve({ 'puter.auth.token': token });
|
||||
expect(first.req.egressActor?.user?.uuid).toBe(visitor.uuid);
|
||||
const cookieName = HOSTED_ASSET_COOKIES.public.name;
|
||||
const cookie = first.out.cookies[cookieName];
|
||||
expect(cookie).toBeTruthy();
|
||||
const claims = await server.services.auth.verifyHostedAssetToken(
|
||||
'public',
|
||||
cookie,
|
||||
{ appUid: app.uid, host },
|
||||
);
|
||||
expect(claims.sessionUuid).toBe(authenticated?.session?.uid);
|
||||
const next = await serve({}, { [cookieName]: cookie });
|
||||
expect(next.req.egressActor?.user?.uuid).toBe(visitor.uuid);
|
||||
expect(next.out.cookies).toEqual({});
|
||||
const blockedCookies = await serve(
|
||||
{},
|
||||
{},
|
||||
{ referer: `http://${host}/?puter.auth.token=${token}` },
|
||||
);
|
||||
expect(blockedCookies.req.egressActor?.user?.uuid).toBe(
|
||||
visitor.uuid,
|
||||
);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
const anonymous = await serve();
|
||||
expect(anonymous.req.egressActor?.user?.uuid).toBe(owner.uuid);
|
||||
const invalid = await serve({}, { [cookieName]: 'invalid' });
|
||||
expect(invalid.req.egressActor?.user?.uuid).toBe(owner.uuid);
|
||||
await server.services.auth.revokeAllSessionsForUserId(visitor.id);
|
||||
const revoked = await serve({}, { [cookieName]: cookie });
|
||||
expect(revoked.req.egressActor?.user?.uuid).toBe(owner.uuid);
|
||||
} finally {
|
||||
create.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('site rooted in the owner tree, root moved to Trash', () => {
|
||||
it('stops serving a trashed folder and resumes when restored', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const dir = await server.services.fs.mkdir(owner.id, {
|
||||
path: `/${owner.username}/Documents/site`,
|
||||
createMissingParents: true,
|
||||
});
|
||||
const body = Buffer.from('<html>trashed</html>');
|
||||
await server.services.fs.write(owner.id, {
|
||||
fileMetadata: {
|
||||
path: `/${owner.username}/Documents/site/index.html`,
|
||||
size: body.byteLength,
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
const sub = `tr-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: dir.id,
|
||||
});
|
||||
const serveIndex = async () => {
|
||||
const mw = createPuterSiteMiddleware(hostingConfig, {
|
||||
clients: server.clients,
|
||||
stores: server.stores,
|
||||
services: server.services,
|
||||
});
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return out;
|
||||
};
|
||||
expect((await serveIndex()).statusCode).toBe(200);
|
||||
|
||||
const trash = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Trash`,
|
||||
))!;
|
||||
const entry = (await server.stores.fsEntry.getEntryById(dir.id))!;
|
||||
await server.services.fs.move(owner.id, {
|
||||
source: entry,
|
||||
destinationParent: trash,
|
||||
});
|
||||
const moved = (await server.stores.fsEntry.getEntryById(dir.id))!;
|
||||
expect(moved.path.startsWith(`/${owner.username}/Trash/`)).toBe(true);
|
||||
|
||||
const out = await serveIndex();
|
||||
expect(out.statusCode).toBe(404);
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Documents`,
|
||||
))!;
|
||||
await server.services.fs.move(owner.id, {
|
||||
source: moved,
|
||||
destinationParent: documents,
|
||||
});
|
||||
expect((await serveIndex()).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
import { awaitLater } from '../../../util/concurrency';
|
||||
import { isTrashedPath } from '../../../util/hostedAppBacking';
|
||||
import { makeActor } from '../../actor';
|
||||
import {
|
||||
buildAppCenterFallback,
|
||||
@@ -109,10 +110,6 @@ const isServableEntryPath = (
|
||||
return !isWorkersSourcePath(relPath) && !isSiteConfigPath(relPath);
|
||||
};
|
||||
|
||||
/** Inside some owner's top-level Trash, which is where Delete puts things. */
|
||||
const isTrashedPath = (path: string): boolean =>
|
||||
/^\/[^/]+\/Trash(\/|$)/u.test(path);
|
||||
|
||||
/**
|
||||
* Suggested value for `config.hosting_csp`. Not applied unless configured.
|
||||
*
|
||||
@@ -333,6 +330,7 @@ export const createPuterSiteMiddleware = (
|
||||
|
||||
const associatedApp = (await appLookup) as AppRow | null;
|
||||
|
||||
let hostedVisitorUid: string | undefined;
|
||||
const isPrivateApp = Boolean(associatedApp?.is_private);
|
||||
const privateApp = isPrivateApp ? associatedApp : null;
|
||||
|
||||
@@ -400,6 +398,8 @@ export const createPuterSiteMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
hostedVisitorUid = identity.userUid;
|
||||
|
||||
// Entitlement check runs on every request — matching v1. The
|
||||
// sticky cookie is an identity shortcut, not an access cache;
|
||||
// the marketplace extension already caches access decisions
|
||||
@@ -509,11 +509,8 @@ export const createPuterSiteMiddleware = (
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
return;
|
||||
} else {
|
||||
// Public hosted site. Mint the public hosted-actor cookie if
|
||||
// we can identify the visitor — lets the hosted page make
|
||||
// cross-origin requests as the actor without needing a
|
||||
// host-scoped main session cookie. No-op for anonymous
|
||||
// visitors.
|
||||
// Remember the authenticated visitor for asset billing across
|
||||
// requests that do not carry the original app token.
|
||||
try {
|
||||
const identity = await resolvePublicHostedIdentity({
|
||||
req,
|
||||
@@ -526,6 +523,7 @@ export const createPuterSiteMiddleware = (
|
||||
expectedSubdomain: subdomain,
|
||||
expectedHost: host,
|
||||
});
|
||||
hostedVisitorUid = identity.userUid;
|
||||
if (
|
||||
identity.userUid &&
|
||||
!(identity as { hasValidPublicCookie?: boolean })
|
||||
@@ -574,7 +572,7 @@ export const createPuterSiteMiddleware = (
|
||||
.send(SUBDOMAIN_404);
|
||||
return;
|
||||
}
|
||||
if (!rootEntry.isDir) {
|
||||
if (!rootEntry.isDir || isTrashedPath(rootEntry.path)) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
.send(SUBDOMAIN_404);
|
||||
@@ -584,37 +582,27 @@ export const createPuterSiteMiddleware = (
|
||||
// A site rooted in someone else's directory stands on the `manage`
|
||||
// grant that authorized publishing it, and serves that whole subtree
|
||||
// with the ACL bypassed — so the grant is re-read on every request.
|
||||
// Trash is its own check: a grant is keyed on the node, not its
|
||||
// location, so it survives the owner deleting the directory. Sites
|
||||
// rooted in their publisher's own tree skip all of this.
|
||||
if (rootEntry.userId !== site.user_id) {
|
||||
let stillPublishable = false;
|
||||
if (!isTrashedPath(rootEntry.path)) {
|
||||
try {
|
||||
stillPublishable = await layers.services.acl.check(
|
||||
makeActor({
|
||||
user: {
|
||||
id: owner.id,
|
||||
uuid: owner.uuid,
|
||||
username: owner.username,
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: rootEntry.path,
|
||||
resolveAncestors: () =>
|
||||
layers.services.fs.getAncestorChain(
|
||||
rootEntry.path,
|
||||
),
|
||||
try {
|
||||
stillPublishable = await layers.services.acl.check(
|
||||
makeActor({
|
||||
user: {
|
||||
id: owner.id,
|
||||
uuid: owner.uuid,
|
||||
username: owner.username,
|
||||
},
|
||||
MANAGE_PERM_PREFIX,
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail closed — an unreadable grant is not a held one.
|
||||
console.warn(
|
||||
'[puter-site] publish grant recheck failed',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}),
|
||||
{
|
||||
path: rootEntry.path,
|
||||
resolveAncestors: () =>
|
||||
layers.services.fs.getAncestorChain(rootEntry.path),
|
||||
},
|
||||
MANAGE_PERM_PREFIX,
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail closed — an unreadable grant is not a held one.
|
||||
console.warn('[puter-site] publish grant recheck failed', e);
|
||||
}
|
||||
if (!stillPublishable) {
|
||||
res.status(404)
|
||||
@@ -837,7 +825,22 @@ export const createPuterSiteMiddleware = (
|
||||
// hosting is unauthenticated by design, so most visitors are nobody in
|
||||
// particular. Set unconditionally because hosting subdomains are not
|
||||
// metered by default: this is what opts the response in.
|
||||
req.egressActor = req.actor ?? {
|
||||
let visitorActor = req.actor;
|
||||
if (hostedVisitorUid && visitorActor?.user?.uuid !== hostedVisitorUid) {
|
||||
try {
|
||||
const visitor =
|
||||
await layers.stores.user.getByUuid(hostedVisitorUid);
|
||||
if (visitor && !visitor.suspended) {
|
||||
visitorActor = makeActor({
|
||||
user: visitor,
|
||||
app: associatedApp ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Unknown requesters are billed to the site owner.
|
||||
}
|
||||
}
|
||||
req.egressActor = visitorActor ?? {
|
||||
user: {
|
||||
uuid: owner.uuid,
|
||||
id: owner.id,
|
||||
|
||||
@@ -84,6 +84,10 @@ export function sweepMemoryWindows() {
|
||||
)
|
||||
memoryWindows.delete(k);
|
||||
}
|
||||
for (const [k, entry] of memoryCounters) {
|
||||
if (entry.index < Math.floor(now / entry.windowMs) - 1)
|
||||
memoryCounters.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
@@ -155,6 +159,97 @@ async function peekMemory(key, limit, windowMs) {
|
||||
return timestamps.length < limit;
|
||||
}
|
||||
|
||||
// -- Counted claims --------------------------------------------------
|
||||
//
|
||||
// `claim` keeps a member per unit, which is fine for a few units and not for
|
||||
// thousands. The counted form keeps two counters per key, the current fixed
|
||||
// window and the one before it, and slides by weighting the previous one by
|
||||
// how much of it the window still covers. It can be off by a little at window
|
||||
// edges, and its buckets are separate from the other forms'.
|
||||
|
||||
/** The sliding-window estimate from the two fixed windows it overlaps. */
|
||||
const slidingCount = (previous, current, now, windowMs) =>
|
||||
previous * (1 - (now % windowMs) / windowMs) + current;
|
||||
|
||||
/** How many of `units` a counted claim that left `used` spent may keep. */
|
||||
const countedAdmits = (used, limit, units) =>
|
||||
units - Math.min(units, Math.max(0, Math.ceil(used - limit)));
|
||||
|
||||
/** Key → `{ index, current, previous, windowMs }`, swept with the windows. */
|
||||
const memoryCounters = new Map();
|
||||
|
||||
async function claimCountMemory(key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
const index = Math.floor(now / windowMs);
|
||||
let entry = memoryCounters.get(key);
|
||||
if (!entry) {
|
||||
if (memoryCounters.size >= MEMORY_MAX_KEYS) {
|
||||
memoryCounters.delete(memoryCounters.keys().next().value);
|
||||
}
|
||||
entry = { index, current: 0, previous: 0, windowMs };
|
||||
memoryCounters.set(key, entry);
|
||||
} else if (entry.index !== index) {
|
||||
entry.previous = entry.index === index - 1 ? entry.current : 0;
|
||||
entry.current = 0;
|
||||
entry.index = index;
|
||||
}
|
||||
entry.windowMs = windowMs;
|
||||
const used = slidingCount(entry.previous, entry.current, now, windowMs);
|
||||
const admitted = countedAdmits(used + units, limit, units);
|
||||
entry.current += admitted;
|
||||
return admitted;
|
||||
}
|
||||
|
||||
async function claimCountRedis(redis, key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
const index = Math.floor(now / windowMs);
|
||||
// The hash tag keeps both windows in one slot, so one MULTI reads both.
|
||||
const current = `ratec:{${key}}:${index}`;
|
||||
const results = await redis
|
||||
.multi()
|
||||
.incrby(current, units)
|
||||
.pexpire(current, 2 * windowMs)
|
||||
.get(`ratec:{${key}}:${index - 1}`)
|
||||
.exec();
|
||||
const used = slidingCount(
|
||||
Number(multiResult(results, 2) ?? 0),
|
||||
Number(multiResult(results, 0)),
|
||||
now,
|
||||
windowMs,
|
||||
);
|
||||
const admitted = countedAdmits(used, limit, units);
|
||||
if (admitted < units) await redis.decrby(current, units - admitted);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
async function claimCountKv(kv, key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
const index = Math.floor(now / windowMs);
|
||||
const current = `ratec:${key}:${index}`;
|
||||
const [counted, previous] = await Promise.all([
|
||||
kv.incr({
|
||||
key: current,
|
||||
pathAndAmountMap: { '': units },
|
||||
expireAt: Math.ceil((now + 2 * windowMs) / 1000),
|
||||
}),
|
||||
kv.get({ key: `ratec:${key}:${index - 1}` }),
|
||||
]);
|
||||
const used = slidingCount(
|
||||
Number(previous.res ?? 0),
|
||||
Number(counted.res),
|
||||
now,
|
||||
windowMs,
|
||||
);
|
||||
const admitted = countedAdmits(used, limit, units);
|
||||
if (admitted < units) {
|
||||
await kv.decr({
|
||||
key: current,
|
||||
pathAndAmountMap: { '': units - admitted },
|
||||
});
|
||||
}
|
||||
return admitted;
|
||||
}
|
||||
|
||||
// -- Redis backend ---------------------------------------------------
|
||||
|
||||
// ioredis MULTI/EXEC reports per-command failures inside the exec() result
|
||||
@@ -475,6 +570,10 @@ function instrumentBackendPair(name, pair) {
|
||||
withSpan('rate_limit.claim', attrs, () =>
|
||||
pair.claim(key, limit, windowMs, units),
|
||||
),
|
||||
count: (key, limit, windowMs, units) =>
|
||||
withSpan('rate_limit.count', attrs, () =>
|
||||
pair.count(key, limit, windowMs, units),
|
||||
),
|
||||
acquire: (key, limit) =>
|
||||
withSpan('rate_limit.acquire', attrs, () =>
|
||||
pair.acquire(key, limit),
|
||||
@@ -486,6 +585,7 @@ const memoryBackendPair = instrumentBackendPair('memory', {
|
||||
rate: checkMemory,
|
||||
peek: peekMemory,
|
||||
claim: claimMemory,
|
||||
count: claimCountMemory,
|
||||
acquire: acquireMemoryConcurrent,
|
||||
});
|
||||
|
||||
@@ -536,6 +636,8 @@ export function configureRateLimit({
|
||||
peekRedis(redis, key, limit, windowMs),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimRedis(redis, key, limit, windowMs, units),
|
||||
count: (key, limit, windowMs, units) =>
|
||||
claimCountRedis(redis, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -545,6 +647,8 @@ export function configureRateLimit({
|
||||
peek: (key, limit) => peekKv(kv, key, limit),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimKv(kv, key, limit, windowMs, units),
|
||||
count: (key, limit, windowMs, units) =>
|
||||
claimCountKv(kv, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -874,6 +978,32 @@ export async function claimRateLimit(key, limit, windowMs, units, backend) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claimRateLimit` for budgets measured in many units per call: a claim costs
|
||||
* the same however many units it spends (see "Counted claims" above). Its
|
||||
* buckets are its own, so a key is only ever claimed one way. Fails open,
|
||||
* admitting every unit.
|
||||
*/
|
||||
export async function claimCountedRateLimit(
|
||||
key,
|
||||
limit,
|
||||
windowMs,
|
||||
units,
|
||||
backend,
|
||||
) {
|
||||
if (!(units > 0)) return 0;
|
||||
const bk = resolveBackend(backend);
|
||||
try {
|
||||
return await bk.count(key, limit, windowMs, units);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[rate-limit] imperative counted claim failed, failing open:',
|
||||
err,
|
||||
);
|
||||
return units;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge a route-shaped rate-limit spec from inside a handler.
|
||||
*
|
||||
@@ -1049,8 +1179,8 @@ export function concurrencyGate(opts) {
|
||||
* Acquire a concurrent slot for a driver call. Mirrors `checkDriverRateLimit`
|
||||
* but returns an acquisition handle: caller MUST invoke `release()` on the
|
||||
* returned object exactly once, even on thrown errors — typically in a
|
||||
* `finally`. `ok: false` means the slot was full; callers should reject with
|
||||
* 429 in that case.
|
||||
* `finally`. `renew()` keeps a long-held slot past the orphan TTL. `ok: false`
|
||||
* means the slot was full; callers should reject with 429 in that case.
|
||||
*
|
||||
* `opts` is the resolved per-method spec from the driver's decorator (or
|
||||
* imperative `concurrent` field). Omitting `opts` (driver declares nothing)
|
||||
@@ -1060,7 +1190,7 @@ export function concurrencyGate(opts) {
|
||||
*/
|
||||
export async function acquireDriverConcurrent(req, ifaceName, method, opts) {
|
||||
if (!opts || typeof opts.limit !== 'number') {
|
||||
return { ok: true, release: noop };
|
||||
return { ok: true, release: noop, renew: noop };
|
||||
}
|
||||
const slot = await charge(
|
||||
req,
|
||||
@@ -1072,8 +1202,7 @@ export async function acquireDriverConcurrent(req, ifaceName, method, opts) {
|
||||
label: '[concurrent] driver acquire',
|
||||
},
|
||||
);
|
||||
if (!slot) return { ok: true, release: noop };
|
||||
if (!slot.ok) return { ok: false, release: noop };
|
||||
const { release } = guardSlot(slot, '[concurrent] driver');
|
||||
return { ok: true, release };
|
||||
if (!slot) return { ok: true, release: noop, renew: noop };
|
||||
if (!slot.ok) return { ok: false, release: noop, renew: noop };
|
||||
return guardSlot(slot, '[concurrent] driver');
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
checkRateLimit,
|
||||
claimCountedRateLimit,
|
||||
claimRateLimit,
|
||||
concurrencyGate,
|
||||
consumeRouteRateLimit,
|
||||
@@ -1487,6 +1488,13 @@ describe('rate + concurrent limiting — kv backend', () => {
|
||||
expect(await claimRateLimit(key, 5, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('claims counted units, admitting only what fits', async () => {
|
||||
const key = uniqueScope();
|
||||
expect(await claimCountedRateLimit(key, 5, 60_000, 3)).toBe(3);
|
||||
expect(await claimCountedRateLimit(key, 5, 60_000, 3)).toBe(2);
|
||||
expect(await claimCountedRateLimit(key, 5, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('claims against a full bucket as full while expired rows are unswept', async () => {
|
||||
const key = uniqueScope();
|
||||
await seedUnswept(`rate:${key}:`, 3);
|
||||
@@ -2010,6 +2018,103 @@ describe('acquireConcurrent (imperative)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimCountedRateLimit', () => {
|
||||
const uniqueKey = () =>
|
||||
`counted-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const WINDOW = 10_000;
|
||||
/** The start of a fixed window, so nothing before it carries in. */
|
||||
const windowStart = (n) => vi.setSystemTime(n * WINDOW);
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['memory', () => configureRateLimit()],
|
||||
[
|
||||
'redis',
|
||||
() =>
|
||||
configureRateLimit({
|
||||
default: 'redis',
|
||||
redis: new RedisMock(),
|
||||
}),
|
||||
],
|
||||
])('%s backend', (_name, configure) => {
|
||||
beforeEach(() => {
|
||||
configure();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
windowStart(1_000);
|
||||
});
|
||||
|
||||
it('admits up to the limit and spends only what fits', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 5, WINDOW, 3)).toBe(3);
|
||||
expect(await claimCountedRateLimit(key, 5, WINDOW, 3)).toBe(2);
|
||||
expect(await claimCountedRateLimit(key, 5, WINDOW, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('spends thousands of units in one claim', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(
|
||||
await claimCountedRateLimit(key, 15_000, WINDOW, 6_000),
|
||||
).toBe(6_000);
|
||||
expect(
|
||||
await claimCountedRateLimit(key, 15_000, WINDOW, 10_000),
|
||||
).toBe(9_000);
|
||||
});
|
||||
|
||||
it('carries the previous window by how much of it is still inside', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 100, WINDOW, 100)).toBe(
|
||||
100,
|
||||
);
|
||||
// A quarter into the next window, 75 of those still count.
|
||||
vi.setSystemTime(1_001 * WINDOW + WINDOW / 4);
|
||||
expect(await claimCountedRateLimit(key, 100, WINDOW, 50)).toBe(25);
|
||||
// Two windows on, nothing carries.
|
||||
windowStart(1_003);
|
||||
expect(await claimCountedRateLimit(key, 100, WINDOW, 100)).toBe(
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps its buckets apart from claimRateLimit', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 2, WINDOW, 2)).toBe(2);
|
||||
expect(await claimRateLimit(key, 2, WINDOW, 2)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('claims nothing for zero units', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 1, WINDOW, 0)).toBe(0);
|
||||
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('fails open, admitting every unit, when the backend throws', async () => {
|
||||
const redis = new RedisMock();
|
||||
redis.multi = () => {
|
||||
throw new Error('redis down');
|
||||
};
|
||||
configureRateLimit({ default: 'redis', redis });
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await claimCountedRateLimit(uniqueKey(), 1, WINDOW, 4)).toBe(4);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('drops a memory counter once its windows have passed', async () => {
|
||||
configureRateLimit();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
windowStart(2_000);
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
|
||||
windowStart(2_002);
|
||||
sweepMemoryWindows();
|
||||
windowStart(2_000);
|
||||
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimRateLimit', () => {
|
||||
const uniqueKey = () => `claim-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ const default_user_permissions = {
|
||||
// is there; which app may is the per-app grant, which is what the user is
|
||||
// actually asked about.
|
||||
'events:background': {},
|
||||
// Likewise, the per-app grant decides whose notifications go out as pushes.
|
||||
'notifications:push': {},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -209,6 +209,7 @@ afterAll(async () => {
|
||||
const attemptsFor = async (
|
||||
model: string,
|
||||
thrown: unknown = new Error('upstream down'),
|
||||
extra: { provider?: string; fallback?: boolean } = {},
|
||||
) => {
|
||||
createMock.mockRejectedValue(thrown);
|
||||
let caught: HttpError | undefined;
|
||||
@@ -217,6 +218,7 @@ const attemptsFor = async (
|
||||
driver.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -886,6 +888,59 @@ describe('ChatCompletionDriver duplicate-model fallback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatCompletionDriver fallback opt-out', () => {
|
||||
const SHARED = 'deepseek-v4-pro';
|
||||
|
||||
it('fails on the first route when the caller sets fallback: false', async () => {
|
||||
const attempts = await attemptsFor(SHARED, undefined, {
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
expect(attempts).toHaveLength(1);
|
||||
expect(attempts[0]).toMatchObject({ provider: 'deepseek' });
|
||||
});
|
||||
|
||||
it('tries only the pinned provider', async () => {
|
||||
const attempts = await attemptsFor(SHARED, undefined, {
|
||||
provider: 'openrouter',
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
expect(attempts).toHaveLength(1);
|
||||
expect(attempts[0]).toMatchObject({ provider: 'openrouter' });
|
||||
});
|
||||
|
||||
it('rejects a pinned provider that has no route for the model', async () => {
|
||||
createMock.mockClear();
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model: SHARED,
|
||||
provider: 'claude',
|
||||
fallback: false,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still marks the failed route for the next caller', async () => {
|
||||
const [only] = await attemptsFor(SHARED, undefined, {
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
expect(isRouteUnhealthy(only.provider, only.model)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the chain when fallback is true or unset', async () => {
|
||||
expect(
|
||||
await attemptsFor(SHARED, undefined, { fallback: true }),
|
||||
).toHaveLength(3);
|
||||
expect(await attemptsFor(SHARED)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// Each attempt in a fallback chain is a whole completion at that model's
|
||||
// prices, so each one has to clear the credit gate on its own. The chain used
|
||||
// to re-check with a nominal 1-microcent amount, which any account with a
|
||||
|
||||
@@ -709,6 +709,20 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// A pinned provider without a route for the model normally resolves
|
||||
// to another vendor; with `fallback: false` that is the one thing the
|
||||
// caller asked us not to do.
|
||||
if (
|
||||
args.fallback === false &&
|
||||
intendedProvider &&
|
||||
model.provider !== intendedProvider
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Model ${args.model} is not served by provider ${intendedProvider}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
args.messages = normalizeMediaParts(normalize_messages(args.messages));
|
||||
if (args.tools) {
|
||||
@@ -816,6 +830,9 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// where "done" is the stream draining rather than this method
|
||||
// returning.
|
||||
let hold: CreditHold = NO_CREDIT_HOLD;
|
||||
// The gate's estimate for the current attempt: the message estimate
|
||||
// plus whatever its provider reads back as prompt beyond the messages.
|
||||
let attemptPromptTokenEstimate = promptTokenEstimate;
|
||||
// The current attempt's balance-bound output cap, if it had one.
|
||||
let fundsCap: number | undefined;
|
||||
// The cost factor the current attempt's charges are recorded at.
|
||||
@@ -824,12 +841,16 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// charge, so a rate change mid-request can't split the two.
|
||||
let pricingTime = new Date();
|
||||
if (!useFakeProvider) {
|
||||
({ hold, fundsCap, costFactor } = await this.#applyCreditGate(
|
||||
actor,
|
||||
model,
|
||||
args,
|
||||
{ promptTokenEstimate, requestedMaxTokens, pricingTime },
|
||||
));
|
||||
({
|
||||
hold,
|
||||
fundsCap,
|
||||
costFactor,
|
||||
promptTokenEstimate: attemptPromptTokenEstimate,
|
||||
} = await this.#applyCreditGate(actor, model, args, {
|
||||
promptTokenEstimate,
|
||||
requestedMaxTokens,
|
||||
pricingTime,
|
||||
}));
|
||||
}
|
||||
|
||||
// First attempt
|
||||
@@ -939,8 +960,15 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const bucketKey = model.id;
|
||||
const tried = new Set([routeId(model.provider!, model.id)]);
|
||||
let lastError: Error | null = e as Error;
|
||||
// With `fallback: false` the caller wants this route's answer or
|
||||
// its error, not a bill for another provider's attempt.
|
||||
const fallbackAllowed = args.fallback !== false;
|
||||
|
||||
while (lastError && attempts.length < MAX_ATTEMPTS) {
|
||||
while (
|
||||
fallbackAllowed &&
|
||||
lastError &&
|
||||
attempts.length < MAX_ATTEMPTS
|
||||
) {
|
||||
const fallback = this.#findFallback(bucketKey, tried);
|
||||
if (!fallback) break;
|
||||
|
||||
@@ -957,12 +985,16 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// this one starts from nothing held.
|
||||
pricingTime = new Date();
|
||||
if (!useFakeProvider) {
|
||||
({ hold, fundsCap, costFactor } =
|
||||
await this.#applyCreditGate(actor, fallback, args, {
|
||||
promptTokenEstimate,
|
||||
requestedMaxTokens,
|
||||
pricingTime,
|
||||
}));
|
||||
({
|
||||
hold,
|
||||
fundsCap,
|
||||
costFactor,
|
||||
promptTokenEstimate: attemptPromptTokenEstimate,
|
||||
} = await this.#applyCreditGate(actor, fallback, args, {
|
||||
promptTokenEstimate,
|
||||
requestedMaxTokens,
|
||||
pricingTime,
|
||||
}));
|
||||
}
|
||||
|
||||
tried.add(routeId(fallback.provider!, fallback.id));
|
||||
@@ -1188,7 +1220,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
actor,
|
||||
chatStream,
|
||||
model,
|
||||
promptTokenEstimate,
|
||||
promptTokenEstimate: attemptPromptTokenEstimate,
|
||||
completionId,
|
||||
username,
|
||||
intendedProvider,
|
||||
@@ -1432,10 +1464,16 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
/** The instant this attempt is priced at. */
|
||||
pricingTime: Date;
|
||||
},
|
||||
): Promise<{ hold: CreditHold; fundsCap?: number; costFactor: number }> {
|
||||
): Promise<{
|
||||
hold: CreditHold;
|
||||
fundsCap?: number;
|
||||
costFactor: number;
|
||||
/** The prompt estimate this attempt was priced on. */
|
||||
promptTokenEstimate: number;
|
||||
}> {
|
||||
const metering = this.services.metering;
|
||||
const { promptTokenEstimate, requestedMaxTokens, pricingTime } =
|
||||
estimates;
|
||||
const { requestedMaxTokens, pricingTime } = estimates;
|
||||
let { promptTokenEstimate } = estimates;
|
||||
// A provider can name its own cost keys for this request (Claude's
|
||||
// fast-mode rate card), cap its output, and size an extra reservation
|
||||
// the per-token math can't see (a web-search/advisor tool that could
|
||||
@@ -1446,6 +1484,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
this.#providers[model.provider!]?.requestPricing?.(args, model, {
|
||||
promptTokenEstimate,
|
||||
}) ?? {};
|
||||
promptTokenEstimate += pricing.additionalPromptTokens ?? 0;
|
||||
const { inputKey: defaultInputKey, outputKey: defaultOutputKey } =
|
||||
costKeys(model);
|
||||
const inputKey = pricing.inputKey ?? defaultInputKey;
|
||||
@@ -1560,7 +1599,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
actor,
|
||||
Math.max(worstCaseCost, minimumCredits),
|
||||
);
|
||||
return { hold, fundsCap, costFactor };
|
||||
return { hold, fundsCap, costFactor, promptTokenEstimate };
|
||||
}
|
||||
|
||||
/** The key `model`'s usage is recorded and cost-factored under. */
|
||||
|
||||
@@ -194,7 +194,9 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
await OpenAIUtil.process_input_messages(messages, {
|
||||
preserveToolCallOrder: this.preservesToolCallOrder(model),
|
||||
keepExtraContent: this.keepsToolCallExtraContent(model),
|
||||
keepAssistantContent: this.preservesAssistantContent(model),
|
||||
}),
|
||||
model,
|
||||
);
|
||||
|
||||
const tools = args.tools
|
||||
@@ -324,8 +326,19 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a replayed assistant turn keeps its text beside hoisted tool
|
||||
* calls.
|
||||
*/
|
||||
protected preservesAssistantContent(_model: IChatModel): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Last pass over the messages, after `process_input_messages`. */
|
||||
protected prepareMessages(messages: PuterMessage[]): PuterMessage[] {
|
||||
protected prepareMessages(
|
||||
messages: PuterMessage[],
|
||||
_model: IChatModel,
|
||||
): PuterMessage[] {
|
||||
return messages;
|
||||
}
|
||||
|
||||
|
||||
@@ -809,6 +809,11 @@ describe('Alibaba official catalog', () => {
|
||||
expect(models.some((model) => model.id === 'qwen-image-fixture')).toBe(
|
||||
false,
|
||||
);
|
||||
const fixture = models.find(
|
||||
(model) => model.id === 'qwen-catalog-fixture',
|
||||
)!;
|
||||
expect(fixture.puterId).toBe('alibaba:qwen/qwen-catalog-fixture');
|
||||
expect(modelLookupNames(models)).toContain('qwen-catalog-fixture');
|
||||
const plus = models.find((model) => model.id === 'qwen-plus')!;
|
||||
const original = ALIBABA_MODELS.find(
|
||||
(model) => model.id === 'qwen-plus',
|
||||
@@ -909,6 +914,115 @@ describe('Alibaba official catalog', () => {
|
||||
expect(model.costs).toEqual({ tokens: 1_000_000 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['qwen3.9-plus-2027-01-01', 'alibaba:qwen/qwen3.9-plus-2027-01-01'],
|
||||
['kimi/kimi-k3', 'alibaba:kimi/kimi-k3'],
|
||||
[
|
||||
'deepseek-v4.1-flash-1017',
|
||||
'alibaba:deepseek/deepseek-v4.1-flash-1017',
|
||||
],
|
||||
['glm-5.3-prime', 'alibaba:zhipu/glm-5.3-prime'],
|
||||
['decision-model-preview', 'alibaba:qwen/decision-model-preview'],
|
||||
])(
|
||||
'gives uncurated model %s a puterId in the alibaba:<vendor>/<id> form',
|
||||
(id, puterId) => {
|
||||
const model = mapAlibabaCatalogModel({
|
||||
model: id,
|
||||
capabilities: ['TG'],
|
||||
})!;
|
||||
expect(model.puterId).toBe(puterId);
|
||||
},
|
||||
);
|
||||
|
||||
it('skips a vendor-prefixed spelling of a curated model', () => {
|
||||
const model = mapAlibabaCatalogModel({
|
||||
model: 'zhipu/glm-5.2',
|
||||
capabilities: ['TG', 'Reasoning'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
});
|
||||
expect(model).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'ASR',
|
||||
{
|
||||
capabilities: ['ASR'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Audio'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'rerank',
|
||||
{
|
||||
capabilities: ['TR'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'image generation',
|
||||
{
|
||||
capabilities: ['IG'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text'],
|
||||
response_modality: ['Text', 'Image'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'realtime omni',
|
||||
{
|
||||
capabilities: ['Realtime-Omni'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text', 'Image', 'Audio', 'Video'],
|
||||
response_modality: ['Text', 'Audio'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'TTS',
|
||||
{
|
||||
capabilities: ['TTS'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Audio'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
])('rejects %s models that cannot serve chat completions', (_kind, raw) => {
|
||||
expect(
|
||||
mapAlibabaCatalogModel({
|
||||
model: 'non-chat-fixture',
|
||||
...(raw as AlibabaApiModel),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('admits a vision model', () => {
|
||||
const model = mapAlibabaCatalogModel({
|
||||
model: 'vision-fixture',
|
||||
capabilities: ['VU'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text', 'Image'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
})!;
|
||||
expect(model.modalities?.input).toEqual(['text', 'image']);
|
||||
});
|
||||
|
||||
it('does not admit an uncurated entry without capability or modality data', () => {
|
||||
expect(mapAlibabaCatalogModel({ model: 'mystery-model' })).toBeNull();
|
||||
expect(mapAlibabaCatalogModel({ model: 'qwen-plus' })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('refuses inference with unverified catalog prices', async () => {
|
||||
seedPages();
|
||||
const { provider } = makeProvider();
|
||||
|
||||
@@ -82,6 +82,23 @@ const positiveLimit = (value: unknown): number | undefined =>
|
||||
? value
|
||||
: undefined;
|
||||
|
||||
const chatCapabilities = ['TG', 'Reasoning', 'VU'];
|
||||
|
||||
const curatedPuterIds = new Set(ALIBABA_MODELS.map((model) => model.puterId));
|
||||
|
||||
const inferPuterId = (id: string): string => {
|
||||
if (id.includes('/')) return `alibaba:${id}`;
|
||||
const family = id.split('-')[0];
|
||||
const sibling = ALIBABA_MODELS.find(
|
||||
(model) => model.puterId && model.id.split('-')[0] === family,
|
||||
);
|
||||
// Unprefixed feed ids are Model Studio's own family unless a curated
|
||||
// sibling points at a third-party org.
|
||||
const org =
|
||||
sibling?.puterId?.slice('alibaba:'.length).split('/')[0] ?? 'qwen';
|
||||
return `alibaba:${org}/${id}`;
|
||||
};
|
||||
|
||||
export const mapAlibabaCatalogModel = (
|
||||
raw: AlibabaApiModel,
|
||||
previous?: IChatModel,
|
||||
@@ -90,16 +107,25 @@ export const mapAlibabaCatalogModel = (
|
||||
const id = raw.model.trim();
|
||||
const existing =
|
||||
previous ?? ALIBABA_MODELS.find((model) => model.id === id);
|
||||
const input = raw.inference_metadata?.request_modality;
|
||||
const output = raw.inference_metadata?.response_modality;
|
||||
if (output && !output.includes('Text')) return null;
|
||||
if (
|
||||
!output &&
|
||||
!existing &&
|
||||
!raw.capabilities?.some((capability) =>
|
||||
['TG', 'Reasoning', 'VU'].includes(capability),
|
||||
raw.capabilities &&
|
||||
!raw.capabilities.some((capability) =>
|
||||
chatCapabilities.includes(capability),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (input && !input.includes('Text')) return null;
|
||||
if (output && !output.includes('Text')) return null;
|
||||
if (!raw.capabilities && !input && !output && !existing) return null;
|
||||
let puterId = existing?.puterId;
|
||||
if (!puterId) {
|
||||
puterId = inferPuterId(id);
|
||||
// A feed entry spelling a curated model differently would duplicate
|
||||
// the curated entry's puterId and list the model twice.
|
||||
if (curatedPuterIds.has(puterId)) return null;
|
||||
}
|
||||
const catalogPricing: AlibabaCatalogTier[] = (raw.prices ?? []).map(
|
||||
(tier) => {
|
||||
// Abbreviated ranges do not specify whether k means 1000 or 1024 tokens.
|
||||
@@ -149,6 +175,7 @@ export const mapAlibabaCatalogModel = (
|
||||
return {
|
||||
...existing,
|
||||
id,
|
||||
puterId,
|
||||
name: raw.name ?? existing?.name ?? id,
|
||||
description: raw.description ?? existing?.description,
|
||||
aliases: existing?.aliases,
|
||||
|
||||
+33
-2
@@ -20,8 +20,8 @@
|
||||
/**
|
||||
* Integration test for the Moonshot provider.
|
||||
*
|
||||
* Uses `kimi-k2.6` (the cheapest current Kimi model). Skipped
|
||||
* when `PUTER_TEST_AI_MOONSHOT_API_KEY` is unset.
|
||||
* Uses `kimi-k2.6` (the cheapest current Kimi model) and `kimi-k3.1-pro`.
|
||||
* Skipped when `PUTER_TEST_AI_MOONSHOT_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -59,5 +59,36 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
?.content;
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a completion and cache usage from kimi-k3.1-pro', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = new MoonshotProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'kimi-k3.1-pro',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
],
|
||||
// Reasoning counts against the cap, so leave room for it.
|
||||
max_tokens: 2048,
|
||||
reasoning_effort: 'low',
|
||||
}),
|
||||
)) as {
|
||||
message?: { content?: string };
|
||||
usage?: Record<string, number>;
|
||||
};
|
||||
|
||||
const text = result.message?.content;
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
expect(result.usage).toMatchObject({
|
||||
prompt_tokens: expect.any(Number),
|
||||
completion_tokens: expect.any(Number),
|
||||
cached_tokens: expect.any(Number),
|
||||
cache_write_tokens: expect.any(Number),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -20,17 +20,15 @@
|
||||
/**
|
||||
* Offline unit tests for MoonshotProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs MoonshotProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. Moonshot is OpenAI-compatible so the OpenAI SDK is mocked at
|
||||
* the module boundary; that's the real network egress point. Image-
|
||||
* inlining behaviour is covered by `utils/inlineImages.test.ts`; here we
|
||||
* stub `inlineHttpImageUrls` so http URLs in vision messages don't
|
||||
* trigger network fetches and only verify the provider invokes it
|
||||
* for vision-capable models. The companion integration test
|
||||
* (MoonshotProvider.integration.test.ts) exercises the real Moonshot
|
||||
* endpoint.
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
|
||||
* constructs MoonshotProvider directly against the live wired `MeteringService`
|
||||
* so the recording side is exercised end-to- end. Moonshot is OpenAI-compatible
|
||||
* so the OpenAI SDK is mocked at the module boundary; that's the real network
|
||||
* egress point. Image- inlining behaviour is covered by
|
||||
* `utils/inlineImages.test.ts`; here we stub `inlineHttpImageUrls` so http URLs
|
||||
* in vision messages don't trigger network fetches and only verify the provider
|
||||
* invokes it for vision-capable models. The companion integration test
|
||||
* (MoonshotProvider.integration.test.ts) exercises the real Moonshot endpoint.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
@@ -46,14 +44,23 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { ChatCompletionDriver } from '../../ChatCompletionDriver.js';
|
||||
import { clearUnhealthyRoutes } from '../../utils/providerHealth.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import {
|
||||
readStreamResult,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { MOONSHOT_MODELS } from './models.js';
|
||||
import { MoonshotProvider } from './MoonshotProvider.js';
|
||||
import {
|
||||
estimatePromptTokens,
|
||||
estimateTextTokens,
|
||||
} from '../../utils/usageEstimate.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
@@ -93,10 +100,23 @@ vi.mock('../../utils/inlineImages.js', () => ({
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: ChatCompletionDriver;
|
||||
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
driver = new ChatCompletionDriver(
|
||||
{
|
||||
providers: {
|
||||
moonshot: { apiKey: 'test-key' },
|
||||
ollama: { enabled: false },
|
||||
},
|
||||
} as never,
|
||||
server.clients,
|
||||
server.stores,
|
||||
server.services,
|
||||
);
|
||||
await driver.onServerStart();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -140,6 +160,7 @@ const makeCapturingChatStream = () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearUnhealthyRoutes();
|
||||
createMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
inlineHttpImageUrlsMock.mockReset();
|
||||
@@ -390,7 +411,10 @@ describe('MoonshotProvider image inlining', () => {
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'kimi-k3', // kimi-k3 declares image input modality
|
||||
messages: messages as unknown as { role: string; content: unknown }[],
|
||||
messages: messages as unknown as {
|
||||
role: string;
|
||||
content: unknown;
|
||||
}[],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -782,3 +806,656 @@ describe('MoonshotProvider.checkModeration', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kimi K3.1 Pro', () => {
|
||||
const models = ['kimi-k3.1-pro', 'kimi-k3.1-pro-highspeed'];
|
||||
/** Catalog rates in µ¢ per token: $2/$10 for Pro, $4/$20 for Highspeed. */
|
||||
const rates = (model: string) =>
|
||||
model.endsWith('highspeed')
|
||||
? { input: 400, output: 2000 }
|
||||
: { input: 200, output: 1000 };
|
||||
const usage = {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 10,
|
||||
prompt_tokens_details: { cached_tokens: 20, cache_write_tokens: 50 },
|
||||
};
|
||||
const response = () => ({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
reasoning_content: 'reasoning',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: structuredClone(usage),
|
||||
});
|
||||
const fund = (balance: number) =>
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance, held: 0 });
|
||||
const user = (content: string) => [{ role: 'user', content }];
|
||||
|
||||
it.each(models)(
|
||||
'maps %s caps and reasoning without sampling parameters',
|
||||
async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: user('hi'),
|
||||
max_tokens: 512,
|
||||
reasoning_effort: 'high',
|
||||
temperature: 0.2,
|
||||
top_p: 0.1,
|
||||
}),
|
||||
);
|
||||
const sent = createMock.mock.calls[0]![0];
|
||||
expect(sent).toMatchObject({
|
||||
model,
|
||||
max_completion_tokens: 512,
|
||||
reasoning_effort: 'high',
|
||||
});
|
||||
expect(sent).not.toHaveProperty('max_tokens');
|
||||
expect(sent).not.toHaveProperty('temperature');
|
||||
expect(sent).not.toHaveProperty('top_p');
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the upstream default output cap and leaves thinking to the model', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({ model: models[0]!, messages: user('hi') }),
|
||||
);
|
||||
const sent = createMock.mock.calls[0]![0];
|
||||
expect(sent.max_completion_tokens).toBe(131072);
|
||||
expect(sent).not.toHaveProperty('thinking');
|
||||
expect(sent).not.toHaveProperty('reasoning_effort');
|
||||
});
|
||||
|
||||
it.each(['low', 'max'] as const)(
|
||||
'forwards %s reasoning effort',
|
||||
async (effort) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: user('hi'),
|
||||
reasoning_effort: effort,
|
||||
}),
|
||||
);
|
||||
expect(createMock.mock.calls[0]![0].reasoning_effort).toBe(effort);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unsupported reasoning effort before inference', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: user('hi'),
|
||||
reasoning_effort: 'medium',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ options: { reasoning: { effort: 'low' } }, expected: 'low' },
|
||||
{ options: { reasoning: { effort: 'max' } }, expected: 'max' },
|
||||
{
|
||||
options: { reasoning: { effort: 'low' }, reasoning_effort: 'high' },
|
||||
expected: 'high',
|
||||
},
|
||||
{ options: {}, expected: undefined },
|
||||
])(
|
||||
'sends the nested reasoning form through the driver: $options',
|
||||
async ({ options, expected }) => {
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: models[0]!,
|
||||
provider: 'moonshotai',
|
||||
messages: user('hi'),
|
||||
...options,
|
||||
} as never),
|
||||
);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock.mock.calls[0]![0].reasoning_effort).toBe(
|
||||
expected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['medium', 'none'])(
|
||||
'rejects the nested reasoning form %s through the driver',
|
||||
async (effort) => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model: models[0]!,
|
||||
provider: 'moonshotai',
|
||||
messages: user('hi'),
|
||||
reasoning: { effort },
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps K3 on max_tokens and bills its cache writes at the K3 write rate', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'kimi-k3',
|
||||
messages: user('hi'),
|
||||
max_tokens: 512,
|
||||
}),
|
||||
);
|
||||
const sent = createMock.mock.calls[0]![0];
|
||||
expect(sent).toMatchObject({ model: 'kimi-k3', max_tokens: 512 });
|
||||
expect(sent).not.toHaveProperty('max_completion_tokens');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 10,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 50,
|
||||
},
|
||||
expect.anything(),
|
||||
'moonshotai:kimi-k3',
|
||||
{
|
||||
prompt_tokens: 30 * 300,
|
||||
completion_tokens: 10 * 1500,
|
||||
cached_tokens: 20 * 30,
|
||||
cache_write_tokens: 50 * 300,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(models)(
|
||||
'meters cache reads, writes and uncached input separately for %s',
|
||||
async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({ model, messages: user('hi') }),
|
||||
);
|
||||
const { input, output } = rates(model);
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 10,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 50,
|
||||
},
|
||||
expect.anything(),
|
||||
`moonshotai:${model}`,
|
||||
{
|
||||
prompt_tokens: 30 * input,
|
||||
completion_tokens: 10 * output,
|
||||
cached_tokens: 20 * 20,
|
||||
cache_write_tokens: 50 * input,
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
usageDetails: {
|
||||
inputTokens: 30,
|
||||
outputTokens: 10,
|
||||
cacheReadTokens: 20,
|
||||
cacheWrite5mTokens: 50,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves assistant reasoning, text and tool-call order on replay', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const toolCalls = ['a', 'b'].map((id) => ({
|
||||
id,
|
||||
type: 'function',
|
||||
function: { name: 'lookup', arguments: '{}' },
|
||||
}));
|
||||
const first = response();
|
||||
Object.assign(first.choices[0]!.message, { tool_calls: toolCalls });
|
||||
createMock.mockResolvedValueOnce(first);
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({ model: models[0]!, messages: user('hi') }),
|
||||
);
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: [(result as { message: unknown }).message],
|
||||
}),
|
||||
);
|
||||
const replayed = createMock.mock.calls[1]![0].messages[0];
|
||||
expect(replayed).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
reasoning_content: 'reasoning',
|
||||
});
|
||||
expect(replayed).not.toHaveProperty('reasoning');
|
||||
expect(replayed.tool_calls).toEqual(toolCalls);
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
reasoning: 'reasoning',
|
||||
content: [
|
||||
{ type: 'text', text: 'answer' },
|
||||
...['a', 'b'].map((id) => ({
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name: 'lookup',
|
||||
input: {},
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(createMock.mock.calls[2]![0].messages[0]).toEqual({
|
||||
role: 'assistant',
|
||||
reasoning_content: 'reasoning',
|
||||
content: [{ type: 'text', text: 'answer' }],
|
||||
tool_calls: toolCalls,
|
||||
});
|
||||
});
|
||||
|
||||
it('meters a stream from its final usage chunk', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { reasoning_content: 'reasoning' } }] },
|
||||
{ choices: [{ delta: { content: 'answer' } }] },
|
||||
{ choices: [], usage: structuredClone(usage) },
|
||||
]),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: user('hi'),
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
expect(createMock.mock.calls[0]![0].stream_options).toEqual({
|
||||
include_usage: true,
|
||||
});
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
expect(recordSpy.mock.calls[0]![0]).toEqual({
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 10,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(models)(
|
||||
'reserves the prompt at the input rate for %s',
|
||||
async (model) => {
|
||||
const { input, output } = rates(model);
|
||||
fund(2_000_000_000);
|
||||
const reserve = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
);
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 1_000_000,
|
||||
completion_tokens: 10,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 100_000,
|
||||
cache_write_tokens: 900_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
provider: 'moonshotai',
|
||||
messages: user('x'.repeat(8_000_000)),
|
||||
max_tokens: 10,
|
||||
}),
|
||||
);
|
||||
expect(reserve).toHaveBeenCalledTimes(1);
|
||||
expect(reserve.mock.calls[0]![1]).toBeGreaterThanOrEqual(
|
||||
1_000_000 * input + 10 * output,
|
||||
);
|
||||
expect(reserve.mock.calls[0]![1]).toBeLessThan(
|
||||
1_000_001 * input + 10 * output,
|
||||
);
|
||||
expect(recordSpy.mock.calls[0]![0]).toMatchObject({
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 10,
|
||||
cached_tokens: 100_000,
|
||||
cache_write_tokens: 900_000,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['kimi-k3', 'kimi-k2.6'])(
|
||||
'keeps the existing reservation for %s',
|
||||
async (model) => {
|
||||
const entry = MOONSHOT_MODELS.find(
|
||||
(candidate) => candidate.id === model,
|
||||
)!;
|
||||
fund(2_000_000_000);
|
||||
const reserve = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
);
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
provider: 'moonshotai',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'x'.repeat(8000),
|
||||
reasoning: 'thought '.repeat(90000),
|
||||
},
|
||||
],
|
||||
max_tokens: 10,
|
||||
}),
|
||||
);
|
||||
expect(reserve.mock.calls[0]![1]).toBe(
|
||||
1000 * Number(entry.costs.prompt_tokens) +
|
||||
10 * Number(entry.costs.completion_tokens),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(models)(
|
||||
'rejects an underfunded request for %s before inference',
|
||||
async (model) => {
|
||||
// Short of the prompt alone at the input rate.
|
||||
fund(model.endsWith('highspeed') ? 300_000_000 : 150_000_000);
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
provider: 'moonshotai',
|
||||
messages: user('x'.repeat(8_000_000)),
|
||||
max_tokens: 10,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, ''])(
|
||||
'round-trips tool-only assistant content %s through the driver',
|
||||
async (content) => {
|
||||
const toolCalls = [
|
||||
{
|
||||
id: 'call_paris',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'weather',
|
||||
arguments: '{"city":"Paris"}',
|
||||
},
|
||||
},
|
||||
];
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
reasoning_content: 'Check weather',
|
||||
tool_calls: toolCalls,
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
});
|
||||
const first = await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: models[0]!,
|
||||
provider: 'moonshotai',
|
||||
messages: user('weather?'),
|
||||
}),
|
||||
);
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: models[0]!,
|
||||
provider: 'moonshotai',
|
||||
messages: [
|
||||
(first as { message: unknown }).message,
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_paris',
|
||||
content: 'sunny',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const assistant = createMock.mock.calls[1]![0].messages.find(
|
||||
(message: { role: string }) => message.role === 'assistant',
|
||||
);
|
||||
expect(assistant).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
reasoning_content: 'Check weather',
|
||||
});
|
||||
expect(assistant).not.toHaveProperty('normalized');
|
||||
expect(assistant.tool_calls).toEqual(toolCalls);
|
||||
expect(
|
||||
JSON.parse(assistant.tool_calls[0].function.arguments),
|
||||
).toEqual({ city: 'Paris' });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ city: 'Paris' }, '{"city":"Paris"}'])(
|
||||
'keeps native tool_use input %j through the driver',
|
||||
async (input) => {
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: models[0]!,
|
||||
provider: 'moonshotai',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
reasoning_content: 'Check weather',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call_paris',
|
||||
name: 'weather',
|
||||
input,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_paris',
|
||||
content: 'sunny',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const assistant = createMock.mock.calls[0]![0].messages.find(
|
||||
(message: { role: string }) => message.role === 'assistant',
|
||||
);
|
||||
expect(assistant.reasoning_content).toBe('Check weather');
|
||||
expect(assistant.tool_calls[0].function.arguments).toBe(
|
||||
'{"city":"Paris"}',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
models.flatMap((model) =>
|
||||
['reasoning', 'reasoning_content', 'both'].map((alias) => ({
|
||||
model,
|
||||
alias,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
'reserves replayed reasoning once with wire precedence: $model $alias',
|
||||
async ({ model, alias }) => {
|
||||
const text = 'reasoning '.repeat(90000);
|
||||
const assistant = {
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
...(alias !== 'reasoning_content'
|
||||
? { reasoning: alias === 'both' ? 'short' : text }
|
||||
: {}),
|
||||
...(alias !== 'reasoning' ? { reasoning_content: text } : {}),
|
||||
};
|
||||
const messages = [
|
||||
...user('hi'),
|
||||
assistant,
|
||||
...user('continue'),
|
||||
];
|
||||
const tokens =
|
||||
estimatePromptTokens(messages) + estimateTextTokens(text);
|
||||
const { input, output } = rates(model);
|
||||
const headroom = fund(2_000_000_000);
|
||||
const reserve = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
);
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
messages: structuredClone(messages),
|
||||
max_tokens: 10,
|
||||
}),
|
||||
);
|
||||
expect(reserve.mock.calls[0]![1]).toBe(
|
||||
tokens * input + 10 * output,
|
||||
);
|
||||
expect(
|
||||
createMock.mock.calls[0]![0].messages[1].reasoning_content,
|
||||
).toBe(text);
|
||||
headroom.mockResolvedValue({
|
||||
balance: tokens * input - 1,
|
||||
held: 0,
|
||||
});
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
messages: structuredClone(messages),
|
||||
max_tokens: 10,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(reserve).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(models)(
|
||||
'charges the estimate, replayed reasoning included, when a %s stream is interrupted',
|
||||
async (model) => {
|
||||
const reasoning = 'history '.repeat(1000);
|
||||
const messages = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
reasoning: 'ignored alias',
|
||||
reasoning_content: reasoning,
|
||||
},
|
||||
...user('hi'),
|
||||
];
|
||||
const expectedInput =
|
||||
estimatePromptTokens(messages) + estimateTextTokens(reasoning);
|
||||
createMock.mockResolvedValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: 'partial answer' } }],
|
||||
};
|
||||
throw new Error('upstream connection lost');
|
||||
})(),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
messages: structuredClone(messages),
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
const text = (await readStreamResult(result)).toString();
|
||||
expect(text).toContain('upstream connection lost');
|
||||
await vi.waitFor(() => expect(recordSpy).toHaveBeenCalledTimes(1));
|
||||
expect(
|
||||
createMock.mock.calls[0]![0].messages[0].reasoning_content,
|
||||
).toBe(reasoning);
|
||||
expect(recordSpy.mock.calls[0]![0]).toHaveProperty(
|
||||
'estimated_prompt_tokens',
|
||||
expectedInput,
|
||||
);
|
||||
expect(recordSpy.mock.calls[0]![0]).toHaveProperty(
|
||||
'estimated_completion_tokens',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(models)(
|
||||
'charges the estimate when a %s stream completes without a usage chunk',
|
||||
async (model) => {
|
||||
createMock.mockResolvedValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { content: 'answer' } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||
]),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
provider: 'moonshotai',
|
||||
messages: user('hi'),
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
const events = (await readStreamResult(result))
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(events.some((event) => event.type === 'error')).toBe(false);
|
||||
await vi.waitFor(() => expect(recordSpy).toHaveBeenCalledTimes(1));
|
||||
expect(recordSpy.mock.calls[0]![0]).toMatchObject({
|
||||
estimated_prompt_tokens: expect.any(Number),
|
||||
estimated_completion_tokens: expect.any(Number),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -18,14 +18,39 @@
|
||||
*/
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
ICompleteArguments,
|
||||
PuterMessage,
|
||||
} from '../../types.js';
|
||||
import { openAICompatParams } from '../../utils/openaiParams.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { estimateTextTokens } from '../../utils/usageEstimate.js';
|
||||
import {
|
||||
type ChatProviderConfig,
|
||||
OpenAICompatProvider,
|
||||
} from '../OpenAICompatProvider.js';
|
||||
import { MOONSHOT_MODELS } from './models.js';
|
||||
|
||||
/**
|
||||
* The K3-series request shape: reasoning always on, capped by
|
||||
* `max_completion_tokens`.
|
||||
*/
|
||||
const K31_MODEL_IDS = new Set(['kimi-k3.1-pro', 'kimi-k3.1-pro-highspeed']);
|
||||
const K31_REASONING_EFFORTS = ['low', 'high', 'max'];
|
||||
/** Moonshot's own output cap when the request names none. */
|
||||
const K31_DEFAULT_MAX_COMPLETION_TOKENS = 131_072;
|
||||
|
||||
const isK31 = (model: IChatModel) => K31_MODEL_IDS.has(model.id);
|
||||
|
||||
/** A replayed assistant turn's reasoning, under the wire key or Puter's. */
|
||||
const replayedReasoning = (message: PuterMessage): string | undefined => {
|
||||
const reasoning = message.reasoning_content ?? message.reasoning;
|
||||
return typeof reasoning === 'string' ? reasoning : undefined;
|
||||
};
|
||||
|
||||
export class MoonshotProvider extends OpenAICompatProvider {
|
||||
constructor(config: ChatProviderConfig, meteringService: MeteringService) {
|
||||
super(meteringService, {
|
||||
@@ -41,4 +66,79 @@ export class MoonshotProvider extends OpenAICompatProvider {
|
||||
inlineImages: 'vision',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* K3.1 Pro reads replayed reasoning back as prompt, which the message
|
||||
* estimate the credit gate works from doesn't count.
|
||||
*/
|
||||
requestPricing(args: ICompleteArguments, model: IChatModel) {
|
||||
if (!isK31(model)) return {};
|
||||
const additionalPromptTokens = (args.messages ?? []).reduce(
|
||||
(tokens, message) => {
|
||||
if (message.role !== 'assistant') return tokens;
|
||||
const reasoning = replayedReasoning(message);
|
||||
return tokens + (reasoning ? estimateTextTokens(reasoning) : 0);
|
||||
},
|
||||
0,
|
||||
);
|
||||
return { additionalPromptTokens };
|
||||
}
|
||||
|
||||
protected override preservesToolCallOrder(model: IChatModel) {
|
||||
return isK31(model);
|
||||
}
|
||||
|
||||
protected override preservesAssistantContent(model: IChatModel) {
|
||||
return isK31(model);
|
||||
}
|
||||
|
||||
/** K3.1 Pro wants a turn's reasoning back under `reasoning_content`, once. */
|
||||
protected override prepareMessages(
|
||||
messages: PuterMessage[],
|
||||
model: IChatModel,
|
||||
) {
|
||||
if (!isK31(model)) return messages;
|
||||
return messages.map((message) => {
|
||||
if (message.role !== 'assistant') return message;
|
||||
const { reasoning, normalized: _normalized, ...rest } = message;
|
||||
const replayed = replayedReasoning(message);
|
||||
return replayed === undefined
|
||||
? rest
|
||||
: { ...rest, reasoning_content: replayed };
|
||||
});
|
||||
}
|
||||
|
||||
protected override vendorParams(
|
||||
params: Record<string, unknown>,
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
) {
|
||||
if (!isK31(model)) return params;
|
||||
const effort = args.reasoning_effort ?? args.reasoning?.effort;
|
||||
if (effort !== undefined && !K31_REASONING_EFFORTS.includes(effort)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`reasoning_effort must be one of ${K31_REASONING_EFFORTS.join(', ')} for ${model.id}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// The K3 series deprecates `max_tokens` in favour of `max_completion_tokens`.
|
||||
const { max_tokens, ...rest } = params;
|
||||
return {
|
||||
...rest,
|
||||
...openAICompatParams(
|
||||
{ ...args, reasoning_effort: effort },
|
||||
'chat',
|
||||
{
|
||||
only: [
|
||||
'tool_choice',
|
||||
'response_format',
|
||||
'reasoning_effort',
|
||||
],
|
||||
},
|
||||
),
|
||||
max_completion_tokens:
|
||||
max_tokens ?? K31_DEFAULT_MAX_COMPLETION_TOKENS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,53 @@
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
// -- Kimi K3.1 Pro ------------------------------------------------
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/kimi-k3.1-pro',
|
||||
id: 'kimi-k3.1-pro',
|
||||
name: 'Kimi K3.1 Pro',
|
||||
aliases: ['moonshotai/kimi-k3.1-pro', 'moonshot/kimi-k3.1-pro'],
|
||||
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200, // $2.00 per 1M
|
||||
completion_tokens: 1000, // $10.00 per 1M
|
||||
cached_tokens: 20, // $0.20 per 1M
|
||||
cache_write_tokens: 200, // $2.00 per 1M
|
||||
},
|
||||
context: 1_048_576,
|
||||
max_tokens: 1_048_576,
|
||||
tool_call: true,
|
||||
release_date: '2026-10-24',
|
||||
},
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/kimi-k3.1-pro-highspeed',
|
||||
id: 'kimi-k3.1-pro-highspeed',
|
||||
name: 'Kimi K3.1 Pro Highspeed',
|
||||
aliases: [
|
||||
'moonshotai/kimi-k3.1-pro-highspeed',
|
||||
'moonshot/kimi-k3.1-pro-highspeed',
|
||||
],
|
||||
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 400, // $4.00 per 1M
|
||||
completion_tokens: 2000, // $20.00 per 1M
|
||||
cached_tokens: 20, // $0.20 per 1M
|
||||
cache_write_tokens: 400, // $4.00 per 1M
|
||||
},
|
||||
context: 1_048_576,
|
||||
max_tokens: 1_048_576,
|
||||
tool_call: true,
|
||||
release_date: '2026-10-24',
|
||||
},
|
||||
|
||||
// -- Flagship ----------------------------------------------------
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/kimi-k3',
|
||||
@@ -35,6 +82,7 @@ export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 300, // $3.00 per 1M
|
||||
completion_tokens: 1500, // $15.00 per 1M
|
||||
cached_tokens: 30, // $0.30 per 1M
|
||||
cache_write_tokens: 300, // $3.00 per 1M
|
||||
},
|
||||
context: 1_048_576,
|
||||
max_tokens: 1_048_576,
|
||||
|
||||
Loaded 100 of 677 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user