mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
200
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a1a24dfe | ||
|
|
620a94c7fc | ||
|
|
93798cba93 | ||
|
|
848e336455 | ||
|
|
a9a8df6409 | ||
|
|
0a8b37ae56 | ||
|
|
23782dec12 | ||
|
|
6a6cf78c03 | ||
|
|
8264837d9f | ||
|
|
eb92421a59 | ||
|
|
e0af3361ed | ||
|
|
9bd7ba7079 | ||
|
|
8f3d4cc40f | ||
|
|
96d91cd74e | ||
|
|
cdb3110857 | ||
|
|
d934a47c5e | ||
|
|
58456ae87c | ||
|
|
abb70c14a6 | ||
|
|
18fd655645 | ||
|
|
480fa7b85b | ||
|
|
47b54aacb4 | ||
|
|
dd3c0dd485 | ||
|
|
e0689986ed | ||
|
|
0f54588b31 | ||
|
|
0cb9f7707d | ||
|
|
622b782f4e | ||
|
|
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 | ||
|
|
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 }}
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Notify HeyPuter (dev)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
puter_ref:
|
||||
description: "puter branch, tag or SHA"
|
||||
type: string
|
||||
default: "main"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
if: github.repository == 'HeyPuter/puter'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require maintain or admin
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ACTOR: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
ROLE=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .role_name)
|
||||
if [ "$ROLE" != "maintain" ] && [ "$ROLE" != "admin" ]; then
|
||||
echo "::error::$ACTOR has role '$ROLE'; maintain or admin required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
notify:
|
||||
needs: authorize
|
||||
runs-on: ubuntu-latest
|
||||
# Holds HEYPUTER_DISPATCH_TOKEN; limited to main so branch copies of this file can't read it.
|
||||
environment: heyputer-dispatch
|
||||
steps:
|
||||
- name: Trigger heyputer dev deploy
|
||||
env:
|
||||
PUTER_REF: ${{ inputs.puter_ref }}
|
||||
TOKEN: ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
jq -nc --arg ref "$PUTER_REF" \
|
||||
'{event_type: "puter-dev-requested", client_payload: {puter_ref: $ref}}' |
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
|
||||
-d @-
|
||||
echo "Requested \`$PUTER_REF\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
environment: heyputer-dispatch
|
||||
steps:
|
||||
- name: Trigger heyputer build
|
||||
run: |
|
||||
@@ -15,4 +16,4 @@ jobs:
|
||||
-H "Authorization: token ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
|
||||
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
|
||||
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
|
||||
@@ -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'
|
||||
|
||||
@@ -201,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.
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ store:
|
||||
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.
|
||||
`from=end` with no cursor answers an empty page whose cursor holds the
|
||||
slice's newest `id` and the slice's latest `(replaced_at, id)` as the
|
||||
replacement mark.
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -257,8 +257,6 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.created_ts).toBeGreaterThan(0);
|
||||
// `directories` is only sent to user actors — confirm it’s present.
|
||||
expect(body.directories).toBeDefined();
|
||||
// taskbar_items is only sent to user actors.
|
||||
expect(body).toHaveProperty('taskbar_items');
|
||||
});
|
||||
|
||||
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
|
||||
@@ -322,7 +320,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(flags.payment_bypass).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
|
||||
it('strips created_ts and human_readable_age fields for app actors', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -340,9 +338,6 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
// is_user_token is stripped for app actors.
|
||||
expect(body.is_user_token).toBeUndefined();
|
||||
expect(body.desktop_bg_url).toBeUndefined();
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
expect(body.human_readable_age).toBeUndefined();
|
||||
// Account age, in either form, is not exposed to apps.
|
||||
expect(body.created_ts).toBeUndefined();
|
||||
@@ -432,7 +427,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot add taskbar_items for an app actor, which core omits', async () => {
|
||||
it('cannot add directories for an app actor, which core omits', async () => {
|
||||
const user = await seedUser();
|
||||
const body = await whoamiWithListener(
|
||||
makeActor({
|
||||
@@ -440,11 +435,11 @@ describe('whoami extension — handleWhoami', () => {
|
||||
app: { uid: 'app-test-actor' },
|
||||
}),
|
||||
(_key, event) => {
|
||||
event.details.taskbar_items = [{ name: 'injected' }];
|
||||
event.details.directories = { '/injected': 'x' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(body).not.toHaveProperty('taskbar_items');
|
||||
expect(body).not.toHaveProperty('directories');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,7 +520,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.otp_secret).toBeUndefined();
|
||||
expect(body.signup_ip).toBeUndefined();
|
||||
// Not just absent as a key — the values must not appear anywhere
|
||||
// in the payload (nested under metadata, taskbar items, …).
|
||||
// in the payload (nested under metadata, …).
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain('5551234567');
|
||||
expect(serialized).not.toContain('fp_ABC123');
|
||||
|
||||
+3
-17
@@ -1,6 +1,5 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import {
|
||||
buildUserDetails,
|
||||
scrubSensitive,
|
||||
@@ -26,7 +25,7 @@ const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
|
||||
export const handleWhoami = async (
|
||||
req: Request,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
@@ -54,19 +53,10 @@ export const handleWhoami = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const rawIconSize =
|
||||
typeof req.query?.icon_size === 'string'
|
||||
? Number(req.query.icon_size)
|
||||
: undefined;
|
||||
const iconSize =
|
||||
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
|
||||
? rawIconSize
|
||||
: undefined;
|
||||
|
||||
const details = await buildUserDetails(
|
||||
user,
|
||||
{ config: extension.config, clients, stores, services },
|
||||
{ isUser, iconSize, noIcons: !iconSize },
|
||||
{ isUser },
|
||||
);
|
||||
details.human_readable_age = user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
@@ -81,9 +71,6 @@ export const handleWhoami = async (
|
||||
delete details.email;
|
||||
delete details.unconfirmed_email;
|
||||
}
|
||||
delete details.desktop_bg_url;
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.created_ts;
|
||||
delete details.is_user_token;
|
||||
@@ -121,8 +108,7 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
+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
+785
-767
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,28 @@ describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
expect(postgres().timestampFromUnixSql()).toBe('to_timestamp(?)');
|
||||
});
|
||||
|
||||
it('reads a timestamp column back as unix seconds in each engine', () => {
|
||||
expect(sqlite().unixSecondsSql('`t`')).toBe(
|
||||
"CAST(strftime('%s', `t`) AS INTEGER)",
|
||||
);
|
||||
expect(mysql().unixSecondsSql('`t`')).toBe('UNIX_TIMESTAMP(`t`)');
|
||||
expect(postgres().unixSecondsSql('`t`')).toBe(
|
||||
'EXTRACT(EPOCH FROM `t`)::bigint',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the database clock as epoch milliseconds in each dialect', () => {
|
||||
expect(sqlite().nowMsExpr()).toBe(
|
||||
"CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
|
||||
);
|
||||
expect(mysql().nowMsExpr()).toBe(
|
||||
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
|
||||
);
|
||||
expect(postgres().nowMsExpr()).toBe(
|
||||
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
|
||||
);
|
||||
});
|
||||
|
||||
it('describes a stored timestamp the way each driver reads it back', () => {
|
||||
const seconds = Date.UTC(2026, 9, 11, 8, 5, 9) / 1000;
|
||||
expect(sqlite().timestampValue(seconds)).toBe('2026-10-11 08:05:09');
|
||||
|
||||
@@ -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,34 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp column as unix seconds, computed by the database: the mysql
|
||||
* driver reads a stored UTC timestamp as local time, so parsing it in JS
|
||||
* can be off by the server's UTC offset.
|
||||
*/
|
||||
unixSecondsSql(column: string): string {
|
||||
return this.case({
|
||||
postgres: `EXTRACT(EPOCH FROM ${column})::bigint`,
|
||||
mysql: `UNIX_TIMESTAMP(${column})`,
|
||||
otherwise: `CAST(strftime('%s', ${column}) AS INTEGER)`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The database's own clock as epoch milliseconds, so nodes whose clocks
|
||||
* disagree still agree on it. The mysql form ignores the session time zone,
|
||||
* so a DST change can't shift it.
|
||||
*/
|
||||
nowMsExpr(): string {
|
||||
return this.case({
|
||||
sqlite: "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
|
||||
postgres:
|
||||
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
|
||||
otherwise:
|
||||
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What a read of a timestamp column holding `seconds` returns: sqlite gives
|
||||
* back the UTC text it stored, mysql2 and pg a `Date` read in node's time
|
||||
|
||||
@@ -217,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 = 91;
|
||||
const CURRENT_SCHEMA_VERSION = 98;
|
||||
|
||||
/**
|
||||
* 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,97 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
);
|
||||
});
|
||||
|
||||
it('indexes notification.replaced_at per mailbox slice for catch-up', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA index_info(`idx_notification_replaced`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'user_id',
|
||||
'audience',
|
||||
'app_uid',
|
||||
'replaced_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps notification.send_key unique', async () => {
|
||||
const rows = await client.read('PRAGMA index_list(`notification`)');
|
||||
expect(
|
||||
rows.find((r) => r.name === 'idx_notification_send_key'),
|
||||
).toMatchObject({ unique: 1 });
|
||||
const columns = await client.read(
|
||||
'PRAGMA index_info(`idx_notification_send_key`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual(['send_key']);
|
||||
});
|
||||
|
||||
it('creates event_user_handlers with its unique and holder indexes', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA table_info(`event_user_handlers`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'id',
|
||||
'app_uid',
|
||||
'holder_user_id',
|
||||
'name',
|
||||
'source',
|
||||
'source_hash',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'scope_since',
|
||||
]);
|
||||
const indexes = await client.read(
|
||||
'PRAGMA index_list(`event_user_handlers`)',
|
||||
);
|
||||
expect(
|
||||
indexes.find(
|
||||
(i) => i.name === 'idx_event_user_handlers_app_holder_name',
|
||||
),
|
||||
).toMatchObject({ unique: 1 });
|
||||
expect(
|
||||
indexes.find((i) => i.name === 'idx_event_user_handlers_holder_app'),
|
||||
).toMatchObject({ unique: 0 });
|
||||
const columnsOf = async (index: string) =>
|
||||
(await client.read(`PRAGMA index_info(\`${index}\`)`)).map((c) =>
|
||||
String(c.name),
|
||||
);
|
||||
expect(await columnsOf('idx_event_user_handlers_app_holder_name')).toEqual(
|
||||
['app_uid', 'holder_user_id', 'name'],
|
||||
);
|
||||
expect(await columnsOf('idx_event_user_handlers_holder_app')).toEqual([
|
||||
'holder_user_id',
|
||||
'app_uid',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves event_handlers as it was', async () => {
|
||||
const columns = await client.read('PRAGMA table_info(`event_handlers`)');
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'id',
|
||||
'app_uid',
|
||||
'name',
|
||||
'source',
|
||||
'source_hash',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds event_subscriptions.handler_scope, app scope for existing rows', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA table_info(`event_subscriptions`)',
|
||||
);
|
||||
expect(columns.find((c) => c.name === 'handler_scope')).toMatchObject({
|
||||
notnull: 1,
|
||||
dflt_value: '0',
|
||||
});
|
||||
const indexes = await client.read(
|
||||
'PRAGMA index_list(`event_subscriptions`)',
|
||||
);
|
||||
expect(indexes.map((i) => String(i.name))).toContain(
|
||||
'idx_event_subscriptions_holder',
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -130,6 +130,13 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[88, ['0093_fsentries-parent-uid-index.sql']],
|
||||
[89, ['0094_deleted-apps.sql']],
|
||||
[90, ['0095_kv-share-handle-levels.sql']],
|
||||
[91, ['0096_kv-share-handle-quota-indexes.sql']],
|
||||
[92, ['0097_user-to-user-holder-permission-index.sql']],
|
||||
[93, ['0098_notification-groups.sql']],
|
||||
[94, ['0099_kv-share-handle-notify.sql']],
|
||||
[95, ['0100_notification-send-key.sql']],
|
||||
[96, ['0101_user-scoped-handlers.sql']],
|
||||
[97, ['0102_notification-scope-id-index.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -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,43 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `notification`.`send_key`. See sqlite/0100_notification-send-key.sql for the
|
||||
-- rationale. The key is a uuid, so ascii and compared byte for byte. No
|
||||
-- per-file applied-state tracking, so every step is guarded and a replay does
|
||||
-- nothing.
|
||||
|
||||
CALL _puter_add_col('notification', 'send_key', '`send_key` char(36) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_send_key_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_send_key'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD UNIQUE KEY `idx_notification_send_key`
|
||||
(`send_key`), ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_notification_send_key_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope, kept apart from
|
||||
-- `event_handlers` so code that predates them never reads them. See
|
||||
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- and a guarded column add. There is no per-file applied-state tracking, so a
|
||||
-- replay has to be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`holder_user_id` int unsigned NOT NULL,
|
||||
`name` varchar(128) NOT NULL,
|
||||
`source` mediumtext NOT NULL,
|
||||
`source_hash` char(64) NOT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
`updated_at` bigint NOT NULL,
|
||||
-- When the scope first held a handler; kept across handler churn, so the
|
||||
-- rent sweep sees the scope's age, not its oldest remaining handler's.
|
||||
`scope_since` bigint NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_event_user_handlers_app_holder_name` (`app_uid`, `holder_user_id`, `name`),
|
||||
KEY `idx_event_user_handlers_holder_app` (`holder_user_id`, `app_uid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Guarded here rather than through `_puter_add_col`, so the file also applies
|
||||
-- on its own to a database that never ran the earlier migrations.
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_handler_scope;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_event_subscriptions_handler_scope()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND COLUMN_NAME = 'handler_scope'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD COLUMN `handler_scope` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
ALGORITHM=INSTANT;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_event_subscriptions_handler_scope();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_handler_scope;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- 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` (`user_id`, `audience`, `app_uid`, `id`). See
|
||||
-- sqlite/0102_notification-scope-id-index.sql for the rationale. No per-file
|
||||
-- applied-state tracking, so the index is added only when missing and a
|
||||
-- replay does nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_id_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_scope_id_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_scope_id'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_scope_id`
|
||||
(`user_id`, `audience`, `app_uid`, `id`), ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_notification_scope_id_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_id_index;
|
||||
@@ -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,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- notification.send_key. See sqlite/0100_notification-send-key.sql for the
|
||||
-- rationale. Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS send_key char(36) DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_send_key
|
||||
ON notification (send_key);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope. See
|
||||
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_user_handlers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
holder_user_id INTEGER NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
source_hash VARCHAR(64) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
scope_since BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_user_handlers_app_holder_name
|
||||
ON event_user_handlers (app_uid, holder_user_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_user_handlers_holder_app
|
||||
ON event_user_handlers (holder_user_id, app_uid);
|
||||
|
||||
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS handler_scope smallint NOT NULL DEFAULT 0;
|
||||
@@ -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/>.
|
||||
|
||||
-- notification (user_id, audience, app_uid, id). See
|
||||
-- sqlite/0102_notification-scope-id-index.sql for the rationale. Idempotent via
|
||||
-- IF NOT EXISTS.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_scope_id
|
||||
ON notification (user_id, audience, app_uid, id);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `notification`.`send_key`: the key of the delayed send (a `deferred`
|
||||
-- schedule) that wrote this row or last replaced it, derived from the
|
||||
-- schedule and its slot. Unique, so one slot can never write two rows, and a
|
||||
-- send that crashed after writing finds its row again by it. Null on every row
|
||||
-- a delayed send never touched.
|
||||
|
||||
ALTER TABLE `notification` ADD COLUMN `send_key` TEXT DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_notification_send_key`
|
||||
ON `notification` (`send_key`);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope, in a table of their
|
||||
-- own so `event_handlers` stays the app owner's set. Same columns as
|
||||
-- `event_handlers` (see 0076_event-handlers.sql) plus the holder.
|
||||
--
|
||||
-- - `holder_user_id` : the user whose scope this is. A name is unique per
|
||||
-- (app, holder), so the same name can live in the app's
|
||||
-- set and in each holder's.
|
||||
-- - `(holder_user_id, app_uid)` : a holder's scopes, for listing and for the
|
||||
-- rent count.
|
||||
--
|
||||
-- `event_subscriptions.handler_scope` says which table a row's `handler_name`
|
||||
-- refers to: 0 the app's set, 1 the row holder's scope.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"holder_user_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"source_hash" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL, -- unix seconds
|
||||
"updated_at" INTEGER NOT NULL,
|
||||
"scope_since" INTEGER NOT NULL -- unix seconds; the scope's first handler, kept across churn
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_user_handlers_app_holder_name`
|
||||
ON `event_user_handlers` (`app_uid`, `holder_user_id`, `name`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_user_handlers_holder_app`
|
||||
ON `event_user_handlers` (`holder_user_id`, `app_uid`);
|
||||
|
||||
ALTER TABLE `event_subscriptions` ADD COLUMN `handler_scope` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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/>.
|
||||
|
||||
-- Index a mailbox slice by row id, so a fetch finds the newest row of a slice,
|
||||
-- and pages forward through one, with a seek rather than a scan of the slice.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_scope_id`
|
||||
ON `notification` (`user_id`, `audience`, `app_uid`, `id`);
|
||||
@@ -534,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>;
|
||||
@@ -574,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,
|
||||
@@ -588,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;
|
||||
@@ -303,6 +323,8 @@ export type EventMap = {
|
||||
// the abuse extension cap sends per device (across accounts) so a
|
||||
// device farm can't dodge the per-account cap with fresh signups.
|
||||
device_fingerprint: string | null;
|
||||
// Browser-signals dispatch id the client sent, or null.
|
||||
dispatch_id: string | null;
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
// With `allowed` false, offer the card fallback in place of SMS. Only
|
||||
@@ -389,6 +411,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.
|
||||
@@ -535,9 +560,30 @@ export type EventMap = {
|
||||
appUid: string | null;
|
||||
value: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
/** An earlier notification replaced in place; `createdAt` is when. */
|
||||
replaced?: true;
|
||||
/** Events handler runs behind whatever created it. */
|
||||
handlerDepth?: number;
|
||||
};
|
||||
/**
|
||||
* Every non-silent write or update, only on the node that wrote it. The
|
||||
* value is the listener's own copy of what the socket carries.
|
||||
*/
|
||||
'notif.written': {
|
||||
userId: number;
|
||||
uid: string;
|
||||
type: string;
|
||||
audience: string;
|
||||
/** The row's app; null when it names none or an update could not tell. */
|
||||
appUid: string | null;
|
||||
value: Record<string, unknown>;
|
||||
/** An earlier notification rewritten in place. */
|
||||
updated?: true;
|
||||
/** Names this one send, for a receipt asked about it. */
|
||||
sendId: string;
|
||||
/** The same `sendId` may be emitted again, as a delayed send is. */
|
||||
replayable?: true;
|
||||
};
|
||||
/**
|
||||
* One notification, addressed at whatever sockets each region holds for the
|
||||
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
|
||||
@@ -546,9 +592,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
|
||||
@@ -598,9 +666,18 @@ export type EventMap = {
|
||||
// and going away (1→0, whether by removing the last handler or by the
|
||||
// destroy route). `actor.user` is the app's owner, not the caller — a
|
||||
// developer session publishing for an app it owns is the common case, but
|
||||
// billing follows ownership.
|
||||
'events.worker.create': { actor: Actor; appUid: string };
|
||||
'events.worker.destroy': { actor: Actor; appUid: string };
|
||||
// billing follows ownership. For `scope: 'user'` the unit is one user's
|
||||
// handlers in one app instead, and `actor.user` is that user.
|
||||
'events.worker.create': {
|
||||
actor: Actor;
|
||||
appUid: string;
|
||||
scope: 'app' | 'user';
|
||||
};
|
||||
'events.worker.destroy': {
|
||||
actor: Actor;
|
||||
appUid: string;
|
||||
scope: 'app' | 'user';
|
||||
};
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
@@ -665,6 +742,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
|
||||
@@ -683,6 +767,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 }[];
|
||||
}>;
|
||||
@@ -690,7 +775,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;
|
||||
@@ -878,6 +963,9 @@ export type AiCostFactorEvent = {
|
||||
*/
|
||||
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
|
||||
|
||||
/** Status a vetoing `before` listener may answer with. */
|
||||
export type LifecycleRejectStatus = 403 | 429;
|
||||
|
||||
/**
|
||||
* Payload for `driver.<iface>.<method>.<phase>` events.
|
||||
*
|
||||
@@ -907,6 +995,8 @@ export type DriverMethodLifecycleEvent = {
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
/** Status for a veto: 403 (default) or 429 for a throttle. */
|
||||
rejectStatus?: LifecycleRejectStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -941,6 +1031,8 @@ export type RouteLifecycleEvent = {
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
/** Status for a veto: 403 (default) or 429 for a throttle. */
|
||||
rejectStatus?: LifecycleRejectStatus;
|
||||
};
|
||||
|
||||
export type EventKey = keyof EventMap & string;
|
||||
@@ -949,11 +1041,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>;
|
||||
@@ -962,6 +1053,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. */
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
@@ -50,7 +54,10 @@ import {
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
import { kvSharePermission } from '../../services/kvShare/kvShares.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.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';
|
||||
@@ -1409,7 +1416,6 @@ describe('AuthController.handleLogin', () => {
|
||||
is_user_token: true,
|
||||
});
|
||||
expect(body.user.directories).toBeTypeOf('object');
|
||||
expect(Array.isArray(body.user.taskbar_items)).toBe(true);
|
||||
});
|
||||
|
||||
// A deleted row still answering from cache reaches the session INSERT.
|
||||
@@ -1750,6 +1756,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();
|
||||
@@ -2179,6 +2219,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.
|
||||
@@ -5019,6 +5131,26 @@ describe('AuthController phone verification — staging & reuse', () => {
|
||||
expect(staged).toBe('+14155550123');
|
||||
});
|
||||
|
||||
it('makes an email-gate-last account verify its card before its phone', async () => {
|
||||
const { user, actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
requires_card_verification: 1,
|
||||
});
|
||||
await server.stores.user.updateMetadata(user.id, {
|
||||
emailGateLast: true,
|
||||
});
|
||||
const prelude = stubPrelude();
|
||||
await withClients({ prelude }, async () => {
|
||||
await expect(
|
||||
controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
expect(prelude.createVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards an abuse-extension veto as 429 with the opaque reason, and sends nothing', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const emitAndWait = vi.fn(
|
||||
@@ -5084,7 +5216,7 @@ describe('AuthController phone verification — staging & reuse', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the client dispatch id on the sent signal', async () => {
|
||||
it('carries the client dispatch id on the check and sent events', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const emit = vi.fn();
|
||||
const emitAndWait = vi.fn(async () => {});
|
||||
@@ -5105,6 +5237,11 @@ describe('AuthController phone verification — staging & reuse', () => {
|
||||
expect.objectContaining({ dispatch_id: 'disp-123' }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(emitAndWait).toHaveBeenCalledWith(
|
||||
'puter.phone-verification.check',
|
||||
expect.objectContaining({ dispatch_id: 'disp-123' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT emit the sent signal when the send fails upstream', async () => {
|
||||
@@ -7612,6 +7749,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 ────────────────────────────────────
|
||||
@@ -7716,6 +7893,12 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
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 },
|
||||
@@ -9658,6 +9841,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(
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
isCardFallbackEnabled,
|
||||
phoneAttemptsKey,
|
||||
} from '../../util/cardFallback.js';
|
||||
import { isEmailGateLast } from '../../util/verificationOrder.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { cleanEmail, isStorableEmail } from '../../util/email.js';
|
||||
import { isGodmodeApp } from '../../util/godmodeApps.js';
|
||||
@@ -1220,6 +1221,18 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
/** The email-gate-last confirmation email; a failure never fails the gate. */
|
||||
private async sendDeferredConfirmation(userId: number): Promise<void> {
|
||||
try {
|
||||
await this.services.signup.sendDeferredConfirmationEmail(userId);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[verification] deferred confirmation email failed:',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async isCardFallbackEligible(user: {
|
||||
id: number;
|
||||
requires_phone_verification?: boolean | number | null;
|
||||
@@ -1265,6 +1278,12 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(403, 'Account suspended.', {
|
||||
legacyCode: 'account_suspended',
|
||||
});
|
||||
if (isEmailGateLast(user) && user.requires_card_verification)
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Card verification must be completed first.',
|
||||
{ legacyCode: 'conflict' },
|
||||
);
|
||||
if (!this.clients.prelude?.isConfigured())
|
||||
throw await this.smsSendError(
|
||||
503,
|
||||
@@ -1335,6 +1354,7 @@ export class AuthController extends PuterController {
|
||||
user_uid: user.uuid,
|
||||
phone: parsed.e164,
|
||||
device_fingerprint: req.deviceFingerprint ?? null,
|
||||
dispatch_id: dispatchId ?? null,
|
||||
allowed: true,
|
||||
reason: null as string | null,
|
||||
card_fallback_instead: false,
|
||||
@@ -1573,6 +1593,7 @@ export class AuthController extends PuterController {
|
||||
requires_phone_verification: 0,
|
||||
phone: pendingPhone,
|
||||
});
|
||||
await this.sendDeferredConfirmation(user.id);
|
||||
|
||||
// Run the verified-event listeners synchronously (emitAndWait, not
|
||||
// fire-and-forget emit) so a carrier-based card-verification waiver in
|
||||
@@ -1653,7 +1674,11 @@ export class AuthController extends PuterController {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
if (user.requires_phone_verification && !fallbackEligible)
|
||||
if (
|
||||
user.requires_phone_verification &&
|
||||
!fallbackEligible &&
|
||||
!isEmailGateLast(user)
|
||||
)
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Phone verification must be completed first.',
|
||||
@@ -1716,6 +1741,7 @@ export class AuthController extends PuterController {
|
||||
await this.stores.user.update(user.id, {
|
||||
requires_card_verification: 0,
|
||||
});
|
||||
await this.sendDeferredConfirmation(user.id);
|
||||
res.json({ card_verified: true, disabled: true });
|
||||
return;
|
||||
}
|
||||
@@ -1775,7 +1801,11 @@ export class AuthController extends PuterController {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
if (user.requires_phone_verification && !fallbackEligible)
|
||||
if (
|
||||
user.requires_phone_verification &&
|
||||
!fallbackEligible &&
|
||||
!isEmailGateLast(user)
|
||||
)
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Phone verification must be completed first.',
|
||||
@@ -1816,6 +1846,7 @@ export class AuthController extends PuterController {
|
||||
await this.stores.user.update(user.id, {
|
||||
requires_card_verification: 0,
|
||||
});
|
||||
await this.sendDeferredConfirmation(user.id);
|
||||
res.json({ card_verified: true, disabled: true });
|
||||
return;
|
||||
}
|
||||
@@ -1840,6 +1871,7 @@ export class AuthController extends PuterController {
|
||||
requires_card_verification: 0,
|
||||
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
|
||||
});
|
||||
await this.sendDeferredConfirmation(user.id);
|
||||
|
||||
// Awaited like `user.phone-verified`, so whatever a listener grants on
|
||||
// verification is in place before the client refreshes. emitAndWait
|
||||
@@ -3304,6 +3336,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,
|
||||
@@ -3327,7 +3367,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,
|
||||
@@ -3343,20 +3384,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(
|
||||
@@ -3804,8 +3850,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);
|
||||
@@ -3821,9 +3868,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
|
||||
@@ -3834,6 +3882,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`, {
|
||||
@@ -4269,7 +4320,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,
|
||||
@@ -4479,11 +4550,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
|
||||
@@ -4492,7 +4566,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 },
|
||||
);
|
||||
@@ -4605,13 +4681,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();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { Controller, Post } from '../../core/http/decorators.js';
|
||||
import { schedulesUnavailable } from '../../core/schedules.js';
|
||||
import type { BroadcastService } from '../../services/broadcast/BroadcastService.js';
|
||||
import type { ForwardBatch } from '../../services/events/forwardQueue.js';
|
||||
import { PuterController } from '../types.js';
|
||||
@@ -108,6 +109,42 @@ export class BroadcastController extends PuterController {
|
||||
);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule changes a region made for schedules this one owns, handed to
|
||||
* `services.schedules`; `events_disabled` on a server without it.
|
||||
*/
|
||||
@Post('/events/schedules', { subdomain: '*' })
|
||||
async schedules(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await broadcast.verifySignedRequest(
|
||||
req.rawBody,
|
||||
broadcastHeaders(req),
|
||||
);
|
||||
if (!verified.ok) {
|
||||
res.status(verified.status ?? 400).json({
|
||||
error: { message: verified.message ?? 'Bad request' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (verified.info?.ignored) {
|
||||
res.status(200).json({ ok: true, ...verified.info });
|
||||
return;
|
||||
}
|
||||
|
||||
const schedules = this.services.schedules;
|
||||
if (!schedules) throw schedulesUnavailable();
|
||||
const reply = await schedules.receivePeerChanges(req.body);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
}
|
||||
|
||||
const broadcastHeaders = (req: Request) => {
|
||||
|
||||
@@ -25,18 +25,13 @@ const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
|
||||
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
|
||||
|
||||
/**
|
||||
* Desktop/UI preference routes.
|
||||
*
|
||||
* Two categories:
|
||||
*
|
||||
* - User-level: desktop background, taskbar items (UserStore)
|
||||
* - Folder-level: layout, sort_by/sort_order (fsentries table)
|
||||
* Folder view preference routes: an explorer window's layout and sort order,
|
||||
* kept on the folder's fsentry.
|
||||
*/
|
||||
/**
|
||||
* Desktop preference writes — background, taskbar, layout, sort order. All four
|
||||
* persist to the user row on every call, and the GUI fires them on direct user
|
||||
* action, so a per-minute ceiling well above human speed is enough to catch a
|
||||
* stuck client.
|
||||
* Folder preference writes — layout, sort order. The GUI fires them on direct
|
||||
* user action, so a per-minute ceiling well above human speed is enough to
|
||||
* catch a stuck client.
|
||||
*/
|
||||
const PREFERENCE_WRITE_LIMIT = {
|
||||
scope: 'desktop-preference',
|
||||
@@ -47,89 +42,6 @@ const PREFERENCE_WRITE_LIMIT = {
|
||||
|
||||
export class DesktopController extends PuterController {
|
||||
registerRoutes(router) {
|
||||
// -- Desktop background --------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set-desktop-bg',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { url, color, fit } = req.body ?? {};
|
||||
|
||||
const patch = {};
|
||||
if (url !== undefined) {
|
||||
if (url !== null && typeof url !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`url` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_url = url;
|
||||
}
|
||||
if (color !== undefined) {
|
||||
if (color !== null && typeof color !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`color` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_color = color;
|
||||
}
|
||||
if (fit !== undefined) {
|
||||
if (fit !== null && typeof fit !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`fit` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_fit = fit;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new HttpError(400, 'No fields provided', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
await this.stores.user.update(req.actor.user.id, patch);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
// -- Taskbar items -------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/update-taskbar-items',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { items } = req.body ?? {};
|
||||
if (!Array.isArray(items)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Missing or invalid `items` array',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
await this.stores.user.update(req.actor.user.id, {
|
||||
taskbar_items: JSON.stringify(items),
|
||||
});
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
// -- Folder layout -------------------------------------------
|
||||
|
||||
router.post(
|
||||
|
||||
@@ -130,150 +130,6 @@ const callRoute = async (
|
||||
});
|
||||
};
|
||||
|
||||
// ── /set-desktop-bg ─────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set-desktop-bg', () => {
|
||||
it('persists url/color/fit on the user row', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({
|
||||
body: {
|
||||
url: 'https://cdn.test/wallpaper.png',
|
||||
color: '#000000',
|
||||
fit: 'cover',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.user.getById(userId);
|
||||
expect(refreshed?.desktop_bg_url).toBe(
|
||||
'https://cdn.test/wallpaper.png',
|
||||
);
|
||||
expect(refreshed?.desktop_bg_color).toBe('#000000');
|
||||
expect(refreshed?.desktop_bg_fit).toBe('cover');
|
||||
});
|
||||
|
||||
it('persists only the supplied fields (partial update)', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { color: '#ffffff' }, actor }),
|
||||
res,
|
||||
);
|
||||
const refreshed = await server.stores.user.getById(userId);
|
||||
expect(refreshed?.desktop_bg_color).toBe('#ffffff');
|
||||
// Untouched fields remain at their column default.
|
||||
expect(refreshed?.desktop_bg_url).toBeFalsy();
|
||||
expect(refreshed?.desktop_bg_fit).toBeFalsy();
|
||||
});
|
||||
|
||||
it('passes through `null` to clear a field', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
// First populate, then clear.
|
||||
const populate = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({
|
||||
body: { url: 'https://cdn.test/x.png' },
|
||||
actor,
|
||||
}),
|
||||
populate.res,
|
||||
);
|
||||
const before = await server.stores.user.getById(userId);
|
||||
expect(before?.desktop_bg_url).toBe('https://cdn.test/x.png');
|
||||
|
||||
const clear = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { url: null }, actor }),
|
||||
clear.res,
|
||||
);
|
||||
const after = await server.stores.user.getById(userId);
|
||||
expect(after?.desktop_bg_url).toBeNull();
|
||||
});
|
||||
|
||||
it('throws 400 when url is not a string or null', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { url: 123 }, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when no fields are provided', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /update-taskbar-items ───────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /update-taskbar-items', () => {
|
||||
it('persists items as a JSON-encoded string', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const items = [{ name: 'editor' }, { name: 'browser' }];
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/update-taskbar-items',
|
||||
makeReq({ body: { items }, actor }),
|
||||
res,
|
||||
);
|
||||
|
||||
const refreshed = await server.stores.user.getById(userId);
|
||||
expect(refreshed?.taskbar_items).toBe(JSON.stringify(items));
|
||||
});
|
||||
|
||||
it('throws 400 when items is missing', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/update-taskbar-items',
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when items is not an array', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/update-taskbar-items',
|
||||
makeReq({ body: { items: 'oops' }, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /set_layout ─────────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set_layout', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -736,6 +736,42 @@ describe('DriverController driver-method lifecycle events', () => {
|
||||
rejectReason: 'blocked in test',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 429 when a before listener vetoes with rejectStatus 429', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await grantKvPerm(actor);
|
||||
const key = `throttle-${uuidv4()}`;
|
||||
server.clients.event.on(
|
||||
'driver.puter-kvstore.set.before',
|
||||
(_k, data) => {
|
||||
if ((data.args as { key?: string })?.key === key) {
|
||||
data.allow = false;
|
||||
data.rejectStatus = 429;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key, value: 'v' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriverController.#handleListInterfaces', () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
checkDriverRateLimit,
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { LifecycleRejectStatus } from '../../clients/event/types.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import {
|
||||
isCreditExhaustion,
|
||||
@@ -50,6 +51,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 +261,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,
|
||||
@@ -322,7 +335,8 @@ export class DriverController extends PuterController {
|
||||
|
||||
if (req.actor) {
|
||||
const permService = this.services.permission as unknown as
|
||||
PermissionService | undefined;
|
||||
| PermissionService
|
||||
| undefined;
|
||||
if (permService) {
|
||||
// Build via PermissionUtil.join so any `:` in a driver or
|
||||
// interface name is escaped — raw interpolation would let a
|
||||
@@ -395,10 +409,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 +443,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
|
||||
@@ -474,6 +477,7 @@ export class DriverController extends PuterController {
|
||||
args,
|
||||
allow: true as boolean,
|
||||
rejectReason: undefined as string | undefined,
|
||||
rejectStatus: undefined as LifecycleRejectStatus | undefined,
|
||||
};
|
||||
await this.clients.event?.emitAndWait(
|
||||
`driver.${ifaceName}.${method}.before`,
|
||||
@@ -495,11 +499,12 @@ export class DriverController extends PuterController {
|
||||
},
|
||||
{},
|
||||
);
|
||||
const throttled = beforeEvent.rejectStatus === 429;
|
||||
throw new HttpError(
|
||||
403,
|
||||
throttled ? 429 : 403,
|
||||
beforeEvent.rejectReason ??
|
||||
`Blocked by policy: ${ifaceName}:${method}`,
|
||||
{ legacyCode: 'forbidden' },
|
||||
{ legacyCode: throttled ? 'too_many_requests' : 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import { DURABLE_LIST_LIMIT_CAP } from '../../stores/events/DurableSubscriptionStore.js';
|
||||
import { EVENTS_WORKERS_LIST_LIMIT_CAP } from '../../stores/events/EventHandlerStore.js';
|
||||
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
|
||||
import {
|
||||
SCHEDULE_LIST_LIMIT_CAP,
|
||||
schedulesUnavailable,
|
||||
type ScheduleServiceContract,
|
||||
} from '../../core/schedules.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { EVENTS_WORKER_PREFIX } from '../../services/events/workerRuntime.js';
|
||||
@@ -59,6 +64,8 @@ const EVENTS_SCRIPT_REGEX = new RegExp(`^${EVENTS_WORKER_PREFIX}[a-f0-9]{32}$`);
|
||||
|
||||
/** Matches the edge dispatcher's own validation of the app uid it forwards. */
|
||||
const APP_UID_REGEX = /^app-[A-Za-z0-9_-]{1,64}$/;
|
||||
/** A user uuid. */
|
||||
const HOLDER_REGEX = /^[A-Za-z0-9-]{1,64}$/;
|
||||
|
||||
@Controller('/events')
|
||||
export class EventsController extends PuterController {
|
||||
@@ -104,12 +111,31 @@ 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.
|
||||
*
|
||||
* Stateless: no subscription, no stored position, nothing written. The
|
||||
* cursor comes back to the caller and goes out again as `cursor` (or
|
||||
* `after`, the name it first shipped under).
|
||||
* `after`, the name it first shipped under). `from=end` with no cursor
|
||||
* starts at the newest row instead of the oldest.
|
||||
*/
|
||||
@Get('/fetch', {
|
||||
subdomain: 'api',
|
||||
@@ -120,9 +146,16 @@ export class EventsController extends PuterController {
|
||||
async fetch(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
const from = query.from as 'start' | 'end' | undefined;
|
||||
if (from !== undefined && from !== 'start' && from !== 'end') {
|
||||
throw new HttpError(400, '`from` must be `start` or `end`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const page = await this.services.events.fetchMissed(actor, {
|
||||
subject: typeof query.subject === 'string' ? query.subject : '',
|
||||
from,
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
after: typeof query.after === 'string' ? query.after : undefined,
|
||||
limit: normalizeLimit(query.limit, {
|
||||
@@ -151,6 +184,88 @@ export class EventsController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
// -- Schedules ---------------------------------------------------
|
||||
//
|
||||
// An app's handler run on a timer, served by `services.schedules` when a
|
||||
// server has it and answered `events_disabled` when it doesn't. Creating
|
||||
// one needs an app context; listing, reading and removing are scoped the
|
||||
// way the durable verbs are. The create and remove verbs spend the
|
||||
// subscribe and unsubscribe budgets in the service, shared with those
|
||||
// verbs.
|
||||
|
||||
#schedules(): ScheduleServiceContract {
|
||||
const schedules = this.services.schedules;
|
||||
if (!schedules) throw schedulesUnavailable();
|
||||
return schedules;
|
||||
}
|
||||
|
||||
/** POST /events/schedules — create a schedule for the calling app. */
|
||||
@Post('/schedules', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async createSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(await this.#schedules().createFor(actor, bodyRecord(req)));
|
||||
}
|
||||
|
||||
/** GET /events/schedules — the caller's schedules, one page at a time. */
|
||||
@Get('/schedules', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSchedules(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.#schedules().list(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: SCHEDULE_LIST_LIMIT_CAP,
|
||||
}),
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
includeTotal: query.includeTotal === 'true',
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items,
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /events/schedules/remove — end one schedule. An id the caller does
|
||||
* not hold reads as absent.
|
||||
*/
|
||||
@Post('/schedules/remove', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async removeSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
await this.#schedules().remove(actor, bodyRecord(req).scheduleId);
|
||||
res.json({});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/schedules/:scheduleId — one schedule, ended or not. An id the
|
||||
* caller does not hold reads as absent.
|
||||
*/
|
||||
@Get('/schedules/:scheduleId', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async getSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(await this.#schedules().get(actor, req.params.scheduleId));
|
||||
}
|
||||
|
||||
// -- Cross-user key-value handles --------------------------------
|
||||
|
||||
/**
|
||||
@@ -297,6 +412,7 @@ export class EventsController extends PuterController {
|
||||
handlers: await this.services.events.listHandlers(actor, {
|
||||
appUid:
|
||||
typeof query.appUid === 'string' ? query.appUid : undefined,
|
||||
scope: query.scope,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -395,6 +511,15 @@ export class EventsController extends PuterController {
|
||||
throw new HttpError(400, 'Missing or invalid `script`/`appUid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
// Absent or empty is the app's own scope.
|
||||
const holder = body.holder === undefined ? '' : body.holder;
|
||||
if (
|
||||
typeof holder !== 'string' ||
|
||||
!(holder === '' || HOLDER_REGEX.test(holder))
|
||||
)
|
||||
throw new HttpError(400, 'Invalid `holder`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const deployer = this.#eventsWorkerDeployer();
|
||||
if (!deployer.enabled) {
|
||||
@@ -402,15 +527,25 @@ export class EventsController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await deployer.ensure(appUid, script);
|
||||
const scope = await deployer.scopeFor(appUid, holder);
|
||||
if (!scope) {
|
||||
res.status(404).json({ deployed: false, reason: 'no-holder' });
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await deployer.ensure(scope, script);
|
||||
if (outcome === 'deployed') {
|
||||
res.json({ deployed: true });
|
||||
return;
|
||||
}
|
||||
// `failed` and `throttled` are worth another attempt: the rest say
|
||||
// this script is not what the app's handlers currently are.
|
||||
// `failed`, `throttled` and `busy` are worth another attempt: the rest
|
||||
// say this script is not what the app's handlers currently are.
|
||||
res.status(
|
||||
outcome === 'failed' || outcome === 'throttled' ? 502 : 404,
|
||||
outcome === 'failed' ||
|
||||
outcome === 'throttled' ||
|
||||
outcome === 'busy'
|
||||
? 502
|
||||
: 404,
|
||||
).json({
|
||||
deployed: false,
|
||||
reason: outcome,
|
||||
@@ -431,14 +566,22 @@ export class EventsController extends PuterController {
|
||||
override onServerStart(): void {
|
||||
const deployer = this.#eventsWorkerDeployer();
|
||||
if (!deployer.enabled) return;
|
||||
this.services.events.useEagerDeploy((scope, script) =>
|
||||
deployer.deployEagerly(scope, script),
|
||||
);
|
||||
if (this.config.workers?.localServer) {
|
||||
this.services.events.useWorkerTransport(
|
||||
new LocalEventsInvokeTransport(this.services, deployer),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.services.events.useWorkerMissHandler((appUid, script) =>
|
||||
deployer.ensure(appUid, script),
|
||||
this.services.events.useWorkerMissHandler(
|
||||
async (appUid, script, options) => {
|
||||
const scope = await deployer.scopeFor(appUid, options.holder);
|
||||
return scope
|
||||
? deployer.ensure(scope, script, options)
|
||||
: 'no-holder';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,23 +34,46 @@ import {
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_MAX,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
EVENTS_HANDLER_PUBLISH_LIMIT,
|
||||
EVENTS_HANDLER_SOURCE_MAX_BYTES,
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_USER_HANDLERS_PER_SCOPE,
|
||||
EVENTS_USER_SCOPE_SOURCE_MAX_BYTES,
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
RENTED_UNITS_PER_USER,
|
||||
limitFor,
|
||||
tiered,
|
||||
type TieredLimit,
|
||||
} from './limits.js';
|
||||
|
||||
const tiers: Array<[string, TieredLimit]> = [
|
||||
['durable subscriptions per account', EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER],
|
||||
[
|
||||
'durable subscriptions per account',
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
],
|
||||
['durable subscriptions per app', EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP],
|
||||
['live share handles per account', EVENTS_KV_HANDLES_PER_USER],
|
||||
['live share handles per app', EVENTS_KV_HANDLES_PER_APP],
|
||||
];
|
||||
|
||||
describe('tiered', () => {
|
||||
it('gives paid plans the base, and the free and temporary tiers their own', () => {
|
||||
const limit = tiered(30, 20, 10);
|
||||
expect(limitFor(limit, null)).toBe(30);
|
||||
expect(limitFor(limit, 'some_plan')).toBe(30);
|
||||
expect(limitFor(limit, DEFAULT_FREE_SUBSCRIPTION)).toBe(20);
|
||||
expect(limitFor(limit, DEFAULT_TEMP_SUBSCRIPTION)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tiered subscription quotas', () => {
|
||||
it.each(tiers)('%s never lets a free tier exceed paid', (_name, tier) => {
|
||||
for (const n of Object.values(tier.bySubscription))
|
||||
@@ -105,9 +128,9 @@ describe('the tiered subscription quotas', () => {
|
||||
});
|
||||
|
||||
it('holds an unrecognised plan to the paid base', () => {
|
||||
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan')).toBe(
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
|
||||
);
|
||||
expect(
|
||||
limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan'),
|
||||
).toBe(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit);
|
||||
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, null)).toBe(
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
|
||||
);
|
||||
@@ -131,22 +154,22 @@ describe('the delivery budgets', () => {
|
||||
expect(
|
||||
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
|
||||
).toBe(matched);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
|
||||
).toBe(evaluated);
|
||||
expect(limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan)).toBe(
|
||||
evaluated,
|
||||
);
|
||||
expect(evaluated).toBeGreaterThanOrEqual(matched);
|
||||
}
|
||||
});
|
||||
|
||||
it('pins the share-handle quotas by plan', () => {
|
||||
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(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', () => {
|
||||
@@ -169,3 +192,42 @@ describe('the delivery budgets', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the handler limits', () => {
|
||||
it('pins the app-scope numbers the quotas page lists', () => {
|
||||
expect(EVENTS_HANDLERS_PER_APP).toBe(100);
|
||||
expect(EVENTS_HANDLER_SOURCE_MAX_BYTES).toBe(64 * 1024);
|
||||
expect(EVENTS_WORKER_SOURCE_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
expect(EVENTS_WORKER_DEPLOYS_PER_HOUR).toBe(30);
|
||||
expect(EVENTS_HANDLER_PUBLISH_LIMIT.limit).toBe(60);
|
||||
expect(EVENTS_HANDLER_PUBLISH_LIMIT.window).toBe(60_000);
|
||||
});
|
||||
|
||||
it('pins the user-scope numbers the quotas page lists', () => {
|
||||
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBe(20);
|
||||
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBe(1024 * 1024);
|
||||
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBe(10);
|
||||
});
|
||||
|
||||
it('keeps a user scope tighter than an app scope', () => {
|
||||
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBeLessThan(
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
);
|
||||
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBeLessThan(
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
);
|
||||
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBeLessThan(
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the rented-unit cap by plan', () => {
|
||||
expect(RENTED_UNITS_PER_USER.limit).toBe(100);
|
||||
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_FREE_SUBSCRIPTION)).toBe(
|
||||
16,
|
||||
);
|
||||
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_TEMP_SUBSCRIPTION)).toBe(
|
||||
16,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,12 @@ export interface TieredLimit {
|
||||
bySubscription: Record<string, number>;
|
||||
}
|
||||
|
||||
const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
|
||||
/** Paid, free and temporary caps as a {@link TieredLimit}. Called by extensions. */
|
||||
export const tiered = (
|
||||
paid: number,
|
||||
free: number,
|
||||
temp: number,
|
||||
): TieredLimit => ({
|
||||
limit: paid,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: free,
|
||||
@@ -93,6 +98,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 +142,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 +179,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.
|
||||
*
|
||||
@@ -176,20 +207,55 @@ export const KV_SHARE_NOTICE_LIMIT = userWindow(
|
||||
/**
|
||||
* 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.
|
||||
* 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_USER = tiered(512, 200, 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.
|
||||
*
|
||||
* Separately bounded from the account cap so one namespace cannot consume the
|
||||
* whole budget on free accounts.
|
||||
* 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(512, 128, 0);
|
||||
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 -------------------------------------------------
|
||||
|
||||
@@ -203,6 +269,12 @@ export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
|
||||
*/
|
||||
export const EVENTS_HANDLERS_PER_APP = 100;
|
||||
|
||||
/**
|
||||
* Handlers one user may hold in their own scope of one app. Each is a script of
|
||||
* its own, so the cap is tighter than the app's.
|
||||
*/
|
||||
export const EVENTS_USER_HANDLERS_PER_SCOPE = 20;
|
||||
|
||||
/** Longest a serialized handler may be. */
|
||||
export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
@@ -214,6 +286,9 @@ export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
*/
|
||||
export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** Source one user scope may hold, all its handlers combined. */
|
||||
export const EVENTS_USER_SCOPE_SOURCE_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Deploys one app's events worker may trigger per hour.
|
||||
*
|
||||
@@ -223,6 +298,16 @@ export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
*/
|
||||
export const EVENTS_WORKER_DEPLOYS_PER_HOUR = 30;
|
||||
|
||||
/** Deploys one user scope may trigger per hour. */
|
||||
export const EVENTS_USER_WORKER_DEPLOYS_PER_HOUR = 10;
|
||||
|
||||
/**
|
||||
* Units one user may rent at once: ordinary workers, user scopes, and the
|
||||
* events workers of apps they own. Only creating a unit is refused; holdings
|
||||
* over the cap are kept.
|
||||
*/
|
||||
export const RENTED_UNITS_PER_USER = tiered(100, 16, 16);
|
||||
|
||||
/**
|
||||
* Handlers one `publishAll` may carry. A build step publishes its whole set in
|
||||
* one call, and the set is capped by what an app may hold anyway.
|
||||
@@ -287,8 +372,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;
|
||||
@@ -380,16 +468,20 @@ 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;
|
||||
|
||||
/** Longest wait between retries, however many have failed. */
|
||||
export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000;
|
||||
// Wait before the first retry of a delivery a handler could not take, the
|
||||
// longest wait, and the doubling between them. Shared with system jobs.
|
||||
export {
|
||||
deliveryBackoffMs,
|
||||
EVENTS_RETRY_BASE_MS,
|
||||
EVENTS_RETRY_MAX_MS,
|
||||
} from '../../util/retryBackoff.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -402,13 +494,6 @@ export const EVENTS_CONSECUTIVE_FAILURES = 5;
|
||||
*/
|
||||
export const EVENTS_FAILURE_COUNTER_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** How long to hold a delivery whose handler has failed `attempts` times. */
|
||||
export const deliveryBackoffMs = (attempts: number): number =>
|
||||
Math.min(
|
||||
EVENTS_RETRY_MAX_MS,
|
||||
EVENTS_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1),
|
||||
);
|
||||
|
||||
// -- Suspended backlog -----------------------------------------------
|
||||
//
|
||||
// A suspended subscription stops metering but keeps what it is owed, and that
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The schedule routes are thin delegates: without `services.schedules` every
|
||||
* one answers 503 `events_disabled`; with it, each hands its request over and
|
||||
* answers what it returns.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import type {
|
||||
ScheduleServiceContract,
|
||||
ScheduleView,
|
||||
} from '../../core/schedules.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { BroadcastController } from '../broadcast/BroadcastController.js';
|
||||
import type { EventsController } from './EventsController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let events: EventsController;
|
||||
let broadcast: BroadcastController;
|
||||
let actor: Actor;
|
||||
let saved: unknown;
|
||||
|
||||
const SCHEDULE_ID = '0b3a3c9e-9f61-4f0e-8d2a-2f1b6f8f2c11';
|
||||
|
||||
const view = {
|
||||
scheduleId: SCHEDULE_ID,
|
||||
kind: 'recurring',
|
||||
appUid: 'app-x',
|
||||
} as ScheduleView;
|
||||
|
||||
const slot = () => server.services as unknown as { schedules?: unknown };
|
||||
/** Put `schedules` in the slot, or leave the slot empty. */
|
||||
const setSchedules = (schedules: unknown): void => {
|
||||
if (schedules === undefined) delete slot().schedules;
|
||||
else slot().schedules = schedules;
|
||||
};
|
||||
|
||||
const fakeSchedules = () =>
|
||||
({
|
||||
onSubscriptionsChanged: vi.fn(async () => {}),
|
||||
createFor: vi.fn(async () => view),
|
||||
list: vi.fn(async () => ({
|
||||
items: [view],
|
||||
cursor: 'next',
|
||||
total: 3,
|
||||
})),
|
||||
get: vi.fn(async () => view),
|
||||
remove: vi.fn(async () => {}),
|
||||
receivePeerChanges: vi.fn(async () => ({ applied: 2 })),
|
||||
prepareSendAt: vi.fn(),
|
||||
createDeferredAppMessage: vi.fn(),
|
||||
}) satisfies ScheduleServiceContract;
|
||||
|
||||
const req = (init: {
|
||||
body?: unknown;
|
||||
query?: Record<string, unknown>;
|
||||
params?: Record<string, string>;
|
||||
}): Request =>
|
||||
({
|
||||
actor,
|
||||
body: init.body ?? {},
|
||||
query: init.query ?? {},
|
||||
params: init.params ?? {},
|
||||
headers: {},
|
||||
rawBody: Buffer.from(JSON.stringify(init.body ?? {})),
|
||||
}) as unknown as Request;
|
||||
|
||||
const res = () => {
|
||||
const sent: { status: number; body: unknown } = {
|
||||
status: 200,
|
||||
body: undefined,
|
||||
};
|
||||
const response = {
|
||||
json: vi.fn((body: unknown) => {
|
||||
sent.body = body;
|
||||
return response;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
sent.status = code;
|
||||
return response;
|
||||
}),
|
||||
setHeader: vi.fn(() => response),
|
||||
};
|
||||
return { response: response as unknown as Response, sent };
|
||||
};
|
||||
|
||||
/** Each route, called as the router would. */
|
||||
const routes: Array<[string, (response: Response) => Promise<void>]> = [
|
||||
[
|
||||
'POST /events/schedules',
|
||||
(r) => events.createSchedule(req({ body: { every: 60_000 } }), r),
|
||||
],
|
||||
[
|
||||
'GET /events/schedules',
|
||||
(r) => events.listSchedules(req({ query: { limit: '5' } }), r),
|
||||
],
|
||||
[
|
||||
'GET /events/schedules/:scheduleId',
|
||||
(r) =>
|
||||
events.getSchedule(req({ params: { scheduleId: SCHEDULE_ID } }), r),
|
||||
],
|
||||
[
|
||||
'POST /events/schedules/remove',
|
||||
(r) =>
|
||||
events.removeSchedule(
|
||||
req({ body: { scheduleId: SCHEDULE_ID } }),
|
||||
r,
|
||||
),
|
||||
],
|
||||
[
|
||||
'POST /broadcast/events/schedules',
|
||||
(r) =>
|
||||
broadcast.schedules(
|
||||
req({ body: { namespace: 'n', changes: [] } }),
|
||||
r,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
events = server.controllers.events as unknown as EventsController;
|
||||
broadcast = server.controllers.broadcast as unknown as BroadcastController;
|
||||
actor = makeActor({
|
||||
user: { id: 1, uuid: 'u-1', username: 'someone' },
|
||||
} as never);
|
||||
saved = slot().schedules;
|
||||
vi.spyOn(
|
||||
server.services.broadcast,
|
||||
'verifySignedRequest',
|
||||
).mockResolvedValue({ ok: true, peerId: 'peer' } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setSchedules(saved);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
describe('without services.schedules', () => {
|
||||
it.each(routes)('%s answers 503 events_disabled', async (_name, call) => {
|
||||
setSchedules(undefined);
|
||||
const { response } = res();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await call(response);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(isHttpError(thrown)).toBe(true);
|
||||
expect(thrown).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'events_disabled',
|
||||
// The designed answer, so it never pages.
|
||||
noAlarm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an unsigned peer batch before saying schedules are off', async () => {
|
||||
setSchedules(undefined);
|
||||
vi.spyOn(
|
||||
server.services.broadcast,
|
||||
'verifySignedRequest',
|
||||
).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
message: 'bad signature',
|
||||
} as never);
|
||||
const { response, sent } = res();
|
||||
await broadcast.schedules(
|
||||
req({ body: { namespace: 'n', changes: [] } }),
|
||||
response,
|
||||
);
|
||||
expect(sent.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with services.schedules', () => {
|
||||
it('creates through createFor and answers its view', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.createSchedule(req({ body: { every: 60_000 } }), response);
|
||||
expect(schedules.createFor).toHaveBeenCalledWith(actor, {
|
||||
every: 60_000,
|
||||
});
|
||||
expect(sent.body).toEqual(view);
|
||||
});
|
||||
|
||||
it('lists with the limit capped, the cursor and includeTotal', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.listSchedules(
|
||||
req({
|
||||
query: { limit: '5000', cursor: 'c', includeTotal: 'true' },
|
||||
}),
|
||||
response,
|
||||
);
|
||||
expect(schedules.list).toHaveBeenCalledWith(actor, {
|
||||
limit: 200,
|
||||
cursor: 'c',
|
||||
includeTotal: true,
|
||||
});
|
||||
expect(sent.body).toEqual({ items: [view], cursor: 'next', total: 3 });
|
||||
});
|
||||
|
||||
it('reads one through get', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.getSchedule(
|
||||
req({ params: { scheduleId: SCHEDULE_ID } }),
|
||||
response,
|
||||
);
|
||||
expect(schedules.get).toHaveBeenCalledWith(actor, SCHEDULE_ID);
|
||||
expect(sent.body).toEqual(view);
|
||||
});
|
||||
|
||||
it('removes through remove and answers {}', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.removeSchedule(
|
||||
req({ body: { scheduleId: SCHEDULE_ID } }),
|
||||
response,
|
||||
);
|
||||
expect(schedules.remove).toHaveBeenCalledWith(actor, SCHEDULE_ID);
|
||||
expect(sent.body).toEqual({});
|
||||
});
|
||||
|
||||
it('hands a signed peer batch to receivePeerChanges', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
const body = { namespace: 'n', changes: [] };
|
||||
await broadcast.schedules(req({ body }), response);
|
||||
expect(schedules.receivePeerChanges).toHaveBeenCalledWith(body);
|
||||
expect(sent).toEqual({ status: 200, body: { ok: true, applied: 2 } });
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,35 @@
|
||||
|
||||
import MockRedis from 'ioredis-mock';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
import { EventsWorkerDeployer } from './workerDeploy.js';
|
||||
import { eventsWorkerScript } from '../../services/events/workerRuntime.js';
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
EVENTS_DEPLOYED_HEADER,
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EventsWorkerInvokerClient,
|
||||
type EventsInvokeCall,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import { INTERNAL_DEPLOY_TARGET } from '../../drivers/workers/WorkerDriver.js';
|
||||
import {
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
} from './limits.js';
|
||||
import {
|
||||
EventsWorkerDeployer,
|
||||
LocalEventsInvokeTransport,
|
||||
type EventsDeployOptions,
|
||||
type EventsDeployOutcome,
|
||||
} from './workerDeploy.js';
|
||||
import {
|
||||
eventsWorkerScript,
|
||||
userHandlerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import {
|
||||
appHandlerScope,
|
||||
hashContent,
|
||||
type HandlerScope,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import { handlerSetHash } from '../../services/events/workerSource.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
|
||||
@@ -46,12 +72,22 @@ interface Owner {
|
||||
type Layers = ConstructorParameters<typeof EventsWorkerDeployer>[0];
|
||||
|
||||
/** Stub layers whose only real dependency is a fresh redis mock. */
|
||||
const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
const makeLayers = (
|
||||
owners: Record<string, Owner | null>,
|
||||
events: Record<string, unknown> = {},
|
||||
) => {
|
||||
const redis = new MockRedis.Cluster(['redis://localhost:7001']);
|
||||
const created: string[] = [];
|
||||
const targets: Array<Record<string, unknown>> = [];
|
||||
/** Apps whose handler sources were read, once per read. */
|
||||
const sourceReads: string[] = [];
|
||||
const layers = {
|
||||
config: {
|
||||
events: { workerRuntime: true, internalSecret: 'sekret' },
|
||||
events: {
|
||||
workerRuntime: true,
|
||||
internalSecret: 'sekret',
|
||||
...events,
|
||||
},
|
||||
// `#namespace()` needs a local-worker shape to resolve without
|
||||
// throwing — this stub never reaches a real Cloudflare or
|
||||
// Miniflare deploy either way.
|
||||
@@ -60,16 +96,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 }: { appUid: string }) => {
|
||||
sourceReads.push(appUid);
|
||||
return [
|
||||
{
|
||||
appUid,
|
||||
name: 'h',
|
||||
source: 'async () => {}',
|
||||
sourceHash: SOURCE_HASH,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
app: {
|
||||
getByUid: async (appUid: string) => {
|
||||
@@ -80,6 +120,18 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
},
|
||||
},
|
||||
user: {
|
||||
getByUuid: async (uuid: string) => {
|
||||
const owner = Object.values(owners).find(
|
||||
(o) => `uuid-${o?.ownerUserId}` === uuid,
|
||||
);
|
||||
if (!owner) return null;
|
||||
return {
|
||||
id: owner.ownerUserId,
|
||||
uuid,
|
||||
username: `user-${owner.ownerUserId}`,
|
||||
suspended: owner.suspended ?? false,
|
||||
};
|
||||
},
|
||||
getById: async (id: number) => {
|
||||
const owner = Object.values(owners).find(
|
||||
(o) => o?.ownerUserId === id,
|
||||
@@ -97,14 +149,24 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
services: {},
|
||||
drivers: {
|
||||
workers: {
|
||||
create: async (args: { workerName: string }) => {
|
||||
create: async (args: {
|
||||
workerName: string;
|
||||
[INTERNAL_DEPLOY_TARGET]?: Record<string, unknown>;
|
||||
}) => {
|
||||
created.push(args.workerName);
|
||||
targets.push(args[INTERNAL_DEPLOY_TARGET] ?? {});
|
||||
return { success: true, errors: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return { layers: layers as unknown as Layers, created, redis };
|
||||
return {
|
||||
layers: layers as unknown as Layers,
|
||||
created,
|
||||
targets,
|
||||
redis,
|
||||
sourceReads,
|
||||
};
|
||||
};
|
||||
|
||||
describe('EventsWorkerDeployer.ensure', () => {
|
||||
@@ -120,15 +182,17 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
deployer.ensure('app-a', SCRIPT),
|
||||
deployer.ensure('app-b', SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-a'), SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-b'), SCRIPT),
|
||||
]);
|
||||
expect(a).toBe('deployed');
|
||||
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(
|
||||
@@ -136,12 +200,697 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
String(EVENTS_WORKER_DEPLOYS_PER_HOUR),
|
||||
);
|
||||
|
||||
expect(await deployer.ensure('app-c', SCRIPT)).toBe('throttled');
|
||||
expect(await deployer.ensure(appHandlerScope('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(appHandlerScope('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('passes events.workerCpuMs to the deploy target', async () => {
|
||||
const { layers, targets } = makeLayers(
|
||||
{ 'app-f': { ownerUserId: 6 } },
|
||||
{ workerCpuMs: 50 },
|
||||
);
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure(appHandlerScope('app-f'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(targets[0]!.cpuMs).toBe(50);
|
||||
});
|
||||
|
||||
it('passes no cpuMs when events.workerCpuMs is unset', async () => {
|
||||
const { layers, targets } = makeLayers({ 'app-g': { ownerUserId: 7 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure(appHandlerScope('app-g'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(targets[0]!.cpuMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deploys normally under the cap', async () => {
|
||||
const { layers } = makeLayers({ 'app-d': { ownerUserId: 4 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure('app-d', SCRIPT)).toBe('deployed');
|
||||
expect(await deployer.ensure(appHandlerScope('app-d'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** A server's schedules with a deploy gate that records and can refuse. */
|
||||
const withGate = (
|
||||
layers: Layers,
|
||||
refuse: (capped: boolean) => boolean = () => false,
|
||||
) => {
|
||||
const gated: Array<{ script: string; capped: boolean }> = [];
|
||||
const gate = {
|
||||
gateDeploy: async (
|
||||
script: string,
|
||||
options: { capped: boolean },
|
||||
deploy: () => Promise<EventsDeployOutcome>,
|
||||
): Promise<EventsDeployOutcome> => {
|
||||
gated.push({ script, capped: options.capped });
|
||||
return refuse(options.capped) ? 'busy' : deploy();
|
||||
},
|
||||
};
|
||||
const deployer = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: { ...layers.services, schedules: gate },
|
||||
} as unknown as Layers);
|
||||
return { deployer, gated };
|
||||
};
|
||||
|
||||
describe('the schedules` deploy gate', () => {
|
||||
it('is skipped without schedules, or with schedules that have no gate', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
await expect(
|
||||
new EventsWorkerDeployer(layers).ensure(
|
||||
appHandlerScope('app-x'),
|
||||
SCRIPT,
|
||||
{
|
||||
lane: 'schedule',
|
||||
},
|
||||
),
|
||||
).resolves.toBe('deployed');
|
||||
const gateless = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: { ...layers.services, schedules: {} },
|
||||
} as unknown as Layers);
|
||||
await expect(
|
||||
gateless.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('deployed');
|
||||
expect(created).toEqual([SCRIPT, SCRIPT]);
|
||||
});
|
||||
|
||||
it('takes every deploy, capped only for a schedule run', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers);
|
||||
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
).resolves.toBe('deployed');
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('deployed');
|
||||
expect(gated).toEqual([
|
||||
{ script: SCRIPT, capped: false },
|
||||
{ script: SCRIPT, capped: true },
|
||||
]);
|
||||
expect(created).toEqual([SCRIPT, SCRIPT]);
|
||||
});
|
||||
|
||||
it('answers a refused schedule run busy, and a delivery still deploys', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer } = withGate(layers, (capped) => capped);
|
||||
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('busy');
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
).resolves.toBe('deployed');
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
});
|
||||
|
||||
it('lets deliveries that join a schedule run`s refused deploy deploy once', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers, (capped) => capped);
|
||||
|
||||
const scheduled = deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
});
|
||||
const deliveries = [
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
];
|
||||
await expect(scheduled).resolves.toBe('busy');
|
||||
await expect(Promise.all(deliveries)).resolves.toEqual([
|
||||
'deployed',
|
||||
'deployed',
|
||||
]);
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
expect(gated.map((g) => g.capped)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('lets a schedule run join a delivery`s deploy in flight', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers, (capped) => capped);
|
||||
|
||||
const delivery = deployer.ensure(appHandlerScope('app-x'), SCRIPT);
|
||||
const scheduled = deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
});
|
||||
await expect(Promise.all([delivery, scheduled])).resolves.toEqual([
|
||||
'deployed',
|
||||
'deployed',
|
||||
]);
|
||||
expect(gated).toEqual([{ script: SCRIPT, capped: false }]);
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalEventsInvokeTransport', () => {
|
||||
it('renders each request as it goes out, after a cold deploy', async () => {
|
||||
const bodies: string[] = [];
|
||||
let resident = false;
|
||||
const transport = new LocalEventsInvokeTransport(
|
||||
{
|
||||
localworkerservice: {
|
||||
dispatchEventsWorker: async (request: Request) => {
|
||||
bodies.push(await request.text());
|
||||
if (!resident) return null;
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-puter-events-handled': '1' },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
ensure: async () => {
|
||||
resident = true;
|
||||
return 'deployed';
|
||||
},
|
||||
scopeFor: async (appUid: string) => appHandlerScope(appUid),
|
||||
} as unknown as EventsWorkerDeployer,
|
||||
);
|
||||
let renders = 0;
|
||||
const call: EventsInvokeCall = {
|
||||
script: SCRIPT,
|
||||
appUid: 'app-x',
|
||||
key: 'k',
|
||||
body: '{"n":0}',
|
||||
timeoutMs: 5000,
|
||||
render: () => `{"n":${++renders}}`,
|
||||
};
|
||||
await expect(transport.send(call)).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
expect(bodies).toEqual(['{"n":1}', '{"n":2}']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deploy-on-miss of an evicted script', () => {
|
||||
it('redeploys the evicted script, then resends the run with the deployed marker', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-m': { ownerUserId: 8 } });
|
||||
// The namespace only has the script once the deploy lands: before
|
||||
// that the dispatcher answers a miss.
|
||||
let resident = false;
|
||||
layers.drivers.workers.create = (async (args: {
|
||||
workerName: string;
|
||||
}) => {
|
||||
created.push(args.workerName);
|
||||
resident = true;
|
||||
return { success: true, errors: [] };
|
||||
}) as never;
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const sent: boolean[] = [];
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
sent.push(headers[EVENTS_DEPLOYED_HEADER] === '1');
|
||||
if (resident)
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
});
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
||||
});
|
||||
}) as unknown as typeof undiciFetch;
|
||||
|
||||
const client = new EventsWorkerInvokerClient({
|
||||
events: { workerRuntime: true },
|
||||
} as IConfig);
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
client.setMissHandler((appUid, script, options) =>
|
||||
deployer.ensure(appHandlerScope(appUid), script, options),
|
||||
);
|
||||
|
||||
const result = await client.invoke({
|
||||
script: SCRIPT,
|
||||
appUid: 'app-m',
|
||||
handler: 'h',
|
||||
token: 't',
|
||||
key: 'k',
|
||||
event: {},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
// One miss, then the resend carrying the deployed marker.
|
||||
expect(sent).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
// The redis mock is shared across tests, so each one uses its own app and code.
|
||||
let fresh = 0;
|
||||
const nextApp = () => `app-u${++fresh}`;
|
||||
const userScope = (holder: number, appUid: string) => ({ appUid, holder });
|
||||
const sourceN = (label: string) =>
|
||||
`async () => { await ${label}${++fresh}(); }`;
|
||||
const scriptOf = (name: string, source: string) =>
|
||||
userHandlerScript({ name, sourceHash: hashContent(source) }, '');
|
||||
|
||||
type Sets = Record<number, Array<{ name: string; source: string }>>;
|
||||
|
||||
/** Layers whose handler sets are per holder (0 is the app's own). */
|
||||
const withSets = (layers: Layers, sets: Sets) => {
|
||||
const handlersOf = (scope: { appUid: string; holder: number }) =>
|
||||
(sets[scope.holder] ?? []).map((h) => ({
|
||||
appUid: scope.appUid,
|
||||
name: h.name,
|
||||
source: h.source,
|
||||
sourceHash: hashContent(h.source),
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}));
|
||||
const store = layers.stores.eventHandler as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
store.setForApp = async (scope: { appUid: string; holder: number }) =>
|
||||
handlersOf(scope).map(({ name, sourceHash }) => ({ name, sourceHash }));
|
||||
store.allForApp = async (scope: { appUid: string; holder: number }) =>
|
||||
handlersOf(scope);
|
||||
};
|
||||
|
||||
const holders = (...ids: number[]) =>
|
||||
Object.fromEntries(
|
||||
ids.map((id) => [`holder-${id}`, { ownerUserId: id }]),
|
||||
) as Record<string, Owner>;
|
||||
|
||||
describe('EventsWorkerDeployer in a user scope', () => {
|
||||
it('deploys the one handler whose script was asked for, as the holder', async () => {
|
||||
const app = nextApp();
|
||||
const a = sourceN('a');
|
||||
const b = sourceN('b');
|
||||
const { layers, created, targets } = makeLayers(holders(10));
|
||||
withSets(layers, {
|
||||
10: [
|
||||
{ name: 'a', source: a },
|
||||
{ name: 'b', source: b },
|
||||
],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const script = scriptOf('b', b);
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
|
||||
expect(created).toEqual([script]);
|
||||
const target = targets[0]!;
|
||||
expect(target.tags).toEqual([`events-app:${app}`, 'user:uuid-10']);
|
||||
// One handler's script: the other one's code is not in it.
|
||||
expect(target.source).toContain(b);
|
||||
expect(target.source).not.toContain(a);
|
||||
});
|
||||
|
||||
it('answers a script no handler of the scope matches as stale, before the throttle', async () => {
|
||||
const app = nextApp();
|
||||
const { layers, redis, created } = makeLayers(holders(10));
|
||||
withSets(layers, { 10: [{ name: 'a', source: sourceN('a') }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
expect(
|
||||
await deployer.ensure(
|
||||
userScope(10, app),
|
||||
scriptOf('b', sourceN('b')),
|
||||
),
|
||||
).toBe('stale');
|
||||
expect(created).toEqual([]);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{${app}}:10:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('answers a scope with no handlers as no-handlers', async () => {
|
||||
const { layers } = makeLayers(holders(10));
|
||||
withSets(layers, {});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(
|
||||
await deployer.ensure(
|
||||
userScope(10, nextApp()),
|
||||
scriptOf('a', sourceN('a')),
|
||||
),
|
||||
).toBe('no-handlers');
|
||||
});
|
||||
|
||||
it('does not deploy for a holder that is suspended or gone', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers({
|
||||
'holder-10': { ownerUserId: 10, suspended: true },
|
||||
});
|
||||
withSets(layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
const app = nextApp();
|
||||
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'no-owner',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'no-owner',
|
||||
);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each scope its own hourly cap, apart from the app scope', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers({
|
||||
...holders(10, 11),
|
||||
[app]: { ownerUserId: 12 },
|
||||
});
|
||||
withSets(layers, {
|
||||
0: [{ name: 'a', source }],
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
|
||||
for (let i = 0; i < EVENTS_USER_WORKER_DEPLOYS_PER_HOUR; i++)
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'throttled',
|
||||
);
|
||||
expect(created).toHaveLength(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR);
|
||||
|
||||
// Another holder of the same app, and the app's own scope, still go.
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(appHandlerScope(app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses concurrent deploys of one script across holders into one upload', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10, 11));
|
||||
withSets(layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
|
||||
await Promise.all([
|
||||
deployer.ensure(userScope(10, app), script),
|
||||
deployer.ensure(userScope(10, app), script),
|
||||
deployer.ensure(userScope(11, app), script),
|
||||
deployer.ensure(userScope(11, nextApp()), script),
|
||||
]);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('names a script for identical code the same under any holder and app', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10, 11));
|
||||
withSets(layers, {
|
||||
10: [{ name: '@x', source }],
|
||||
11: [{ name: '@x', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('@x', source);
|
||||
|
||||
expect(await deployer.ensure(userScope(10, nextApp()), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(11, nextApp()), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the deployed marker', () => {
|
||||
const setup = () => {
|
||||
const source = sourceN('a');
|
||||
const made = makeLayers(holders(10, 11));
|
||||
withSets(made.layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
return {
|
||||
...made,
|
||||
deployer: new EventsWorkerDeployer(made.layers),
|
||||
script: scriptOf('a', source),
|
||||
app: nextApp(),
|
||||
};
|
||||
};
|
||||
|
||||
it('is left by a user-scope deploy with a ttl under a day', async () => {
|
||||
const { deployer, redis, script, app } = setup();
|
||||
await deployer.ensure(userScope(10, app), script);
|
||||
const ttl = await redis.ttl(`ev:deployed:${script}`);
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThan(24 * 60 * 60);
|
||||
});
|
||||
|
||||
it('lets an eager deploy skip the upload another holder already made', async () => {
|
||||
const { deployer, created, script, app } = setup();
|
||||
expect(await deployer.deployEagerly(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.deployEagerly(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('spends no throttle on a skipped eager deploy', async () => {
|
||||
const { deployer, redis, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
await deployer.deployEagerly(userScope(11, app), script);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{${app}}:11:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('never skips a deploy-on-miss', async () => {
|
||||
const { deployer, created, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
|
||||
it('is not left by an app-scope deploy', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, redis, created } = makeLayers({
|
||||
[app]: { ownerUserId: 4 },
|
||||
});
|
||||
withSets(layers, { 0: [{ name: 'a', source }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
await deployer.ensure(appHandlerScope(app), script);
|
||||
expect(created).toEqual([script]);
|
||||
expect(await redis.get(`ev:deployed:${script}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('still deploys eagerly when the marker cannot be read', async () => {
|
||||
const { deployer, redis, created, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
const get = redis.get.bind(redis);
|
||||
redis.get = (async (key: string) => {
|
||||
if (key.startsWith('ev:deployed:')) throw new Error('down');
|
||||
return get(key);
|
||||
}) as never;
|
||||
expect(await deployer.deployEagerly(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeFor', () => {
|
||||
it('reads no holder as the app scope, and resolves a uuid to the user scope', async () => {
|
||||
const { layers } = makeLayers(holders(10));
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.scopeFor('app-u')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 0,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', '')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 0,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', 'uuid-10')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 10,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', 'uuid-99')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalEventsInvokeTransport in a user scope', () => {
|
||||
it('deploys the holder`s script on a miss and refuses an unknown holder', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10));
|
||||
withSets(layers, { 10: [{ name: 'a', source }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
let resident = false;
|
||||
const transport = new LocalEventsInvokeTransport(
|
||||
{
|
||||
localworkerservice: {
|
||||
dispatchEventsWorker: async () => {
|
||||
if (!resident) return null;
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-puter-events-handled': '1' },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
ensure: async (
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
) => {
|
||||
const outcome = await deployer.ensure(
|
||||
scope,
|
||||
script,
|
||||
options,
|
||||
);
|
||||
resident = outcome === 'deployed';
|
||||
return outcome;
|
||||
},
|
||||
scopeFor: (appUid: string, holder?: string) =>
|
||||
deployer.scopeFor(appUid, holder),
|
||||
} as unknown as EventsWorkerDeployer,
|
||||
);
|
||||
const script = scriptOf('a', source);
|
||||
const call: EventsInvokeCall = {
|
||||
script,
|
||||
appUid: nextApp(),
|
||||
holder: 'uuid-10',
|
||||
key: 'k',
|
||||
body: '{}',
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
await expect(transport.send(call)).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
expect(created).toEqual([script]);
|
||||
|
||||
resident = false;
|
||||
await expect(
|
||||
transport.send({ ...call, holder: 'uuid-99' }),
|
||||
).resolves.toMatchObject({ status: null, error: 'deploy: no-holder' });
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent eager deploys of one script', () => {
|
||||
const setup = () => {
|
||||
const source = sourceN('a');
|
||||
const made = makeLayers(holders(10, 11));
|
||||
withSets(made.layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
return {
|
||||
...made,
|
||||
script: scriptOf('a', source),
|
||||
app: nextApp(),
|
||||
};
|
||||
};
|
||||
|
||||
it('upload once when two holders publish the same new code together', async () => {
|
||||
const { layers, created, script, app } = setup();
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const outcomes = await Promise.all([
|
||||
deployer.deployEagerly(userScope(10, app), script),
|
||||
deployer.deployEagerly(userScope(11, app), script),
|
||||
]);
|
||||
|
||||
expect(outcomes).toEqual(['deployed', 'deployed']);
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('skip the upload when the marker appears while they wait on the gate', async () => {
|
||||
const { layers, redis, created, script, app } = setup();
|
||||
// Another node's deploy of the script finishes before the gate opens.
|
||||
const gated = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: {
|
||||
schedules: {
|
||||
gateDeploy: async (
|
||||
_script: string,
|
||||
_options: { capped: boolean },
|
||||
deploy: () => Promise<EventsDeployOutcome>,
|
||||
) => {
|
||||
await redis.set(`ev:deployed:${script}`, '1');
|
||||
return deploy();
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Layers);
|
||||
|
||||
expect(
|
||||
await gated.ensure(userScope(10, app), script, { eager: true }),
|
||||
).toBe('deployed');
|
||||
expect(created).toEqual([]);
|
||||
// A miss still uploads, whatever the marker says.
|
||||
expect(await gated.ensure(userScope(11, app), script)).toBe('deployed');
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('make a miss that joins an eager deploy deploy after it', async () => {
|
||||
const { layers, redis, created, script, app } = setup();
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
await redis.set(`ev:deployed:${script}`, '1');
|
||||
|
||||
const eager = deployer.ensure(userScope(10, app), script, {
|
||||
eager: true,
|
||||
});
|
||||
const miss = deployer.ensure(userScope(11, app), script);
|
||||
|
||||
expect(await eager).toBe('deployed');
|
||||
expect(await miss).toBe('deployed');
|
||||
// The eager one skipped on the marker; the miss uploaded.
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
});
|
||||
@@ -26,50 +26,100 @@ import {
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
INTERNAL_DEPLOY_TARGET,
|
||||
} from '../../drivers/workers/WorkerDriver.js';
|
||||
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type {
|
||||
EventsInvokeAnswer,
|
||||
EventsInvokeCall,
|
||||
InvokeLane,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import {
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EVENTS_INVOKE_PATH,
|
||||
answerFrom,
|
||||
failedAnswer,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type { puterServices } from '../../services/index.js';
|
||||
import type { IExtensionServiceInstances } from '../../services/types.js';
|
||||
import {
|
||||
eventsInvokeKey,
|
||||
eventsWorkerScope,
|
||||
eventsWorkerScript,
|
||||
userHandlerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import { generateEventsWorkerSource } from '../../services/events/workerSource.js';
|
||||
import {
|
||||
generateEventsWorkerSource,
|
||||
handlerSetHash,
|
||||
} from '../../services/events/workerSource.js';
|
||||
import {
|
||||
appHandlerScope,
|
||||
type HandlerScope,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import type { puterStores } from '../../stores/index.js';
|
||||
import type { IConfig, LayerInstances } from '../../types.js';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
import {
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
} from './limits.js';
|
||||
|
||||
/**
|
||||
* Deploys an app's events worker from its published handlers.
|
||||
*
|
||||
* Deployed the first time a delivery needs it and again on eviction — a publish
|
||||
* only writes rows. Scripts carry no worker token and no `subdomains` row: not
|
||||
* one of the owner's workers, not addressable from the internet, not listed or
|
||||
* deleted as if it were. A republished set leaves the old script behind rather
|
||||
* than deleting it; redeploying a still-live one is always safe.
|
||||
* only writes rows (a user-scope publish also deploys eagerly). An app's scope
|
||||
* is one script for the whole set; a user scope is one script per handler,
|
||||
* deployed as the holder. Scripts carry no worker token and no `subdomains`
|
||||
* row: not one of the owner's workers, not addressable from the internet, not
|
||||
* listed or deleted as if it were. A republished set leaves the old script
|
||||
* behind rather than deleting it; redeploying a still-live one is always safe.
|
||||
*/
|
||||
|
||||
/** Why a deploy could not happen, for the callers that answer differently. */
|
||||
export type EventsDeployOutcome =
|
||||
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
|
||||
| 'deployed'
|
||||
| 'stale'
|
||||
| 'no-handlers'
|
||||
| 'no-owner'
|
||||
| 'throttled'
|
||||
| 'failed'
|
||||
| 'busy';
|
||||
|
||||
/** Whose call asked for the deploy: a schedule run's is held to a share. */
|
||||
export interface EventsDeployOptions {
|
||||
lane?: InvokeLane;
|
||||
/**
|
||||
* A user-scope publish's deploy, which skips the upload when the script's
|
||||
* deployed marker is already set. A deploy-on-miss is never eager.
|
||||
*/
|
||||
eager?: boolean;
|
||||
}
|
||||
|
||||
interface DeployLayers {
|
||||
config: IConfig;
|
||||
clients: LayerInstances<typeof puterClients>;
|
||||
stores: LayerInstances<typeof puterStores>;
|
||||
services: LayerInstances<typeof puterServices>;
|
||||
services: LayerInstances<typeof puterServices> &
|
||||
Pick<IExtensionServiceInstances, 'schedules'>;
|
||||
drivers: LayerInstances<typeof puterDrivers>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ev:deploys:{<appUid>}:<hour>` bucket TTL — comfortably past the hour it
|
||||
* counts.
|
||||
* `ev:deploys:{<appUid>}[:<holder>]:<hour>` bucket TTL — comfortably past the
|
||||
* hour it counts.
|
||||
*/
|
||||
const DEPLOY_THROTTLE_TTL_SECONDS = 2 * 60 * 60;
|
||||
|
||||
/** How long `ev:deployed:<script>` says a script was uploaded. Under a day. */
|
||||
const DEPLOYED_MARKER_TTL_SECONDS = 20 * 60 * 60;
|
||||
|
||||
const deployedMarkerKey = (script: string): string => `ev:deployed:${script}`;
|
||||
|
||||
const isUserScope = (scope: HandlerScope): boolean => scope.holder !== 0;
|
||||
|
||||
interface InFlightDeploy {
|
||||
/** A schedule run's, which the region's deploy slots may refuse. */
|
||||
capped: boolean;
|
||||
eager: boolean;
|
||||
promise: Promise<EventsDeployOutcome>;
|
||||
}
|
||||
|
||||
interface DeployResult {
|
||||
success?: boolean;
|
||||
errors?: unknown[];
|
||||
@@ -78,7 +128,7 @@ interface DeployResult {
|
||||
export class EventsWorkerDeployer {
|
||||
readonly #layers: DeployLayers;
|
||||
/** Collapses the burst of misses one cold app produces into one deploy. */
|
||||
readonly #inFlight = new Map<string, Promise<EventsDeployOutcome>>();
|
||||
readonly #inFlight = new Map<string, InFlightDeploy>();
|
||||
|
||||
constructor(layers: DeployLayers) {
|
||||
this.#layers = layers;
|
||||
@@ -93,39 +143,187 @@ export class EventsWorkerDeployer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy `script`, which must be what the app's handlers currently hash to.
|
||||
* A request for anything else is `stale` — the set moved on, and the next
|
||||
* delivery resolves the new script rather than this one being revived.
|
||||
* Deploy `script`, which must be what the scope's handlers currently hash
|
||||
* to: the whole set for an app, one handler for a user scope. A request for
|
||||
* anything else is `stale` — the handlers moved on, and the next delivery
|
||||
* resolves the new script rather than this one being revived. Always
|
||||
* uploads; a deploy-on-miss means whatever said it was there is wrong.
|
||||
*/
|
||||
ensure(appUid: string, script: string): Promise<EventsDeployOutcome> {
|
||||
const key = `${appUid}|${script}`;
|
||||
ensure(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions = {},
|
||||
): Promise<EventsDeployOutcome> {
|
||||
// A user scope's script is the same bytes whoever holds it, so
|
||||
// concurrent deploys of it from any holder or app are one.
|
||||
const key = isUserScope(scope)
|
||||
? `user|${script}`
|
||||
: `${scope.appUid}|${scope.holder}|${script}`;
|
||||
const capped = options.lane === 'schedule';
|
||||
const existing = this.#inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const started = this.#deploy(appUid, script).finally(() =>
|
||||
this.#inFlight.delete(key),
|
||||
if (!existing) return this.#start(key, scope, script, options);
|
||||
// An eager deploy may have skipped the upload, which a miss must not
|
||||
// take for done.
|
||||
if (existing.eager && !options.eager)
|
||||
return this.#afterEager(
|
||||
existing.promise,
|
||||
key,
|
||||
scope,
|
||||
script,
|
||||
options,
|
||||
);
|
||||
if (capped || !existing.capped) return existing.promise;
|
||||
return this.#afterCapped(existing.promise, key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ensure} for the deploy a user-scope publish starts, which skips the
|
||||
* upload when another holder's deploy of the same script left its marker.
|
||||
* Only this path reads the marker.
|
||||
*/
|
||||
async deployEagerly(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
if (await this.#markedDeployed(script)) return 'deployed';
|
||||
return this.ensure(scope, script, { eager: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope a script request names: the app's own, or the user scope of
|
||||
* `holder` (a user uuid). Null for a holder that does not exist.
|
||||
*/
|
||||
async scopeFor(
|
||||
appUid: string,
|
||||
holder?: string | null,
|
||||
): Promise<HandlerScope | null> {
|
||||
if (!holder) return appHandlerScope(appUid);
|
||||
const user = await this.#layers.stores.user.getByUuid(holder);
|
||||
if (!user?.id) return null;
|
||||
return { appUid, holder: Number(user.id) };
|
||||
}
|
||||
|
||||
#start(
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const entry: InFlightDeploy = {
|
||||
capped: options.lane === 'schedule',
|
||||
eager: options.eager === true,
|
||||
promise: this.#gated(scope, script, options).finally(() => {
|
||||
if (this.#inFlight.get(key) === entry)
|
||||
this.#inFlight.delete(key);
|
||||
}),
|
||||
};
|
||||
this.#inFlight.set(key, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** A miss that found an eager deploy in flight deploys after it. */
|
||||
async #afterEager(
|
||||
eager: Promise<EventsDeployOutcome>,
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
await eager;
|
||||
const current = this.#inFlight.get(key);
|
||||
if (current && !current.eager) return current.promise;
|
||||
return this.#start(key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* A delivery that joined a schedule run's deploy: `busy` means no deploy
|
||||
* started, and a delivery is never refused a slot, so it asks again.
|
||||
*/
|
||||
async #afterCapped(
|
||||
capped: Promise<EventsDeployOutcome>,
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const outcome = await capped;
|
||||
if (outcome !== 'busy') return outcome;
|
||||
const current = this.#inFlight.get(key);
|
||||
if (current && !current.capped) return current.promise;
|
||||
return this.#start(key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every deploy goes through the schedules' deploy gate when a server has
|
||||
* one: a per-script lock, so a burst of runs of an evicted script deploys
|
||||
* it once, and a region-wide cap that a schedule run's deploy (`capped`)
|
||||
* may be refused with `busy`. A delivery's deploy is never refused.
|
||||
*/
|
||||
#gated(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const deploy = () =>
|
||||
this.#deploy(scope, script, options.eager === true);
|
||||
return (
|
||||
this.#layers.services.schedules?.gateDeploy?.(
|
||||
script,
|
||||
{ capped: options.lane === 'schedule' },
|
||||
deploy,
|
||||
) ?? deploy()
|
||||
);
|
||||
this.#inFlight.set(key, started);
|
||||
return started;
|
||||
}
|
||||
|
||||
async #deploy(
|
||||
appUid: string,
|
||||
handlerScope: HandlerScope,
|
||||
script: string,
|
||||
eager: boolean,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const { appUid } = handlerScope;
|
||||
const { stores, drivers } = this.#layers;
|
||||
const backend = eventsWorkerScope(this.#layers.config);
|
||||
const userScope = isUserScope(handlerScope);
|
||||
|
||||
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(handlerScope);
|
||||
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.
|
||||
// the invocation. Whoever deploys first owns it. A user scope's script
|
||||
// is one handler's, so it is shared the same way across holders.
|
||||
const wanted = userScope
|
||||
? set.find((entry) => userHandlerScript(entry, backend) === script)
|
||||
: undefined;
|
||||
if (
|
||||
eventsWorkerScript(
|
||||
generated.setHash,
|
||||
eventsWorkerScope(this.#layers.config),
|
||||
) !== script
|
||||
userScope
|
||||
? !wanted
|
||||
: eventsWorkerScript(handlerSetHash(set), backend) !== script
|
||||
)
|
||||
return 'stale';
|
||||
|
||||
// Asked again inside the gate, where another deploy of this script may
|
||||
// have just finished.
|
||||
if (eager && userScope && (await this.#markedDeployed(script)))
|
||||
return 'deployed';
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(handlerScope))
|
||||
return 'throttled';
|
||||
|
||||
const all = await stores.eventHandler.allForApp(handlerScope);
|
||||
const handlers = wanted
|
||||
? all.filter(
|
||||
(handler) =>
|
||||
handler.name === wanted.name &&
|
||||
handler.sourceHash === wanted.sourceHash,
|
||||
)
|
||||
: all;
|
||||
const generated = generateEventsWorkerSource(handlers);
|
||||
// The set can move between the two reads.
|
||||
if (
|
||||
handlers.length === 0 ||
|
||||
eventsWorkerScript(generated.setHash, backend) !== script
|
||||
)
|
||||
return 'stale';
|
||||
if (generated.tooLarge) {
|
||||
@@ -140,9 +338,7 @@ export class EventsWorkerDeployer {
|
||||
generated.broken.join(', '),
|
||||
);
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
|
||||
|
||||
const owner = await this.#owner(appUid);
|
||||
const owner = await this.#owner(handlerScope);
|
||||
if (!owner) return 'no-owner';
|
||||
|
||||
const secret = this.#layers.config.events?.internalSecret;
|
||||
@@ -171,6 +367,7 @@ export class EventsWorkerDeployer {
|
||||
runtime: 'events',
|
||||
source: generated.source,
|
||||
omitOwnerToken: true,
|
||||
cpuMs: this.#layers.config.events?.workerCpuMs,
|
||||
// What the script compares an invocation against.
|
||||
// Derived from the script name, so a redeploy of
|
||||
// the same set binds the same key.
|
||||
@@ -201,34 +398,74 @@ export class EventsWorkerDeployer {
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
if (userScope) await this.#markDeployed(script);
|
||||
return 'deployed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this app has already deployed `EVENTS_WORKER_DEPLOYS_PER_HOUR`
|
||||
* times this hour. A flapping set (publish, remove, republish) provisions
|
||||
* upstream on every miss, and this is the ceiling on that loop.
|
||||
*/
|
||||
async #tooManyDeploysThisHour(appUid: string): Promise<boolean> {
|
||||
const redis = this.#layers.clients.redis;
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
const key = `ev:deploys:{${appUid}}:${hour}`;
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) await redis.expire(key, DEPLOY_THROTTLE_TTL_SECONDS);
|
||||
return count > EVENTS_WORKER_DEPLOYS_PER_HOUR;
|
||||
async #markedDeployed(script: string): Promise<boolean> {
|
||||
try {
|
||||
return !!(await this.#layers.clients.redis.get(
|
||||
deployedMarkerKey(script),
|
||||
));
|
||||
} catch (err) {
|
||||
console.warn('[events] deployed marker unreadable', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The app owner, as an actor the deploy can run as. */
|
||||
async #markDeployed(script: string): Promise<void> {
|
||||
try {
|
||||
await this.#layers.clients.redis.set(
|
||||
deployedMarkerKey(script),
|
||||
'1',
|
||||
'EX',
|
||||
DEPLOYED_MARKER_TTL_SECONDS,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[events] could not mark a script deployed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this scope has already deployed its hourly cap this hour. A
|
||||
* flapping set (publish, remove, republish) provisions upstream on every
|
||||
* miss, and this is the ceiling on that loop. An app's counter is per app;
|
||||
* a user scope has its own, so one user cannot spend another's.
|
||||
*/
|
||||
async #tooManyDeploysThisHour(scope: HandlerScope): Promise<boolean> {
|
||||
const redis = this.#layers.clients.redis;
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
const userScope = isUserScope(scope);
|
||||
const key = userScope
|
||||
? `ev:deploys:{${scope.appUid}}:${scope.holder}:${hour}`
|
||||
: `ev:deploys:{${scope.appUid}}:${hour}`;
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) await redis.expire(key, DEPLOY_THROTTLE_TTL_SECONDS);
|
||||
return (
|
||||
count >
|
||||
(userScope
|
||||
? EVENTS_USER_WORKER_DEPLOYS_PER_HOUR
|
||||
: EVENTS_WORKER_DEPLOYS_PER_HOUR)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user a scope's deploy runs as, as an actor: the app's owner, or the
|
||||
* holder of a user scope.
|
||||
*/
|
||||
async #owner(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
): Promise<
|
||||
| (Actor & { user: { id: number; uuid: string; username: string } })
|
||||
| null
|
||||
> {
|
||||
const { stores } = this.#layers;
|
||||
const app = await stores.app.getByUid(appUid);
|
||||
const ownerUserId = Number(app?.owner_user_id);
|
||||
if (!app || !Number.isFinite(ownerUserId)) return null;
|
||||
let ownerUserId = scope.holder;
|
||||
if (!isUserScope(scope)) {
|
||||
const app = await stores.app.getByUid(scope.appUid);
|
||||
ownerUserId = Number(app?.owner_user_id);
|
||||
if (!app || !Number.isFinite(ownerUserId)) return null;
|
||||
}
|
||||
|
||||
const user = await stores.user.getById(ownerUserId);
|
||||
if (!user?.id || !user.uuid || !user.username || user.suspended)
|
||||
@@ -289,11 +526,9 @@ export class LocalEventsInvokeTransport {
|
||||
this.#deployer = deployer;
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
// Built per attempt, so a send time stamped into the body is taken
|
||||
// after a cold deploy rather than before it.
|
||||
const request = (): Request =>
|
||||
new Request(
|
||||
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
|
||||
@@ -303,7 +538,7 @@ export class LocalEventsInvokeTransport {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-events-key': call.key,
|
||||
},
|
||||
body: call.body,
|
||||
body: call.render ? call.render() : call.body,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -311,22 +546,27 @@ export class LocalEventsInvokeTransport {
|
||||
const resident = await this.#dispatch(request(), call.timeoutMs);
|
||||
if (resident !== null) return resident;
|
||||
|
||||
const outcome = await this.#deployer.ensure(
|
||||
const scope = await this.#deployer.scopeFor(
|
||||
call.appUid,
|
||||
call.script,
|
||||
call.holder,
|
||||
);
|
||||
if (!scope) return { status: null, error: 'deploy: no-holder' };
|
||||
const outcome = await this.#deployer.ensure(scope, call.script, {
|
||||
lane: call.lane,
|
||||
});
|
||||
if (outcome !== 'deployed')
|
||||
return { status: null, error: `deploy: ${outcome}` };
|
||||
return {
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
...(outcome === 'busy' ? { deployBusy: true } : {}),
|
||||
};
|
||||
|
||||
const retried = await this.#dispatch(request(), call.timeoutMs);
|
||||
if (retried === null)
|
||||
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 +577,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 +588,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';
|
||||
@@ -75,11 +78,13 @@ interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
contentType?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
const makeReq = (init: {
|
||||
params?: Record<string, unknown>;
|
||||
path?: string;
|
||||
originalUrl?: string;
|
||||
actor?: Actor;
|
||||
hostname?: string;
|
||||
protocol?: string;
|
||||
@@ -90,6 +95,7 @@ const makeReq = (init: {
|
||||
headers: {},
|
||||
params: init.params ?? {},
|
||||
path: init.path ?? '/',
|
||||
originalUrl: init.originalUrl ?? init.path ?? '/',
|
||||
hostname: init.hostname ?? 'test.local',
|
||||
protocol: init.protocol ?? 'http',
|
||||
actor: init.actor,
|
||||
@@ -127,6 +133,11 @@ const makeRes = () => {
|
||||
captured.contentType = value;
|
||||
return res;
|
||||
}),
|
||||
redirect: vi.fn((code: number, location: string) => {
|
||||
captured.statusCode = code;
|
||||
captured.location = location;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
@@ -210,14 +221,12 @@ describe('HomepageController shell routes', () => {
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
});
|
||||
|
||||
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
|
||||
it('serves the shell on /settings, /dashboard, /action, /@:username', async () => {
|
||||
for (const path of [
|
||||
'/settings',
|
||||
'/settings/*splat',
|
||||
'/dashboard',
|
||||
'/dashboard/',
|
||||
'/desktop',
|
||||
'/desktop/',
|
||||
'/action/*splat',
|
||||
'/@:username',
|
||||
]) {
|
||||
@@ -227,6 +236,18 @@ describe('HomepageController shell routes', () => {
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('serves the shell on a path inside a public folder, /@:username/*splat', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/@:username/*splat',
|
||||
makeReq({ path: '/@alice/photos/cat.png' }),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /app/:name ──────────────────────────────────────────────────────
|
||||
@@ -356,49 +377,6 @@ describe('HomepageController GET /app/:name', () => {
|
||||
expect(html).not.toContain('owner_user_id');
|
||||
});
|
||||
|
||||
it('serves the same app shell under /desktop/app/:name', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `desk-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'Desktop App',
|
||||
description: 'opens on the desktop instead of the dashboard',
|
||||
index_url: `https://example.com/${name}/`,
|
||||
approved_for_listing: 1,
|
||||
},
|
||||
{ ownerUserId: userId },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/desktop/app/:name',
|
||||
makeReq({ params: { name }, path: `/desktop/app/${name}` }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const html = String(captured.body);
|
||||
expect(html).toMatch(/<!DOCTYPE html>/i);
|
||||
expect(html).toContain('Desktop App');
|
||||
});
|
||||
|
||||
it('returns 404 under /desktop/app/:name when the app is unknown', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/desktop/app/:name',
|
||||
makeReq({
|
||||
params: { name: 'no-such-app' },
|
||||
path: '/desktop/app/no-such-app',
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
});
|
||||
|
||||
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `pub-${Math.random().toString(36).slice(2, 10)}`;
|
||||
@@ -430,8 +408,8 @@ describe('HomepageController GET /app/:name', () => {
|
||||
// -- app landing rate limit --
|
||||
|
||||
describe('HomepageController app landing rate limit', () => {
|
||||
it('rate-limits /app/:name and /desktop/app/:name per network', () => {
|
||||
for (const path of ['/app/:name', '/desktop/app/:name']) {
|
||||
it('rate-limits /app/:name per network', () => {
|
||||
for (const path of ['/app/:name']) {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === path,
|
||||
);
|
||||
@@ -449,7 +427,7 @@ describe('HomepageController app landing rate limit', () => {
|
||||
// ── /show/* ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('HomepageController GET /show/*splat', () => {
|
||||
it('emits a launch_app explorer call with the post-/show path', async () => {
|
||||
it('serves the plain shell; the GUI opens the path itself', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
@@ -457,12 +435,111 @@ describe('HomepageController GET /show/*splat', () => {
|
||||
makeReq({ path: '/show/alice/Documents' }),
|
||||
res,
|
||||
);
|
||||
// The launch payload is JSON-serialized into the rendered HTML.
|
||||
// We assert the rendered shell included the explorer launch hint
|
||||
// pointing at the expected (slashed) path.
|
||||
const html = String(captured.body);
|
||||
expect(html).toContain('launch_app');
|
||||
expect(html).toContain('explorer');
|
||||
expect(html).toContain('/alice/Documents');
|
||||
expect(html).toMatch(/<!DOCTYPE html>/i);
|
||||
// No boot-time launch hook: the one it used to carry named a GUI
|
||||
// global that no longer exists, which stopped the GUI booting.
|
||||
expect(html).not.toContain('launch_app');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /desktop ────────────────────────────────────────────────────────
|
||||
|
||||
describe('HomepageController GET /desktop', () => {
|
||||
it('redirects /desktop to the root, keeping the query', async () => {
|
||||
for (const path of ['/desktop', '/desktop/']) {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute('get', path, makeReq({ path }), res);
|
||||
expect(captured.statusCode).toBe(302);
|
||||
expect(captured.location).toBe('/');
|
||||
}
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/desktop',
|
||||
makeReq({
|
||||
path: '/desktop',
|
||||
originalUrl: '/desktop?shared=%2Falice%2Fx%2Fy&download=a',
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.location).toBe('/?shared=%2Falice%2Fx%2Fy&download=a');
|
||||
});
|
||||
|
||||
it('redirects /desktop/app/:name to the app landing', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/desktop/app/:name',
|
||||
makeReq({
|
||||
params: { name: 'editor' },
|
||||
path: '/desktop/app/editor',
|
||||
originalUrl: '/desktop/app/editor?file=%2Falice%2Fa.txt',
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(302);
|
||||
expect(captured.location).toBe('/app/editor?file=%2Falice%2Fa.txt');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /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('');
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,6 @@ import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
import type {
|
||||
PuterHomepageService,
|
||||
PageMeta,
|
||||
LaunchOptions,
|
||||
} from '../../services/homepage/PuterHomepageService';
|
||||
|
||||
/** Longest description/og:description/twitter:description on `/app/<name>`. */
|
||||
@@ -43,9 +42,9 @@ const APP_LANDING_LIMIT = {
|
||||
};
|
||||
|
||||
/**
|
||||
* App descriptions can run long; search snippets and social cards want a
|
||||
* short blurb. Collapses whitespace, then cuts at a word boundary with an
|
||||
* ellipsis so the result never exceeds `APP_META_DESCRIPTION_MAX`.
|
||||
* App descriptions can run long; search snippets and social cards want a short
|
||||
* blurb. Collapses whitespace, then cuts at a word boundary with an ellipsis so
|
||||
* the result never exceeds `APP_META_DESCRIPTION_MAX`.
|
||||
*/
|
||||
export function appMetaDescription(text: string): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim();
|
||||
@@ -89,7 +88,6 @@ export class HomepageController extends PuterController {
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
metaOverrides: Partial<PageMeta> = {},
|
||||
launch: LaunchOptions = {},
|
||||
) => {
|
||||
const meta = { ...defaultMeta(req), ...metaOverrides };
|
||||
const actor =
|
||||
@@ -98,7 +96,7 @@ export class HomepageController extends PuterController {
|
||||
actor?: Parameters<typeof homepage.send>[0]['actor'];
|
||||
}
|
||||
).actor ?? null;
|
||||
await homepage.send({ req, res, actor }, meta, launch);
|
||||
await homepage.send({ req, res, actor }, meta);
|
||||
};
|
||||
|
||||
// -- Root + path-aliased shell routes ------------------------
|
||||
@@ -111,17 +109,41 @@ export class HomepageController extends PuterController {
|
||||
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/desktop', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
|
||||
// `/desktop` used to pick the desktop interface. What follows it is
|
||||
// the same route at the root, so it redirects there, keeping the query
|
||||
// (`?shared=`, `?download=`); the browser carries the hash over.
|
||||
const redirectWithoutDesktop = (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
target: string,
|
||||
) => {
|
||||
const url = req.originalUrl ?? req.url ?? '';
|
||||
const query = url.includes('?') ? url.slice(url.indexOf('?')) : '';
|
||||
res.redirect(302, `${target}${query}`);
|
||||
};
|
||||
|
||||
router.get('/desktop', {}, (req, res) =>
|
||||
redirectWithoutDesktop(req, res, '/'),
|
||||
);
|
||||
router.get('/desktop/', {}, (req, res) =>
|
||||
redirectWithoutDesktop(req, res, '/'),
|
||||
);
|
||||
router.get('/desktop/app/:name', {}, (req, res) =>
|
||||
redirectWithoutDesktop(
|
||||
req,
|
||||
res,
|
||||
`/app/${encodeURIComponent(String(req.params.name))}`,
|
||||
),
|
||||
);
|
||||
|
||||
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
// `/@<user>/<path>` names something in that user's Public folder; the
|
||||
// GUI opens it.
|
||||
router.get('/@:username', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/@:username/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
// -- /app/:name - app metadata baked into the shell ----------
|
||||
// `/desktop/app/:name` is the same landing booted on the desktop
|
||||
// instead of the dashboard; the GUI strips the prefix and treats the
|
||||
// rest of the path identically, so both render the same shell.
|
||||
|
||||
const sendAppShell = async (
|
||||
req: express.Request,
|
||||
@@ -176,26 +198,23 @@ export class HomepageController extends PuterController {
|
||||
{ rateLimit: APP_LANDING_LIMIT },
|
||||
sendAppShell,
|
||||
);
|
||||
router.get(
|
||||
'/desktop/app/:name',
|
||||
{ rateLimit: APP_LANDING_LIMIT },
|
||||
sendAppShell,
|
||||
);
|
||||
|
||||
// -- /show/* - launch explorer with the requested file path --
|
||||
// -- /show/* - the GUI opens the path that follows ----------
|
||||
|
||||
router.get('/show/*splat', {}, (req, res) => {
|
||||
const filePath = req.path.slice('/show'.length);
|
||||
const launch: LaunchOptions = {
|
||||
on_initialized: [
|
||||
{
|
||||
$: 'window-call',
|
||||
fn_name: 'launch_app',
|
||||
args: [{ name: 'explorer', path: filePath }],
|
||||
},
|
||||
],
|
||||
};
|
||||
return sendShell(req, res, {}, launch);
|
||||
router.get('/show/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
// -- /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 -----------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,7 +34,16 @@ import {
|
||||
type TestUserCredentials,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { EVENTS_KV_HANDLE_LIMIT } from '../events/limits.js';
|
||||
import { claimRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { seedKvShareHandles } from '../../services/kvShare/kvShareTestUtil.js';
|
||||
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
|
||||
import {
|
||||
EVENTS_KV_HANDLE_LIMIT,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
KV_SHARE_BATCH_MAX,
|
||||
KV_SHARE_GRANT_LIMITS,
|
||||
KV_SHARE_LOOKUP_LIMIT,
|
||||
} from '../events/limits.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -57,9 +66,12 @@ const call = (
|
||||
const resetBudgets = async (env: PuterTestEnv) => {
|
||||
for (const name of [env.users.user.username, env.users.other.username]) {
|
||||
const user = await env.server.stores.user.getByUsername(name);
|
||||
await env.server.clients.redis.del(
|
||||
`rate:${EVENTS_KV_HANDLE_LIMIT.scope}:${user!.id}`,
|
||||
);
|
||||
for (const scope of [
|
||||
EVENTS_KV_HANDLE_LIMIT.scope,
|
||||
KV_SHARE_LOOKUP_LIMIT.scope,
|
||||
...KV_SHARE_GRANT_LIMITS.map((window) => window.scope),
|
||||
])
|
||||
await env.server.clients.redis.del(`rate:${scope}:${user!.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,6 +125,7 @@ describe('/kv/shares', () => {
|
||||
'handle',
|
||||
'holder',
|
||||
'label',
|
||||
'notify',
|
||||
'prefix',
|
||||
].sort(),
|
||||
);
|
||||
@@ -121,6 +134,42 @@ describe('/kv/shares', () => {
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Groceries',
|
||||
notify: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes notify, keeps it on a re-share that leaves it out, and shows it to both sides', async () => {
|
||||
const prefix = `notify-${uuidv4().slice(0, 8)}:`;
|
||||
const on = await shareWithOther({ prefix, notify: true });
|
||||
expect(on).toMatchObject({ prefix, notify: true });
|
||||
|
||||
const kept = await shareWithOther({ prefix, access: 'write' });
|
||||
expect(kept).toMatchObject({ handle: on.handle, notify: true });
|
||||
|
||||
const listed = async (path: string, token: string) =>
|
||||
(
|
||||
(await (await req('GET', path, token)).json()) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
}
|
||||
).items.find((row) => row.handle === on.handle);
|
||||
expect(
|
||||
await listed('/kv/shares?limit=200', env.users.user.token),
|
||||
).toMatchObject({ notify: true });
|
||||
expect(
|
||||
await listed('/kv/shares/with-me?limit=200', env.users.other.token),
|
||||
).toMatchObject({ notify: true });
|
||||
|
||||
const off = await shareWithOther({ prefix, notify: false });
|
||||
expect(off).toMatchObject({ handle: on.handle, notify: false });
|
||||
|
||||
const refused = await req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix,
|
||||
recipient: env.users.other.username,
|
||||
notify: 'yes',
|
||||
});
|
||||
expect(refused.status).toBe(400);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'kv_share_notify_invalid',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +208,7 @@ describe('/kv/shares', () => {
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Trip',
|
||||
notify: false,
|
||||
createdAt: shared.createdAt,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
@@ -180,6 +230,7 @@ describe('/kv/shares', () => {
|
||||
access: 'write',
|
||||
appUid: null,
|
||||
label: 'Trip',
|
||||
notify: false,
|
||||
createdAt: shared.createdAt,
|
||||
});
|
||||
expect(withMeText).not.toContain(env.users.user.username);
|
||||
@@ -293,6 +344,481 @@ describe('/kv/shares', () => {
|
||||
const res = await req('GET', '/kv/shares');
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
describe('filtering a listing', () => {
|
||||
type Page = { items: Array<Record<string, unknown>>; total?: number };
|
||||
let appUid: string;
|
||||
let inApp: Record<string, unknown>;
|
||||
let global: Record<string, unknown>;
|
||||
|
||||
const page = async (path: string, token: string) => {
|
||||
const res = await req('GET', path, token);
|
||||
return { status: res.status, body: (await res.json()) as Page };
|
||||
};
|
||||
const handlesOf = (body: Page) => body.items.map((row) => row.handle);
|
||||
|
||||
const makeApp = async () => {
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const name = `kv-http-${uuidv4().slice(0, 8)}`;
|
||||
return env.server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'Listing App',
|
||||
index_url: `https://${name}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: owner!.id },
|
||||
);
|
||||
};
|
||||
|
||||
/** The token the desktop launches the app with, for `token`'s user. */
|
||||
const appToken = async (token: string, uid: string) => {
|
||||
const res = await req('POST', '/auth/get-user-app-token', token, {
|
||||
app_uid: uid,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetBudgets(env);
|
||||
appUid = (await makeApp()).uid;
|
||||
inApp = await shareWithOther({ appUid });
|
||||
global = await shareWithOther();
|
||||
});
|
||||
|
||||
it('lists every app`s shares for the account, or one app`s, or the app-less store`s with `null`', async () => {
|
||||
for (const path of ['/kv/shares', '/kv/shares/with-me']) {
|
||||
const token =
|
||||
path === '/kv/shares'
|
||||
? env.users.user.token
|
||||
: env.users.other.token;
|
||||
const all = await page(`${path}?limit=200`, token);
|
||||
expect(handlesOf(all.body)).toEqual(
|
||||
expect.arrayContaining([inApp.handle, global.handle]),
|
||||
);
|
||||
|
||||
const one = await page(
|
||||
`${path}?limit=200&appUid=${appUid}`,
|
||||
token,
|
||||
);
|
||||
expect(handlesOf(one.body)).toEqual([inApp.handle]);
|
||||
|
||||
const appLess = await page(
|
||||
`${path}?limit=200&appUid=null`,
|
||||
token,
|
||||
);
|
||||
expect(handlesOf(appLess.body)).toContain(global.handle);
|
||||
expect(
|
||||
appLess.body.items.every((row) => row.appUid === null),
|
||||
).toBe(true);
|
||||
|
||||
const bad = await page(`${path}?appUid=not-an-app`, token);
|
||||
expect(bad.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an app to its own namespace, refusing any other it names', async () => {
|
||||
const token = await appToken(env.users.other.token, appUid);
|
||||
const own = await page('/kv/shares/with-me?limit=200', token);
|
||||
expect(handlesOf(own.body)).toEqual([inApp.handle]);
|
||||
const named = await page(
|
||||
`/kv/shares/with-me?limit=200&appUid=${appUid}`,
|
||||
token,
|
||||
);
|
||||
expect(handlesOf(named.body)).toEqual([inApp.handle]);
|
||||
|
||||
const otherApp = (await makeApp()).uid;
|
||||
for (const appUidParam of ['null', otherApp]) {
|
||||
const refused = await page(
|
||||
`/kv/shares/with-me?appUid=${appUidParam}`,
|
||||
token,
|
||||
);
|
||||
expect(refused.status).toBe(403);
|
||||
expect(refused.body).toMatchObject({
|
||||
code: 'events_kv_handle_outside_namespace',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('narrows what was shared out to live or revoked shares, all by default', async () => {
|
||||
const gone = await shareWithOther({ appUid });
|
||||
await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${gone.handle}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
const at = async (state?: string) =>
|
||||
page(
|
||||
`/kv/shares?limit=200&appUid=${appUid}` +
|
||||
(state ? `&state=${state}` : ''),
|
||||
env.users.user.token,
|
||||
);
|
||||
|
||||
expect(handlesOf((await at()).body)).toEqual([
|
||||
inApp.handle,
|
||||
gone.handle,
|
||||
]);
|
||||
expect(handlesOf((await at('all')).body)).toEqual([
|
||||
inApp.handle,
|
||||
gone.handle,
|
||||
]);
|
||||
expect(handlesOf((await at('live')).body)).toEqual([inApp.handle]);
|
||||
expect(handlesOf((await at('revoked')).body)).toEqual([
|
||||
gone.handle,
|
||||
]);
|
||||
const bad = await at('stopped');
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with several recipients', () => {
|
||||
let third: { username: string; uuid: string; id: number };
|
||||
// Holds seeded rows, so no recipient's per-holder cap is what trips.
|
||||
let filler: number;
|
||||
let ownerId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const username = `kv-batch-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
third = { username, uuid: row!.uuid as string, id: row!.id! };
|
||||
ownerId = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!.id!;
|
||||
const fillerName = `kv-filler-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username: fillerName,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
filler = (await env.server.stores.user.getByUsername(fillerName))!
|
||||
.id!;
|
||||
});
|
||||
|
||||
const shareMany = (body: Record<string, unknown>) =>
|
||||
req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: `batch-${uuidv4().slice(0, 8)}:`,
|
||||
...body,
|
||||
});
|
||||
|
||||
it('reports each recipient on its own item, in order', async () => {
|
||||
const res = await shareMany({
|
||||
recipients: [
|
||||
env.users.other.username,
|
||||
'nobody-by-this-name',
|
||||
env.users.user.username,
|
||||
'[email protected]',
|
||||
{ uid: third.uuid },
|
||||
],
|
||||
access: 'write',
|
||||
label: 'Team list',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const { items } = (await res.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(items).toHaveLength(5);
|
||||
expect(items[0]).toMatchObject({
|
||||
recipient: env.users.other.username,
|
||||
handle: expect.any(String),
|
||||
access: 'write',
|
||||
holder: env.users.other.username,
|
||||
label: 'Team list',
|
||||
appUid: null,
|
||||
});
|
||||
expect(items[1]).toEqual({
|
||||
recipient: 'nobody-by-this-name',
|
||||
error: {
|
||||
message: expect.any(String),
|
||||
code: 'subject_does_not_exist',
|
||||
},
|
||||
});
|
||||
expect(items[2]).toMatchObject({
|
||||
error: { code: 'bad_request' },
|
||||
});
|
||||
expect(items[3]).toMatchObject({
|
||||
error: { code: 'invalid_recipient' },
|
||||
});
|
||||
expect(items[4]).toMatchObject({
|
||||
recipient: { uid: third.uuid },
|
||||
holder: third.username,
|
||||
});
|
||||
expect(items[4].handle).not.toBe(items[0].handle);
|
||||
});
|
||||
|
||||
it('answers a recipient named twice with the one share', async () => {
|
||||
const res = await shareMany({
|
||||
recipients: [third.username, { uid: third.uuid }],
|
||||
});
|
||||
const { items } = (await res.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(items[0].handle).toEqual(expect.any(String));
|
||||
expect(items[1].handle).toBe(items[0].handle);
|
||||
});
|
||||
|
||||
it('refuses a malformed batch outright', async () => {
|
||||
for (const body of [
|
||||
{ recipients: [] },
|
||||
{ recipients: 'alice' },
|
||||
{
|
||||
recipients: Array.from(
|
||||
{ length: KV_SHARE_BATCH_MAX + 1 },
|
||||
(_, i) => `user-${i}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
recipient: env.users.other.username,
|
||||
recipients: [env.users.other.username],
|
||||
},
|
||||
]) {
|
||||
const res = await shareMany(body);
|
||||
expect(res.status).toBe(400);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
code: 'bad_request',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses the whole batch when it would pass the account cap', async () => {
|
||||
const prefix = `batch-cap-${uuidv4().slice(0, 8)}:`;
|
||||
const existing = await shareMany({
|
||||
prefix,
|
||||
recipients: [env.users.other.username],
|
||||
});
|
||||
const [standing] = (
|
||||
(await existing.json()) as { items: { handle: string }[] }
|
||||
).items;
|
||||
try {
|
||||
const { total } =
|
||||
await env.server.stores.kvShareHandle.countLiveForOwner(
|
||||
ownerId,
|
||||
);
|
||||
// One slot left.
|
||||
await seedKvShareHandles(env.server, {
|
||||
ownerUserId: ownerId,
|
||||
ownerUuid,
|
||||
granteeUserId: filler,
|
||||
count: EVENTS_KV_HANDLES_PER_USER.limit - total - 1,
|
||||
appUid: () => KV_GLOBAL_APP_KEY,
|
||||
});
|
||||
|
||||
const refused = await shareMany({
|
||||
prefix: `batch-over-${uuidv4().slice(0, 8)}:`,
|
||||
recipients: [env.users.other.username, third.username],
|
||||
});
|
||||
expect(refused.status).toBe(409);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'events_kv_handle_limit_reached',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await env.server.stores.kvShareHandle.countLiveForOwner(
|
||||
ownerId,
|
||||
)
|
||||
).total,
|
||||
).toBe(EVENTS_KV_HANDLES_PER_USER.limit - 1);
|
||||
|
||||
// The standing share takes no slot, so one new one still fits.
|
||||
const fits = await shareMany({
|
||||
prefix,
|
||||
recipients: [env.users.other.username, third.username],
|
||||
});
|
||||
expect(fits.status).toBe(200);
|
||||
const { items } = (await fits.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(items[0].handle).toBe(standing.handle);
|
||||
expect(items[1]).toMatchObject({ holder: third.username });
|
||||
} finally {
|
||||
await env.server.clients.db.write(
|
||||
'DELETE FROM `kv_share_handles` WHERE `owner_user_id` = ?',
|
||||
[ownerId],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('spends the grant budget per recipient, all or nothing', async () => {
|
||||
const [minute] = KV_SHARE_GRANT_LIMITS;
|
||||
await claimRateLimit(
|
||||
`${minute.scope}:${ownerId}`,
|
||||
minute.limit,
|
||||
minute.window,
|
||||
minute.limit - 1,
|
||||
);
|
||||
try {
|
||||
const refused = await shareMany({
|
||||
recipients: [env.users.other.username, third.username],
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'too_many_requests',
|
||||
});
|
||||
} finally {
|
||||
await resetBudgets(env);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharing again', () => {
|
||||
let ownerId: number;
|
||||
let third: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
third = `kv-again-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username: third,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
ownerId = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!.id!;
|
||||
});
|
||||
|
||||
const shareAt = (prefix: string, body: Record<string, unknown>) =>
|
||||
req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix,
|
||||
...body,
|
||||
});
|
||||
|
||||
/** Leaves `left` units in one of the owner's windows. */
|
||||
const drain = (
|
||||
window: { scope: string; limit: number; window: number },
|
||||
left = 0,
|
||||
) =>
|
||||
claimRateLimit(
|
||||
`${window.scope}:${ownerId}`,
|
||||
window.limit,
|
||||
window.window,
|
||||
window.limit - left,
|
||||
);
|
||||
|
||||
it('spends neither the mint nor the grant budget when every share stays as it is', async () => {
|
||||
const prefix = `again-${uuidv4().slice(0, 8)}:`;
|
||||
const recipients = [env.users.other.username, third];
|
||||
const made = (await (
|
||||
await shareAt(prefix, { recipients, label: 'Room' })
|
||||
).json()) as { items: Array<{ handle: string }> };
|
||||
await drain(EVENTS_KV_HANDLE_LIMIT);
|
||||
for (const window of KV_SHARE_GRANT_LIMITS) await drain(window);
|
||||
try {
|
||||
const again = await shareAt(prefix, {
|
||||
recipients: [...recipients, third],
|
||||
});
|
||||
expect(again.status).toBe(200);
|
||||
const { items } = (await again.json()) as {
|
||||
items: Array<{ handle: string; label: string }>;
|
||||
};
|
||||
expect(items.map((item) => item.handle)).toEqual([
|
||||
made.items[0].handle,
|
||||
made.items[1].handle,
|
||||
made.items[1].handle,
|
||||
]);
|
||||
expect(items[0].label).toBe('Room');
|
||||
|
||||
const single = await shareAt(prefix, {
|
||||
recipient: env.users.other.username,
|
||||
access: 'read',
|
||||
});
|
||||
expect(single.status).toBe(200);
|
||||
} finally {
|
||||
await resetBudgets(env);
|
||||
}
|
||||
});
|
||||
|
||||
it('spends the mint budget on a change, and grant units only on recipients without the share', async () => {
|
||||
const prefix = `again-${uuidv4().slice(0, 8)}:`;
|
||||
const other = env.users.other.username;
|
||||
expect((await shareAt(prefix, { recipient: other })).status).toBe(
|
||||
200,
|
||||
);
|
||||
for (const window of KV_SHARE_GRANT_LIMITS) await drain(window);
|
||||
try {
|
||||
// A level change on a standing share takes no grant unit.
|
||||
const raised = await shareAt(prefix, {
|
||||
recipients: [other],
|
||||
access: 'write',
|
||||
});
|
||||
expect(raised.status).toBe(200);
|
||||
|
||||
// Naming an unknown user next to it costs what a new share
|
||||
// would, so the window refuses both.
|
||||
for (const extra of ['nobody-by-this-name', third]) {
|
||||
const refused = await shareAt(prefix, {
|
||||
recipients: [other, extra],
|
||||
access: 'write',
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
}
|
||||
} finally {
|
||||
await resetBudgets(env);
|
||||
}
|
||||
|
||||
await drain(EVENTS_KV_HANDLE_LIMIT);
|
||||
try {
|
||||
const lowered = await shareAt(prefix, {
|
||||
recipient: other,
|
||||
access: 'read',
|
||||
});
|
||||
expect(lowered.status).toBe(429);
|
||||
await expect(lowered.json()).resolves.toMatchObject({
|
||||
code: 'too_many_requests',
|
||||
});
|
||||
} finally {
|
||||
await resetBudgets(env);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts turning notify on or off as a change', async () => {
|
||||
const prefix = `again-${uuidv4().slice(0, 8)}:`;
|
||||
const other = env.users.other.username;
|
||||
expect((await shareAt(prefix, { recipient: other })).status).toBe(
|
||||
200,
|
||||
);
|
||||
await drain(EVENTS_KV_HANDLE_LIMIT);
|
||||
try {
|
||||
expect(
|
||||
(await shareAt(prefix, { recipient: other })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await shareAt(prefix, {
|
||||
recipient: other,
|
||||
notify: true,
|
||||
})
|
||||
).status,
|
||||
).toBe(429);
|
||||
} finally {
|
||||
await resetBudgets(env);
|
||||
}
|
||||
});
|
||||
|
||||
it('spends the lookup budget on every recipient named', async () => {
|
||||
const prefix = `again-${uuidv4().slice(0, 8)}:`;
|
||||
const other = env.users.other.username;
|
||||
expect((await shareAt(prefix, { recipient: other })).status).toBe(
|
||||
200,
|
||||
);
|
||||
await resetBudgets(env);
|
||||
await drain(KV_SHARE_LOOKUP_LIMIT, 1);
|
||||
try {
|
||||
expect(
|
||||
(await shareAt(prefix, { recipient: other })).status,
|
||||
).toBe(200);
|
||||
const refused = await shareAt(prefix, { recipient: other });
|
||||
expect(refused.status).toBe(429);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'too_many_requests',
|
||||
});
|
||||
} finally {
|
||||
await resetBudgets(env);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/events/kv-handles beside /kv/shares', () => {
|
||||
|
||||
@@ -22,27 +22,100 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import { parseKvShareRecipient } from '../../services/kvShare/kvShares.js';
|
||||
import type { KvShareHandleListOptions } from '../../stores/events/KvShareHandleStore.js';
|
||||
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
|
||||
import {
|
||||
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 { EVENTS_LIST_LIMIT } from '../events/limits.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.
|
||||
*
|
||||
@@ -56,7 +129,8 @@ export class KvShareController extends PuterController {
|
||||
/**
|
||||
* POST /kv/shares — share a region with another account, `read` unless the
|
||||
* body asks otherwise. Sharing the same region with the same account again
|
||||
* changes its level or label in place.
|
||||
* 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',
|
||||
@@ -67,29 +141,53 @@ export class KvShareController extends PuterController {
|
||||
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,
|
||||
{
|
||||
prefix: body.prefix,
|
||||
recipient: parseKvShareRecipient(body.recipient),
|
||||
access: body.access,
|
||||
appUid: body.appUid,
|
||||
label: body.label,
|
||||
},
|
||||
{ ...fields, recipient: parseKvShareRecipient(body.recipient) },
|
||||
{ defaultAccess: 'read' },
|
||||
);
|
||||
res.json({
|
||||
handle: share.handle,
|
||||
prefix: share.prefix,
|
||||
access: share.access,
|
||||
appUid: publicAppUid(share.appUid),
|
||||
holder: share.holderUsername,
|
||||
label: share.label,
|
||||
createdAt: share.createdAt,
|
||||
});
|
||||
res.json(shareBody(share));
|
||||
}
|
||||
|
||||
/** GET /kv/shares — what this account has shared out, retired included. */
|
||||
/**
|
||||
* GET /kv/shares — what this account has shared out, retired included
|
||||
* unless `state` narrows it.
|
||||
*/
|
||||
@Get('/shares', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
@@ -100,7 +198,7 @@ export class KvShareController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const page = await this.services.kvShare.listKvHandles(
|
||||
actor,
|
||||
pageOptions(req),
|
||||
ownerPageOptions(req),
|
||||
);
|
||||
res.json({
|
||||
items: page.items.map((row) => ({
|
||||
@@ -110,6 +208,7 @@ export class KvShareController extends PuterController {
|
||||
appUid: publicAppUid(row.appUid),
|
||||
holder: row.holderUsername,
|
||||
label: row.label,
|
||||
notify: row.notify,
|
||||
createdAt: row.createdAt,
|
||||
revokedAt: row.revokedAt,
|
||||
revokedBy: row.revokedBy,
|
||||
@@ -138,6 +237,7 @@ export class KvShareController extends PuterController {
|
||||
access: row.access,
|
||||
appUid: publicAppUid(row.appUid),
|
||||
label: row.label,
|
||||
notify: row.notify,
|
||||
createdAt: row.createdAt,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import type { Request, Response } from 'express';
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { RouteDescriptor } from '../../core/http/types.js';
|
||||
@@ -111,6 +112,7 @@ const callGetHandler = async (
|
||||
let captured: { filename?: string; root?: string } = {};
|
||||
const req = {} as Request;
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
sendFile: vi.fn((filename: string, opts: { root: string }) => {
|
||||
captured = { filename, root: opts.root };
|
||||
}),
|
||||
@@ -151,6 +153,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 +194,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 () => {
|
||||
@@ -227,6 +240,48 @@ describe('StaticAssetsController puterjs_root', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── push-sw.js caching ──────────────────────────────────────────────
|
||||
|
||||
/** Serves the route's handler over real HTTP and returns the response. */
|
||||
const fetchThroughExpress = async (router: PuterRouter, routePath: string) => {
|
||||
const route = findRoute(router, 'get', routePath);
|
||||
if (!route) throw new Error(`No GET ${routePath} registered`);
|
||||
const app = express();
|
||||
app.get(routePath, (req, res, next) => route.handler(req, res, next));
|
||||
const listener = app.listen(0);
|
||||
await new Promise((resolve) => listener.once('listening', resolve));
|
||||
try {
|
||||
const { port } = listener.address() as AddressInfo;
|
||||
return await fetch(`http://127.0.0.1:${port}${routePath}`);
|
||||
} finally {
|
||||
listener.close();
|
||||
}
|
||||
};
|
||||
|
||||
describe('StaticAssetsController push-sw.js', () => {
|
||||
it.each([
|
||||
['client_libs_root', 'puter.js/push-sw.js'],
|
||||
['puterjs_root', 'push-sw.js'],
|
||||
] as const)(
|
||||
'lets the CDN keep it for five minutes from %s',
|
||||
async (configKey, file) => {
|
||||
const root = makeTempDir();
|
||||
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
writeFileSync(path.join(root, file), '/* worker */');
|
||||
const router = buildController({ [configKey]: root });
|
||||
|
||||
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
|
||||
const response = await fetchThroughExpress(router, route);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('cache-control')).toBe(
|
||||
'public, max-age=0, s-maxage=300',
|
||||
);
|
||||
expect(await response.text()).toBe('/* worker */');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── gui_assets_root ─────────────────────────────────────────────────
|
||||
|
||||
describe('StaticAssetsController gui_assets_root', () => {
|
||||
|
||||
@@ -23,12 +23,15 @@ import path from 'node:path';
|
||||
import { PuterController } from '../types.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
|
||||
// Sites import push-sw.js by URL, so the CDN refetches it within five minutes.
|
||||
const PUSH_WORKER_CACHE_CONTROL = 'public, max-age=0, s-maxage=300';
|
||||
|
||||
/**
|
||||
* 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 +58,13 @@ 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.setHeader('Cache-Control', PUSH_WORKER_CACHE_CONTROL);
|
||||
res.sendFile('puter.js/push-sw.js', { root });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// puter-js SDK mount. GUI loads it at `/sdk/puter.dev.js`; the
|
||||
@@ -100,6 +110,15 @@ 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.setHeader(
|
||||
'Cache-Control',
|
||||
PUSH_WORKER_CACHE_CONTROL,
|
||||
);
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -836,6 +836,34 @@ describe('requireVerifiedAccount', () => {
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
});
|
||||
|
||||
it('asks an email-gate-last account for card, then phone, then email', () => {
|
||||
const pending = {
|
||||
uuid: 'u-1',
|
||||
requires_email_confirmation: true,
|
||||
email_confirmed: false,
|
||||
requires_phone_verification: true,
|
||||
requires_card_verification: true,
|
||||
metadata: { emailGateLast: true },
|
||||
};
|
||||
const gateFor = (user: Record<string, unknown>) =>
|
||||
runGate(requireVerifiedAccount(), { actor: { user } });
|
||||
expectHttpError(gateFor(pending), 403, 'card_verification_required');
|
||||
expectHttpError(
|
||||
gateFor({ ...pending, requires_card_verification: false }),
|
||||
403,
|
||||
'phone_verification_required',
|
||||
);
|
||||
expectHttpError(
|
||||
gateFor({
|
||||
...pending,
|
||||
requires_card_verification: false,
|
||||
requires_phone_verification: false,
|
||||
}),
|
||||
403,
|
||||
'email_confirmation_required',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 403 two_factor_required for a seat whose team requires 2FA', async () => {
|
||||
const teams = { getOrgSeat: async () => ({ require_2fa: 1 }) };
|
||||
const got = await runGateAsync(requireTeam2fa(teams), {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type CardFallbackDeps,
|
||||
} from '../../../util/cardFallback';
|
||||
import { reserveUsernames } from '../../../util/reservedUsernames';
|
||||
import { isEmailGateLast } from '../../../util/verificationOrder';
|
||||
import {
|
||||
isAccountContext,
|
||||
isAppActor,
|
||||
@@ -309,11 +310,25 @@ export const requireVerifiedAccount = (): RequestHandler =>
|
||||
export const assertVerifiedAccount = (
|
||||
user: AccountGateUser | undefined,
|
||||
): void => {
|
||||
if (user?.requires_email_confirmation && !user?.email_confirmed) {
|
||||
throw new HttpError(403, 'Please confirm your email to continue', {
|
||||
legacyCode: 'email_confirmation_required',
|
||||
});
|
||||
}
|
||||
const assertEmail = () => {
|
||||
if (user?.requires_email_confirmation && !user?.email_confirmed) {
|
||||
throw new HttpError(403, 'Please confirm your email to continue', {
|
||||
legacyCode: 'email_confirmation_required',
|
||||
});
|
||||
}
|
||||
};
|
||||
const assertCard = () => {
|
||||
if (user?.requires_card_verification) {
|
||||
throw new HttpError(403, 'Please verify your card to continue', {
|
||||
legacyCode: 'card_verification_required',
|
||||
});
|
||||
}
|
||||
};
|
||||
// Email → phone → card, or card → phone → email for an email-gate-last
|
||||
// account, so the client is sent to the gate it has to clear next.
|
||||
const emailLast = isEmailGateLast(user);
|
||||
if (emailLast) assertCard();
|
||||
else assertEmail();
|
||||
if (user?.requires_phone_verification) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
@@ -323,11 +338,8 @@ export const assertVerifiedAccount = (
|
||||
},
|
||||
);
|
||||
}
|
||||
if (user?.requires_card_verification) {
|
||||
throw new HttpError(403, 'Please verify your card to continue', {
|
||||
legacyCode: 'card_verification_required',
|
||||
});
|
||||
}
|
||||
if (emailLast) assertEmail();
|
||||
else assertCard();
|
||||
// A team seat signs in on a password its administrator still holds,
|
||||
// so it reaches nothing until it has replaced that password.
|
||||
if (user?.requires_password_change) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -476,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;
|
||||
@@ -500,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 &&
|
||||
@@ -586,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,
|
||||
|
||||
Loaded 100 of 777 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user