mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efc0ab7126 | ||
|
|
ef99893fc8 | ||
|
|
f8dc10268e | ||
|
|
5d34e803ff | ||
|
|
a40e4d984a | ||
|
|
dd144b6b64 | ||
|
|
4851df716c | ||
|
|
faa8cd99dd | ||
|
|
279bb8beb6 | ||
|
|
52aedde3c5 | ||
|
|
7f832ede43 | ||
|
|
58275ccd52 | ||
|
|
11114c80d6 | ||
|
|
5d907d18c9 | ||
|
|
2d5e037599 | ||
|
|
8d3a1ccc2b | ||
|
|
a1fe14cbd5 | ||
|
|
a195605003 | ||
|
|
1e39936d12 | ||
|
|
1390a87280 | ||
|
|
6d47db9358 | ||
|
|
2e57214bb3 | ||
|
|
7ac2f2e247 | ||
|
|
0982e696a5 | ||
|
|
fea89c91b8 | ||
|
|
489a7c0129 | ||
|
|
e5ba469ffc | ||
|
|
2a3c181657 | ||
|
|
4881264c88 | ||
|
|
c7c3f21e4b | ||
|
|
7401fe0b09 | ||
|
|
18f5c9f2c7 | ||
|
|
f0f97a08cf |
No files matched your search
@@ -47,3 +47,13 @@ jobs:
|
||||
PUTER_TEST_AI_MOONSHOT_API_KEY: ${{ secrets.PUTER_TEST_AI_MOONSHOT_API_KEY }}
|
||||
PUTER_TEST_AI_ZAI_API_KEY: ${{ secrets.PUTER_TEST_AI_ZAI_API_KEY }}
|
||||
PUTER_TEST_AI_ELEVENLABS_API_KEY: ${{ secrets.PUTER_TEST_AI_ELEVENLABS_API_KEY }}
|
||||
|
||||
# Realtime conversations through the SDK against live OpenAI. Builds
|
||||
# the SDK bundle these tests load itself, so it runs even when the
|
||||
# step above fails; the tests skip themselves without the key.
|
||||
- name: Run live realtime conversation tests
|
||||
if: ${{ !cancelled() }}
|
||||
run: npm run setupExtensions && (cd src/puter-js && npm run build) && npx vitest run --config src/puter-js/tests/api/vitest.config.ts src/puter-js/tests/api/realtime/convoLive.test.ts
|
||||
env:
|
||||
CI: 'true'
|
||||
PUTER_TEST_AI_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENAI_API_KEY }}
|
||||
@@ -118,9 +118,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The base branch lives in this repo; for a fork PR, the head
|
||||
# repo's copy of it can be arbitrarily stale.
|
||||
- ref: ${{ github.base_ref }}
|
||||
repository: ${{ github.repository }}
|
||||
artifact: base
|
||||
- ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
artifact: pr
|
||||
|
||||
steps:
|
||||
@@ -129,7 +133,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
repository: ${{ matrix.repository }}
|
||||
|
||||
- name: Set up Node.js
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
|
||||
@@ -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"]
|
||||
@@ -59,6 +59,10 @@ 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.
|
||||
|
||||
Cursors that carry a sort also pin it: a request that passes a cursor plus a
|
||||
conflicting sort is rejected with 400.
|
||||
|
||||
+13
-2
@@ -18,6 +18,17 @@ Core subscribes the browser to a push service only when the server also publishe
|
||||
|
||||
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:
|
||||
@@ -57,8 +68,8 @@ interface PushClient {
|
||||
}
|
||||
```
|
||||
|
||||
- `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. It subscribes with the `/sw.js` registration, registers the device with `POST /push/devices` (`platform: 'web'`), and resolves the device `uid` the server returned. It rejects when permission is refused or the device couldn't be registered.
|
||||
- `disable()` unsubscribes and removes this browser's device with `DELETE /push/devices/:uid`. The tab calls it for Turn off, and for Remove on the device marked as this browser; Remove on any other device is a plain `DELETE`.
|
||||
- `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.
|
||||
|
||||
+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`).
|
||||
|
||||
|
||||
+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
+769
-744
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"
|
||||
|
||||
@@ -275,6 +275,16 @@ describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
expect(postgres().timestampFromUnixSql()).toBe('to_timestamp(?)');
|
||||
});
|
||||
|
||||
it('reads a timestamp column back as unix seconds in each engine', () => {
|
||||
expect(sqlite().unixSecondsSql('`t`')).toBe(
|
||||
"CAST(strftime('%s', `t`) AS INTEGER)",
|
||||
);
|
||||
expect(mysql().unixSecondsSql('`t`')).toBe('UNIX_TIMESTAMP(`t`)');
|
||||
expect(postgres().unixSecondsSql('`t`')).toBe(
|
||||
'EXTRACT(EPOCH FROM `t`)::bigint',
|
||||
);
|
||||
});
|
||||
|
||||
it('describes a stored timestamp the way each driver reads it back', () => {
|
||||
const seconds = Date.UTC(2026, 9, 11, 8, 5, 9) / 1000;
|
||||
expect(sqlite().timestampValue(seconds)).toBe('2026-10-11 08:05:09');
|
||||
|
||||
@@ -388,6 +388,19 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp column as unix seconds, computed by the database: the mysql
|
||||
* driver reads a stored UTC timestamp as local time, so parsing it in JS
|
||||
* can be off by the server's UTC offset.
|
||||
*/
|
||||
unixSecondsSql(column: string): string {
|
||||
return this.case({
|
||||
postgres: `EXTRACT(EPOCH FROM ${column})::bigint`,
|
||||
mysql: `UNIX_TIMESTAMP(${column})`,
|
||||
otherwise: `CAST(strftime('%s', ${column}) AS INTEGER)`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What a read of a timestamp column holding `seconds` returns: sqlite gives
|
||||
* back the UTC text it stored, mysql2 and pg a `Date` read in node's time
|
||||
|
||||
@@ -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 = 93;
|
||||
const CURRENT_SCHEMA_VERSION = 95;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -673,6 +673,38 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
});
|
||||
});
|
||||
|
||||
it('adds kv_share_handles.notify from 0099, off for every existing share', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-notify-'));
|
||||
const path = join(dir, 'puter.sqlite');
|
||||
try {
|
||||
const before = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path, targetVersion: 94 }),
|
||||
);
|
||||
await before.onServerStart();
|
||||
await before.write(
|
||||
'INSERT INTO `kv_share_handles` (`handle`, `owner_user_id`, ' +
|
||||
'`grantee_user_id`, `app_uid`, `key_prefix`, `permission`, ' +
|
||||
'`created_at`) VALUES (?, 1, 1, ?, ?, ?, 0)',
|
||||
['kvh-before-notify', 'os-global', 'a:', 'kv-share:x'],
|
||||
);
|
||||
before.onServerShutdown();
|
||||
|
||||
const after = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path }),
|
||||
);
|
||||
await after.onServerStart();
|
||||
await expect(
|
||||
after.read(
|
||||
'SELECT `notify` FROM `kv_share_handles` WHERE `handle` = ?',
|
||||
['kvh-before-notify'],
|
||||
),
|
||||
).resolves.toEqual([{ notify: 0 }]);
|
||||
after.onServerShutdown();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('indexes notification.created_at for the retention sweep', async () => {
|
||||
const rows = await client.read('PRAGMA index_list(`notification`)');
|
||||
expect(rows.map((r) => String(r.name))).toContain(
|
||||
@@ -680,6 +712,18 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
);
|
||||
});
|
||||
|
||||
it('indexes notification.replaced_at per mailbox slice for catch-up', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA index_info(`idx_notification_replaced`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'user_id',
|
||||
'audience',
|
||||
'app_uid',
|
||||
'replaced_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an already-migrated database untouched on a second boot', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
|
||||
const path = join(dir, 'nested', 'puter.sqlite');
|
||||
|
||||
@@ -132,6 +132,8 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[90, ['0095_kv-share-handle-levels.sql']],
|
||||
[91, ['0096_kv-share-handle-quota-indexes.sql']],
|
||||
[92, ['0097_user-to-user-holder-permission-index.sql']],
|
||||
[93, ['0098_notification-groups.sql']],
|
||||
[94, ['0099_kv-share-handle-notify.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `notification`.`group_key` and `replaced_at`. See
|
||||
-- sqlite/0098_notification-groups.sql for the rationale. The key is a hex
|
||||
-- digest, so ascii and compared byte for byte. No per-file applied-state
|
||||
-- tracking, so every step is guarded and a replay does nothing.
|
||||
|
||||
CALL _puter_add_col('notification', 'group_key', '`group_key` char(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
|
||||
CALL _puter_add_col('notification', 'replaced_at', '`replaced_at` bigint DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_group_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_group_indexes()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_group'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_group`
|
||||
(`user_id`, `app_uid`, `group_key`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_replaced'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_replaced`
|
||||
(`user_id`, `audience`, `app_uid`, `replaced_at`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_notification_group_indexes();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_group_indexes;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Notifications through a share handle. See
|
||||
-- sqlite/0099_kv-share-handle-notify.sql for the column. No per-file
|
||||
-- applied-state tracking, so it goes through _puter_add_col and a replay does
|
||||
-- nothing.
|
||||
|
||||
CALL _puter_add_col('kv_share_handles', 'notify', '`notify` tinyint(1) NOT NULL DEFAULT 0');
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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,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;
|
||||
@@ -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': {
|
||||
@@ -549,6 +558,8 @@ 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;
|
||||
};
|
||||
@@ -577,7 +588,7 @@ 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;
|
||||
};
|
||||
/**
|
||||
@@ -743,6 +754,7 @@ export type EventMap = {
|
||||
'outer.gui.item.renamed': GuiEvent;
|
||||
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
|
||||
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
|
||||
'outer.gui.notif.removed': GuiEvent<{ uid: string }>;
|
||||
'outer.gui.notif.unreads': GuiEvent<{
|
||||
unreads: { uid: string; notification: unknown }[];
|
||||
}>;
|
||||
@@ -1028,6 +1040,13 @@ export type NotifDeliveryOutcome =
|
||||
/** 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 = {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
EVENTS_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EventsWorkerInvokerClient,
|
||||
INVOKE_CONNECTIONS,
|
||||
outcomeForStatus,
|
||||
type WorkerInvokeRequest,
|
||||
} from './EventsWorkerInvokerClient.js';
|
||||
|
||||
@@ -54,14 +56,22 @@ const stubFetch = (
|
||||
(async () =>
|
||||
new Response(null, { status, headers })) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that never answers until the abort signal fires. */
|
||||
/**
|
||||
* A stub fetch that never answers until the abort signal fires, then rejects
|
||||
* with the signal's reason the way undici does.
|
||||
*/
|
||||
const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('This operation was aborted')),
|
||||
reject(init.signal!.reason),
|
||||
);
|
||||
})) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that fails before any answer, as a refused connection does. */
|
||||
const refusedFetch: FetchImpl = (async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that records the URL it was called with. */
|
||||
const capturingFetch = (
|
||||
calls: string[],
|
||||
@@ -141,13 +151,39 @@ describe('DispatcherInvokeTransport', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('answers null with an error when the request times out', async () => {
|
||||
it('answers null and says so when the request times out', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: hangingFetch,
|
||||
});
|
||||
const result = await transport.send({ ...CALL, timeoutMs: 10 });
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.error).toMatch(/abort/i);
|
||||
expect(result.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('answers null without a timeout when the request fails outright', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: refusedFetch,
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: null,
|
||||
error: 'fetch failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a retry-after in seconds, and ignores a date', async () => {
|
||||
const answer = (value: string) =>
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(500, {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'platform-error',
|
||||
'retry-after': value,
|
||||
}),
|
||||
}).send(CALL);
|
||||
|
||||
expect((await answer('30')).retryAfterMs).toBe(30_000);
|
||||
expect(
|
||||
(await answer('Wed, 21 Oct 2015 07:28:00 GMT')).retryAfterMs,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces the runtime`s own error header alongside its status', async () => {
|
||||
@@ -176,6 +212,97 @@ describe('DispatcherInvokeTransport', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DispatcherInvokeTransport with every connection busy', () => {
|
||||
/** A fetch that answers 200 to one call at a time, when told to. */
|
||||
const heldFetch = () => {
|
||||
const held: Array<{ answer: () => void; signal: AbortSignal }> = [];
|
||||
const fetchImpl = ((_url: string, init: RequestInit) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const signal = init.signal!;
|
||||
signal.addEventListener('abort', () => reject(signal.reason));
|
||||
held.push({
|
||||
signal,
|
||||
answer: () =>
|
||||
resolve(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
}),
|
||||
),
|
||||
});
|
||||
})) as unknown as FetchImpl;
|
||||
return { fetchImpl, held };
|
||||
};
|
||||
|
||||
/** Every connection taken by a call nobody has answered yet. */
|
||||
const saturated = async () => {
|
||||
const { fetchImpl, held } = heldFetch();
|
||||
const transport = new DispatcherInvokeTransport(
|
||||
'http://dispatcher',
|
||||
's',
|
||||
{ fetchImpl },
|
||||
);
|
||||
const busy = Array.from({ length: INVOKE_CONNECTIONS }, () =>
|
||||
transport.send(CALL),
|
||||
);
|
||||
await vi.waitFor(() => expect(held).toHaveLength(INVOKE_CONNECTIONS));
|
||||
const answerAll = async () => {
|
||||
for (const call of held) call.answer();
|
||||
await Promise.all(busy);
|
||||
};
|
||||
return { transport, held, answerAll };
|
||||
};
|
||||
|
||||
it('waits for a free connection, then gives the run its whole timeout', async () => {
|
||||
const { transport, held, answerAll } = await saturated();
|
||||
|
||||
const waiting = transport.send({ ...CALL, timeoutMs: 400 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(held).toHaveLength(INVOKE_CONNECTIONS);
|
||||
|
||||
held[0].answer();
|
||||
await vi.waitFor(() =>
|
||||
expect(held).toHaveLength(INVOKE_CONNECTIONS + 1),
|
||||
);
|
||||
// Past 400 ms from when the call was made, not from when it got its
|
||||
// connection.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(held[INVOKE_CONNECTIONS].signal.aborted).toBe(false);
|
||||
|
||||
await answerAll();
|
||||
await expect(waiting).resolves.toMatchObject({ status: 200 });
|
||||
});
|
||||
|
||||
it('fails a call that never got a connection as Puter`s, not the handler`s', async () => {
|
||||
const { transport, held, answerAll } = await saturated();
|
||||
|
||||
await expect(
|
||||
transport.send({ ...CALL, timeoutMs: 40 }),
|
||||
).resolves.toEqual({
|
||||
status: null,
|
||||
error: 'no free connection to the events dispatcher',
|
||||
});
|
||||
|
||||
const client = new EventsWorkerInvokerClient({
|
||||
events: { invokeTimeoutMs: 40 },
|
||||
} as unknown as IConfig);
|
||||
client.setTransport(transport);
|
||||
await expect(client.invoke(REQUEST)).resolves.toMatchObject({
|
||||
outcome: 'unavailable',
|
||||
});
|
||||
expect(held).toHaveLength(INVOKE_CONNECTIONS);
|
||||
|
||||
// The slots come back once the calls holding them are answered.
|
||||
await answerAll();
|
||||
const after = transport.send(CALL);
|
||||
await vi.waitFor(() =>
|
||||
expect(held).toHaveLength(INVOKE_CONNECTIONS + 1),
|
||||
);
|
||||
held[INVOKE_CONNECTIONS].answer();
|
||||
await expect(after).resolves.toMatchObject({ status: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
const REQUEST: WorkerInvokeRequest = {
|
||||
script: 'evw-test',
|
||||
appUid: 'app-test',
|
||||
@@ -275,7 +402,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'deploy: stale',
|
||||
});
|
||||
@@ -300,7 +427,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
expect(handlerCalls).toBe(1);
|
||||
// The first send plus the one retry — never a third.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(result.outcome).toBe('retriable');
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
expect(result.status).toBeNull();
|
||||
});
|
||||
|
||||
@@ -317,7 +444,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'dispatcher: missing (404)',
|
||||
});
|
||||
@@ -342,7 +469,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
|
||||
expect(handlerCalled).toBe(false);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'dispatcher: forbidden (403)',
|
||||
});
|
||||
@@ -402,9 +529,205 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(deployCalls).toHaveLength(0);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'dispatcher: deploy-timeout (503)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('a script still bound to a rotated key', () => {
|
||||
/** Answers `bad-key` until it sees the deployed header, then settles. */
|
||||
const badKeyUntilDeployedFetch = (
|
||||
headersSeen: Array<Record<string, string>>,
|
||||
): FetchImpl =>
|
||||
(async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
headersSeen.push(headers);
|
||||
return headers[EVENTS_DEPLOYED_HEADER] === '1'
|
||||
? new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
})
|
||||
: new Response(null, {
|
||||
status: 500,
|
||||
headers: {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'bad-key',
|
||||
},
|
||||
});
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
const clientWith = (fetchImpl: FetchImpl) => {
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
return client;
|
||||
};
|
||||
|
||||
it('is redeployed, then retried once with the deployed header', async () => {
|
||||
const headersSeen: Array<Record<string, string>> = [];
|
||||
const client = clientWith(badKeyUntilDeployedFetch(headersSeen));
|
||||
const deploys: Array<[string, string]> = [];
|
||||
client.setMissHandler(async (appUid, script) => {
|
||||
deploys.push([appUid, script]);
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(deploys).toEqual([[REQUEST.appUid, REQUEST.script]]);
|
||||
expect(headersSeen).toHaveLength(2);
|
||||
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
});
|
||||
|
||||
it('backs off, uncounted, when the redeploy is throttled', async () => {
|
||||
const headersSeen: Array<Record<string, string>> = [];
|
||||
const client = clientWith(badKeyUntilDeployedFetch(headersSeen));
|
||||
client.setMissHandler(async () => 'throttled');
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(headersSeen).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'deploy: throttled',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops after the one retry when the old key is still being served', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = clientWith((async () => {
|
||||
calls.push(null);
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
headers: {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'bad-key',
|
||||
},
|
||||
});
|
||||
}) as unknown as FetchImpl);
|
||||
let deploys = 0;
|
||||
client.setMissHandler(async () => {
|
||||
deploys++;
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(deploys).toBe(1);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('deploys nothing for a handler`s own failure', async () => {
|
||||
const client = clientWith(
|
||||
stubFetch(500, {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'handler-threw',
|
||||
}),
|
||||
);
|
||||
let deploys = 0;
|
||||
client.setMissHandler(async () => {
|
||||
deploys++;
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
expect((await client.invoke(REQUEST)).outcome).toBe('retriable');
|
||||
expect(deploys).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a deploy that was throttled as Puter`s failure', async () => {
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: alwaysMissingFetch([]),
|
||||
}),
|
||||
);
|
||||
client.setMissHandler(async () => 'throttled');
|
||||
|
||||
expect((await client.invoke(REQUEST)).outcome).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose failure an invocation was', () => {
|
||||
const invokeWith = (fetchImpl: FetchImpl, timeoutMs?: number) => {
|
||||
const client = new EventsWorkerInvokerClient({
|
||||
events: timeoutMs ? { invokeTimeoutMs: timeoutMs } : {},
|
||||
} as unknown as IConfig);
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
return client.invoke(REQUEST);
|
||||
};
|
||||
|
||||
it('counts a run past the timeout against the handler', async () => {
|
||||
expect((await invokeWith(hangingFetch, 10)).outcome).toBe('retriable');
|
||||
});
|
||||
|
||||
it('puts a request that never got an answer on Puter', async () => {
|
||||
expect((await invokeWith(refusedFetch)).outcome).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('puts an unmarked 5xx, 429 or 4xx on Puter', async () => {
|
||||
for (const status of [500, 502, 429, 404]) {
|
||||
const result = await invokeWith(stubFetch(status));
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
status,
|
||||
error: `unmarked ${status}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('puts a handler failed by a Puter API call on Puter, with its wait', async () => {
|
||||
const result = await invokeWith(
|
||||
stubFetch(500, {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'platform-error',
|
||||
'retry-after': '20',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
outcome: 'unavailable',
|
||||
status: 500,
|
||||
error: 'platform-error',
|
||||
retryAfterMs: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers unavailable with no dispatcher configured', async () => {
|
||||
const result = await makeClient().invoke(REQUEST);
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcomeForStatus', () => {
|
||||
it.each([
|
||||
[200, true, undefined, 'settled'],
|
||||
[204, false, undefined, 'settled'],
|
||||
[400, true, 'handler-terminal', 'terminal'],
|
||||
[404, true, 'unknown-handler', 'terminal'],
|
||||
[500, true, 'handler-threw', 'retriable'],
|
||||
[500, true, 'handler-broken', 'retriable'],
|
||||
[429, true, undefined, 'retriable'],
|
||||
[500, true, 'bad-key', 'unavailable'],
|
||||
[500, true, 'no-token', 'unavailable'],
|
||||
[500, true, 'platform-error', 'unavailable'],
|
||||
[500, false, undefined, 'unavailable'],
|
||||
[429, false, undefined, 'unavailable'],
|
||||
[403, false, undefined, 'unavailable'],
|
||||
] as const)(
|
||||
'reads %i (handled: %s, %s) as %s',
|
||||
(status, handled, errorCode, expected) => {
|
||||
expect(outcomeForStatus(status, handled, errorCode)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -68,6 +68,23 @@ export const EVENTS_HANDLED_HEADER = 'x-puter-events-handled';
|
||||
/** Machine-readable reason on the runtime's own failure answers. */
|
||||
export const EVENTS_ERROR_HEADER = 'x-puter-events-error';
|
||||
|
||||
/**
|
||||
* The runtime's answer to a key it does not hold: the script was deployed under
|
||||
* a secret that has since rotated, and redeploying it binds the current key.
|
||||
*/
|
||||
const BAD_KEY_ERROR = 'bad-key';
|
||||
|
||||
/**
|
||||
* Runtime failure answers that are Puter's fault rather than the handler's: a
|
||||
* key the script no longer matches, no token to run as, or a handler that
|
||||
* failed because a Puter API call it made did.
|
||||
*/
|
||||
const PLATFORM_ERROR_CODES = new Set([
|
||||
BAD_KEY_ERROR,
|
||||
'no-token',
|
||||
'platform-error',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Set on the retry after this backend deploys a missing script itself, so the
|
||||
* dispatcher skips its own deploy-on-miss callback and negative cache and goes
|
||||
@@ -106,12 +123,19 @@ export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Connections held open to the dispatcher, so a busy fleet is not
|
||||
* re-handshaking. The 30s invoke abort starts when a request is queued, not
|
||||
* when it gets a connection, so a saturated pool shows up as timeouts.
|
||||
* re-handshaking. Also how many calls one transport makes at once: a call past
|
||||
* that waits for a free one before its invoke timeout starts.
|
||||
*/
|
||||
const INVOKE_CONNECTIONS = 256;
|
||||
export const INVOKE_CONNECTIONS = 256;
|
||||
const INVOKE_KEEP_ALIVE_MS = 30_000;
|
||||
|
||||
/**
|
||||
* A call waits at most this share of its invoke timeout for a connection, so
|
||||
* the wait and the run together stay inside the worker lease (twice the
|
||||
* timeout). Past it the call fails as Puter's, not the handler's.
|
||||
*/
|
||||
const CONNECTION_WAIT_SHARE = 0.5;
|
||||
|
||||
/** One invocation, as the events service hands it over. */
|
||||
export interface WorkerInvokeRequest {
|
||||
/** Script the app's current handler set deploys as. */
|
||||
@@ -139,27 +163,38 @@ export interface EventsInvokeCall {
|
||||
deployed?: boolean;
|
||||
}
|
||||
|
||||
/** What one attempt at delivering an invocation came back with. */
|
||||
export interface EventsInvokeAnswer {
|
||||
/** `null` when no answer came back. */
|
||||
status: number | null;
|
||||
/** Set only when the answer provably came from the script. */
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
/** Set only by the dispatcher transport, naming its own failure. */
|
||||
dispatchReason?: string;
|
||||
/** No answer within the invoke timeout. */
|
||||
timedOut?: boolean;
|
||||
/** From a `retry-after` header in seconds. */
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers an invocation. `status` null means nothing ran (retriable);
|
||||
* `handled` is set only when the answer provably came from the script, so an
|
||||
* unmarked 4xx is not a handler's refusal.
|
||||
* Delivers an invocation. `handled` is set only when the answer provably came
|
||||
* from the script, so an unmarked 4xx is not a handler's refusal.
|
||||
*/
|
||||
export interface EventsInvokeTransport {
|
||||
send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
/** Set only by the dispatcher transport, naming its own failure. */
|
||||
dispatchReason?: string;
|
||||
}>;
|
||||
send(call: EventsInvokeCall): Promise<EventsInvokeAnswer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `settled`: handler accepted (2xx). `terminal`: handler refused (marked 4xx),
|
||||
* not retried. `retriable`: no handler answer (5xx, 429, timeout, transport
|
||||
* failure, unmarked 4xx).
|
||||
* not retried. `retriable`: the handler failed or ran past the timeout.
|
||||
* `unavailable`: Puter could not start or finish the run (transport, dispatch
|
||||
* and deploy failures, unmarked answers, a Puter API failure inside the
|
||||
* handler); retried like `retriable` but never the handler's fault.
|
||||
*/
|
||||
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
|
||||
export type WorkerInvokeOutcome =
|
||||
'settled' | 'terminal' | 'retriable' | 'unavailable';
|
||||
|
||||
export interface WorkerInvokeResult {
|
||||
outcome: WorkerInvokeOutcome;
|
||||
@@ -167,6 +202,8 @@ export interface WorkerInvokeResult {
|
||||
status: number | null;
|
||||
/** Why it could not answer, when nothing did. */
|
||||
error?: string;
|
||||
/** How long the answer asked to be left alone. */
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
const NO_TRANSPORT =
|
||||
@@ -187,8 +224,8 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
/**
|
||||
* Deploys a script the dispatcher reports missing, in place of the
|
||||
* rehydrate callback — for the case where that callback lands on a backend
|
||||
* other than this one. Set once; `invoke()` calls it at most once per
|
||||
* delivery and never loops.
|
||||
* other than this one — or one still bound to a rotated key. Set once;
|
||||
* `invoke()` calls it at most once per delivery and never loops.
|
||||
*/
|
||||
setMissHandler(handler: WorkerMissHandler): void {
|
||||
this.#missHandler = handler;
|
||||
@@ -202,7 +239,11 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
|
||||
const transport = this.#pickTransport();
|
||||
if (!transport)
|
||||
return { outcome: 'retriable', status: null, error: NO_TRANSPORT };
|
||||
return {
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: NO_TRANSPORT,
|
||||
};
|
||||
|
||||
const call: EventsInvokeCall = {
|
||||
script: request.script,
|
||||
@@ -218,48 +259,50 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
};
|
||||
|
||||
let result = await transport.send(call);
|
||||
if (result.status === null && result.dispatchReason) {
|
||||
if (
|
||||
this.#missHandler &&
|
||||
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
|
||||
) {
|
||||
const outcome = await this.#missHandler(
|
||||
request.appUid,
|
||||
request.script,
|
||||
);
|
||||
if (outcome !== 'deployed')
|
||||
return {
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
};
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
} else if (
|
||||
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
|
||||
) {
|
||||
// No deploy to trigger — just give propagation the one retry
|
||||
// it usually needs, without spending an upload on it.
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
}
|
||||
if (this.#missHandler && selfDeployable(result)) {
|
||||
const outcome = await this.#missHandler(
|
||||
request.appUid,
|
||||
request.script,
|
||||
);
|
||||
if (outcome !== 'deployed')
|
||||
return {
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
};
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
} else if (
|
||||
result.status === null &&
|
||||
result.dispatchReason &&
|
||||
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
|
||||
) {
|
||||
// No deploy to trigger — just give propagation the one retry
|
||||
// it usually needs, without spending an upload on it.
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
}
|
||||
|
||||
const { status, handled, error } = result;
|
||||
const { status, handled, error, timedOut, retryAfterMs } = result;
|
||||
// Past the timeout the handler was most likely still running, which
|
||||
// is its own doing; any other missing answer never reached it.
|
||||
if (status === null)
|
||||
return { outcome: 'retriable', status: null, error };
|
||||
return {
|
||||
outcome: timedOut ? 'retriable' : 'unavailable',
|
||||
status: null,
|
||||
error,
|
||||
};
|
||||
|
||||
const outcome = outcomeForStatus(status, handled === true);
|
||||
// An unmarked 4xx (429 aside — that one is always retriable) is not
|
||||
// the handler's own answer; say so even when the transport did not,
|
||||
// so the reason a delivery kept retrying shows up in the log rather
|
||||
// than reading as an unexplained retry.
|
||||
const unmarkedFourxx =
|
||||
status !== 429 && status >= 400 && status < 500 && !handled;
|
||||
const outcome = outcomeForStatus(status, handled === true, error);
|
||||
// An unmarked failure is not the handler's own answer; say so even
|
||||
// when the transport did not, so the reason a delivery kept retrying
|
||||
// shows up in the log rather than reading as an unexplained retry.
|
||||
const unmarked = status >= 300 && !handled;
|
||||
const reportedError =
|
||||
error ?? (unmarkedFourxx ? 'unmarked 4xx' : undefined);
|
||||
error ?? (unmarked ? `unmarked ${status}` : undefined);
|
||||
return {
|
||||
outcome,
|
||||
status,
|
||||
...(reportedError ? { error: reportedError } : {}),
|
||||
...(retryAfterMs ? { retryAfterMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,6 +334,10 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
readonly #secret: string;
|
||||
readonly #fetchImpl: typeof undiciFetch;
|
||||
#agent: Agent | null = null;
|
||||
/** Calls holding a connection slot. */
|
||||
#active = 0;
|
||||
/** Calls waiting for one, longest-waiting first. */
|
||||
readonly #waiting = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
dispatcherUrl: string,
|
||||
@@ -307,12 +354,14 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
this.#agent = null;
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
dispatchReason?: string;
|
||||
}> {
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
// No `timedOut`: the handler never got this call, so the wait is not
|
||||
// its failure.
|
||||
if (!(await this.#acquire(call.timeoutMs * CONNECTION_WAIT_SHARE)))
|
||||
return {
|
||||
status: null,
|
||||
error: 'no free connection to the events dispatcher',
|
||||
};
|
||||
try {
|
||||
// `new URL(path, base)` resolves an absolute path against the
|
||||
// base's origin, dropping any path prefix `base` already carries.
|
||||
@@ -347,21 +396,45 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
dispatchReason: dispatchError,
|
||||
};
|
||||
|
||||
const handled = response.headers.get(EVENTS_HANDLED_HEADER) === '1';
|
||||
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
|
||||
return {
|
||||
status: response.status,
|
||||
handled,
|
||||
...(errorHeader ? { error: errorHeader } : {}),
|
||||
};
|
||||
return answerFrom(response);
|
||||
} catch (err) {
|
||||
return {
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
return failedAnswer(err);
|
||||
} finally {
|
||||
this.#release();
|
||||
}
|
||||
}
|
||||
|
||||
/** A connection slot, or false once `waitMs` passes without one. */
|
||||
#acquire(waitMs: number): Promise<boolean> {
|
||||
if (this.#active < INVOKE_CONNECTIONS) {
|
||||
this.#active++;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const granted = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
this.#waiting.delete(granted);
|
||||
resolve(false);
|
||||
}, waitMs);
|
||||
timer.unref?.();
|
||||
this.#waiting.add(granted);
|
||||
});
|
||||
}
|
||||
|
||||
/** Hand the slot to the longest waiter, or give it back. */
|
||||
#release(): void {
|
||||
const next = this.#waiting.values().next().value;
|
||||
if (next) {
|
||||
this.#waiting.delete(next);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
this.#active--;
|
||||
}
|
||||
|
||||
/** One pool per process, built on first use and closed with the server. */
|
||||
#dispatcherFor(): Agent {
|
||||
this.#agent ??= new Agent({
|
||||
@@ -373,18 +446,61 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether deploying the script is what fixes this answer. */
|
||||
const selfDeployable = (result: EventsInvokeAnswer): boolean =>
|
||||
result.status === null
|
||||
? result.dispatchReason !== undefined &&
|
||||
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
|
||||
: result.handled === true && result.error === BAD_KEY_ERROR;
|
||||
|
||||
/** What a script's answer says, read the same way by every transport. */
|
||||
export const answerFrom = (response: {
|
||||
status: number;
|
||||
headers: { get(name: string): string | null };
|
||||
}): EventsInvokeAnswer => {
|
||||
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
|
||||
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'));
|
||||
return {
|
||||
status: response.status,
|
||||
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
|
||||
...(errorHeader ? { error: errorHeader } : {}),
|
||||
...(retryAfterMs ? { retryAfterMs } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/** An attempt that threw before any answer came back. */
|
||||
export const failedAnswer = (err: unknown): EventsInvokeAnswer => ({
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
// What `AbortSignal.timeout` rejects with.
|
||||
...((err as { name?: unknown } | null)?.name === 'TimeoutError'
|
||||
? { timedOut: true }
|
||||
: {}),
|
||||
});
|
||||
|
||||
/** Whole seconds only; an HTTP-date form reads as absent. */
|
||||
const parseRetryAfterMs = (value: string | null): number => {
|
||||
const seconds = Number(value);
|
||||
return value && Number.isFinite(seconds) && seconds > 0
|
||||
? seconds * 1000
|
||||
: 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 2xx took it regardless. 429 is always "not now". Any other 4xx is a refusal
|
||||
* only when `handled` says the answer is provably the script's own — an
|
||||
* unmarked 4xx came from something between here and the handler.
|
||||
* 2xx took it regardless. Anything else not marked as the script's own answer
|
||||
* came from something between here and the handler. A marked answer is a
|
||||
* refusal when it is a 4xx other than 429, Puter's failure when the runtime
|
||||
* names one, and the handler's failure otherwise.
|
||||
*/
|
||||
export const outcomeForStatus = (
|
||||
status: number,
|
||||
handled: boolean,
|
||||
errorCode?: string,
|
||||
): WorkerInvokeOutcome => {
|
||||
if (status >= 200 && status < 300) return 'settled';
|
||||
if (!handled) return 'unavailable';
|
||||
if (errorCode && PLATFORM_ERROR_CODES.has(errorCode)) return 'unavailable';
|
||||
if (status === 429) return 'retriable';
|
||||
if (status >= 400 && status < 500)
|
||||
return handled ? 'terminal' : 'retriable';
|
||||
if (status >= 400 && status < 500) return 'terminal';
|
||||
return 'retriable';
|
||||
};
|
||||
@@ -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,
|
||||
@@ -1753,6 +1757,40 @@ describe('AuthController.handleLoginOtp + handleLoginRecoveryCode', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('refuses the sign-in while the used-token store is down', async () => {
|
||||
const { user, secret, otpToken } = await startOtpLogin();
|
||||
const token = await otpToken();
|
||||
|
||||
const { default: backend } = listConfiguredRateLimitBackends();
|
||||
configureRateLimit({
|
||||
default: 'redis',
|
||||
redis: {
|
||||
multi: () => {
|
||||
throw new Error('store down');
|
||||
},
|
||||
},
|
||||
});
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
try {
|
||||
await expect(
|
||||
controller.handleLoginOtp(
|
||||
makeReq({ token, code: liveTotp(user.username, secret) }),
|
||||
res,
|
||||
),
|
||||
).rejects.toThrow('store down');
|
||||
} finally {
|
||||
logged.mockRestore();
|
||||
configureRateLimit({
|
||||
default: backend,
|
||||
redis: server.clients.redis,
|
||||
kv: server.stores.kv,
|
||||
metering: server.services.metering,
|
||||
});
|
||||
}
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a wrong code leaves the token usable for a retry', async () => {
|
||||
const { user, secret, otpToken } = await startOtpLogin();
|
||||
const token = await otpToken();
|
||||
@@ -7687,6 +7725,46 @@ describe('AuthController permission revokes', () => {
|
||||
);
|
||||
expect(res.body).toEqual({});
|
||||
});
|
||||
|
||||
it('revoke-user-user: refuses a key-value share grant and leaves it standing', async () => {
|
||||
const { actor: ownerActor, user: owner } = await makeUserAndActor();
|
||||
const { user: holder } = await makeUserAndActor();
|
||||
const permissions = ['kv-share', 'kv-share-read', 'kv-share-write'].map(
|
||||
(root) => `${root}:${owner.uuid}:os-global:notes:${uuidv4()}`,
|
||||
);
|
||||
for (const permission of permissions)
|
||||
await server.stores.permission.upsertUserUserPerm(
|
||||
holder.id,
|
||||
owner.id,
|
||||
permission,
|
||||
{},
|
||||
);
|
||||
|
||||
for (const permission of permissions)
|
||||
await expect(
|
||||
inCtx(ownerActor, () =>
|
||||
controller.handleRevokeUserUser(
|
||||
makeReq(
|
||||
{ target_username: holder.username, permission },
|
||||
{ actor: ownerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('puter.kv.unshare()'),
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await server.stores.permission.findUserUserPermsByIssuer(
|
||||
holder.id,
|
||||
owner.id,
|
||||
permissions,
|
||||
)
|
||||
).sort(),
|
||||
).toEqual([...permissions].sort());
|
||||
});
|
||||
});
|
||||
|
||||
// ── Permission checks + listing ────────────────────────────────────
|
||||
|
||||
@@ -3304,6 +3304,14 @@ export class AuthController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// Only unsharing retires the handle along with its grant; withdrawing
|
||||
// the grant alone would leave the share listed and dead.
|
||||
if (typeof permission === 'string' && isKvSharePermission(permission))
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Stop a key-value share with `puter.kv.unshare()` (`DELETE /kv/shares/:handle`)',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
await this.services.permission.revokeUserUserPermission(
|
||||
req.actor!,
|
||||
target_username,
|
||||
@@ -3810,8 +3818,9 @@ export class AuthController extends PuterController {
|
||||
|
||||
let app = await this.stores.app.getByUid(app_uid);
|
||||
if (!app && resolvedFromOrigin) {
|
||||
// Hosted-subdomain origins get the site owner stamped as the
|
||||
// app's creator at bootstrap; external origins stay unowned.
|
||||
// Hosted-subdomain and custom-domain origins get the site owner
|
||||
// stamped as the app's creator at bootstrap; external origins
|
||||
// stay unowned.
|
||||
// Canonical origin only, so alternate hosts share one row.
|
||||
const canonicalOrigin =
|
||||
this.services.auth.canonicalizeOrigin(origin);
|
||||
@@ -3827,9 +3836,10 @@ export class AuthController extends PuterController {
|
||||
// An origin's uid is a deterministic uuidv5, so a deleted app
|
||||
// reappears here under the identical uid. Withdraw any cross-app
|
||||
// data grants left pointing at it before this new row can inherit
|
||||
// consent the user gave its predecessor. Only *this* path can reuse
|
||||
// a uid: `AppStore.create` mints a random uuid4, which no deleted
|
||||
// app can ever hold again.
|
||||
// consent the user gave its predecessor. Only `createFromOrigin`
|
||||
// reuses a uid, here or wherever else an origin app is bootstrapped:
|
||||
// `AppStore.create` mints a random uuid4, which no deleted app can
|
||||
// ever hold again.
|
||||
//
|
||||
// Called directly rather than through `app.changed`: the token is
|
||||
// issued below, so this has to be able to stop that, and
|
||||
@@ -3840,6 +3850,9 @@ export class AuthController extends PuterController {
|
||||
app_uid,
|
||||
'uid reused by a new app',
|
||||
);
|
||||
// Key-value shares the predecessor left read as gone already,
|
||||
// being older than this row; this only cleans them up.
|
||||
this.services.kvShare.retireSharesOlderThanApp(app);
|
||||
}
|
||||
if (!app) {
|
||||
throw new HttpError(404, `App ${app_uid} does not exist`, {
|
||||
@@ -4636,13 +4649,19 @@ export class AuthController extends PuterController {
|
||||
await checkRateLimit(`${scope}:${userUid}`, limit, window);
|
||||
}
|
||||
|
||||
/** An `otp-login` JWT completes one sign-in; a second redemption is refused. */
|
||||
/**
|
||||
* An `otp-login` JWT completes one sign-in; a second redemption is refused.
|
||||
* Fails closed: while the store can't say whether the token was used, the
|
||||
* sign-in is refused rather than letting a captured token replay.
|
||||
*/
|
||||
async #consumeOtpLoginToken(token: string): Promise<void> {
|
||||
const digest = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const firstUse = await checkRateLimit(
|
||||
`login-otp-token-used:${digest}`,
|
||||
1,
|
||||
OTP_LOGIN_TOKEN_USED_WINDOW_MS,
|
||||
undefined,
|
||||
{ failOpen: false },
|
||||
);
|
||||
if (!firstUse) {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
|
||||
@@ -104,6 +104,24 @@ export class EventsController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /events/subscriptions/update — replace one subscription's `context`
|
||||
* in place. An id the caller does not hold reads as absent.
|
||||
*/
|
||||
@Post('/subscriptions/update', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const { sub } = await this.services.events.updateDurable(
|
||||
actor,
|
||||
bodyRecord(req),
|
||||
);
|
||||
res.json(sub);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/fetch — what the caller missed, one page at a time.
|
||||
*
|
||||
|
||||
@@ -93,6 +93,18 @@ export interface TieredQuota {
|
||||
*/
|
||||
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET = 50;
|
||||
|
||||
/**
|
||||
* Live subscriptions one user may hold through a share handle someone else
|
||||
* minted for them, across all of their connections.
|
||||
*
|
||||
* Those rows sit in the owner's keyspace, where each one costs a filter
|
||||
* evaluation on the owner's writes. The count also takes in connections that
|
||||
* dropped and haven't been reaped yet, so the cap sits far above what
|
||||
* reconnects leave behind. The owner's own rows through the handle don't
|
||||
* count.
|
||||
*/
|
||||
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_KV_HANDLE = 1000;
|
||||
|
||||
/**
|
||||
* Durable subscriptions one account may hold, across every app.
|
||||
*
|
||||
@@ -125,6 +137,13 @@ export const EVENTS_DURABLE_SUBSCRIPTIONS_MAX =
|
||||
*/
|
||||
export const SUSPENDED_ROW_TTL_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Subscriptions stopped by a revoked grant that one account keeps for `list`.
|
||||
* They hold no quota slot, so past this the oldest are deleted when the account
|
||||
* subscribes again, keeping its rows bounded.
|
||||
*/
|
||||
export const EVENTS_REVOKED_SUBSCRIPTIONS_KEPT = 500;
|
||||
|
||||
/**
|
||||
* Subscribe calls per minute, per user.
|
||||
*
|
||||
@@ -204,17 +223,35 @@ export const EVENTS_KV_HANDLES_PER_APP = tiered(10_000, 2_000, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_HOLDER = 256;
|
||||
|
||||
/**
|
||||
* Recipients one account may share with, per minute and per hour. A batch
|
||||
* spends one per recipient, on top of the per-call mint budget.
|
||||
* 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 -------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -311,8 +348,11 @@ export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
|
||||
*/
|
||||
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
|
||||
|
||||
/** Omit all inline KV values when the matched audience exceeds this count. */
|
||||
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
|
||||
/**
|
||||
* Deliveries of one key-value change that carry its value inline, in the order
|
||||
* the fan-out serves them. Later ones carry the key alone.
|
||||
*/
|
||||
export const EVENTS_KV_VALUE_DELIVERIES_PER_EVENT = 128;
|
||||
|
||||
/**
|
||||
* Largest inline KV value in serialized bytes. Larger values are omitted;
|
||||
@@ -404,7 +444,9 @@ export const EVENTS_REGION_PENDING_CEILING = 1_000_000;
|
||||
// a handler that answers "no" is not. Retrying on a fixed cadence turns one
|
||||
// broken deploy into a permanent load on whatever is failing, so the wait
|
||||
// doubles per attempt up to a ceiling, and a run of failures stops the
|
||||
// subscription rather than retrying it forever.
|
||||
// subscription rather than retrying it forever. A run Puter could not start or
|
||||
// finish backs off the same way but is not the handler's failure, so it never
|
||||
// counts toward that run.
|
||||
|
||||
/** Wait before the first retry of a delivery a handler could not take. */
|
||||
export const EVENTS_RETRY_BASE_MS = 2_000;
|
||||
@@ -413,7 +455,7 @@ export const EVENTS_RETRY_BASE_MS = 2_000;
|
||||
export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Failures in a row before a subscription is suspended. Counted per
|
||||
* Handler failures in a row before a subscription is suspended. Counted per
|
||||
* subscription and reset by the first delivery a handler takes, so an
|
||||
* occasional failure never accumulates into one.
|
||||
*/
|
||||
|
||||
@@ -49,6 +49,8 @@ type Layers = ConstructorParameters<typeof EventsWorkerDeployer>[0];
|
||||
const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
const redis = new MockRedis.Cluster(['redis://localhost:7001']);
|
||||
const created: string[] = [];
|
||||
/** Apps whose handler sources were read, once per read. */
|
||||
const sourceReads: string[] = [];
|
||||
const layers = {
|
||||
config: {
|
||||
events: { workerRuntime: true, internalSecret: 'sekret' },
|
||||
@@ -60,16 +62,20 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
clients: { redis },
|
||||
stores: {
|
||||
eventHandler: {
|
||||
allForApp: async (appUid: string) => [
|
||||
{
|
||||
appUid,
|
||||
name: 'h',
|
||||
source: 'async () => {}',
|
||||
sourceHash: SOURCE_HASH,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
],
|
||||
setForApp: async () => [{ name: 'h', sourceHash: SOURCE_HASH }],
|
||||
allForApp: async (appUid: string) => {
|
||||
sourceReads.push(appUid);
|
||||
return [
|
||||
{
|
||||
appUid,
|
||||
name: 'h',
|
||||
source: 'async () => {}',
|
||||
sourceHash: SOURCE_HASH,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
app: {
|
||||
getByUid: async (appUid: string) => {
|
||||
@@ -104,7 +110,12 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
},
|
||||
},
|
||||
};
|
||||
return { layers: layers as unknown as Layers, created, redis };
|
||||
return {
|
||||
layers: layers as unknown as Layers,
|
||||
created,
|
||||
redis,
|
||||
sourceReads,
|
||||
};
|
||||
};
|
||||
|
||||
describe('EventsWorkerDeployer.ensure', () => {
|
||||
@@ -127,8 +138,10 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
expect(b).toBe('no-owner');
|
||||
});
|
||||
|
||||
it('refuses once the app has deployed past the hourly cap', async () => {
|
||||
const { layers, redis } = makeLayers({ 'app-c': { ownerUserId: 3 } });
|
||||
it('refuses once the app has deployed past the hourly cap, before reading or building any source', async () => {
|
||||
const { layers, redis, sourceReads, created } = makeLayers({
|
||||
'app-c': { ownerUserId: 3 },
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
await redis.set(
|
||||
@@ -137,6 +150,24 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
);
|
||||
|
||||
expect(await deployer.ensure('app-c', SCRIPT)).toBe('throttled');
|
||||
expect(sourceReads).toEqual([]);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
|
||||
it('answers a stale script without spending the hourly cap', async () => {
|
||||
const { layers, redis, sourceReads } = makeLayers({
|
||||
'app-e': { ownerUserId: 5 },
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const stale = eventsWorkerScript(
|
||||
handlerSetHash([{ name: 'h', sourceHash: 'b'.repeat(64) }]),
|
||||
'',
|
||||
);
|
||||
|
||||
expect(await deployer.ensure('app-e', stale)).toBe('stale');
|
||||
expect(sourceReads).toEqual([]);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{app-e}:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('deploys normally under the cap', async () => {
|
||||
|
||||
@@ -26,10 +26,14 @@ import {
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
INTERNAL_DEPLOY_TARGET,
|
||||
} from '../../drivers/workers/WorkerDriver.js';
|
||||
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type {
|
||||
EventsInvokeAnswer,
|
||||
EventsInvokeCall,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import {
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EVENTS_INVOKE_PATH,
|
||||
answerFrom,
|
||||
failedAnswer,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type { puterServices } from '../../services/index.js';
|
||||
import {
|
||||
@@ -37,7 +41,10 @@ import {
|
||||
eventsWorkerScope,
|
||||
eventsWorkerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import { generateEventsWorkerSource } from '../../services/events/workerSource.js';
|
||||
import {
|
||||
generateEventsWorkerSource,
|
||||
handlerSetHash,
|
||||
} from '../../services/events/workerSource.js';
|
||||
import type { puterStores } from '../../stores/index.js';
|
||||
import type { IConfig, LayerInstances } from '../../types.js';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
@@ -113,20 +120,24 @@ export class EventsWorkerDeployer {
|
||||
script: string,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const { stores, drivers } = this.#layers;
|
||||
const scope = eventsWorkerScope(this.#layers.config);
|
||||
|
||||
const handlers = await stores.eventHandler.allForApp(appUid);
|
||||
if (handlers.length === 0) return 'no-handlers';
|
||||
|
||||
const generated = generateEventsWorkerSource(handlers);
|
||||
// Names and hashes are enough to tell a stale request, so the set's
|
||||
// sources are read and built only once the throttle has let it through.
|
||||
const set = await stores.eventHandler.setForApp(appUid);
|
||||
if (set.length === 0) return 'no-handlers';
|
||||
// Two apps that published byte-identical sets share one script, which
|
||||
// is sound: the source is the same, and everything per-app arrives with
|
||||
// the invocation. Whoever deploys first owns it.
|
||||
if (
|
||||
eventsWorkerScript(
|
||||
generated.setHash,
|
||||
eventsWorkerScope(this.#layers.config),
|
||||
) !== script
|
||||
)
|
||||
if (eventsWorkerScript(handlerSetHash(set), scope) !== script)
|
||||
return 'stale';
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
|
||||
|
||||
const handlers = await stores.eventHandler.allForApp(appUid);
|
||||
const generated = generateEventsWorkerSource(handlers);
|
||||
// The set can move between the two reads.
|
||||
if (eventsWorkerScript(generated.setHash, scope) !== script)
|
||||
return 'stale';
|
||||
if (generated.tooLarge) {
|
||||
console.error(
|
||||
@@ -140,8 +151,6 @@ export class EventsWorkerDeployer {
|
||||
generated.broken.join(', '),
|
||||
);
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
|
||||
|
||||
const owner = await this.#owner(appUid);
|
||||
if (!owner) return 'no-owner';
|
||||
|
||||
@@ -289,11 +298,7 @@ export class LocalEventsInvokeTransport {
|
||||
this.#deployer = deployer;
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
const request = (): Request =>
|
||||
new Request(
|
||||
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
|
||||
@@ -323,10 +328,7 @@ export class LocalEventsInvokeTransport {
|
||||
return { status: null, error: 'script not resident' };
|
||||
return retried;
|
||||
} catch (err) {
|
||||
return {
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
return failedAnswer(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +339,7 @@ export class LocalEventsInvokeTransport {
|
||||
async #dispatch(
|
||||
request: Request,
|
||||
timeoutMs: number,
|
||||
): Promise<{ status: number; handled: boolean } | null> {
|
||||
): Promise<EventsInvokeAnswer | null> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
||||
@@ -348,12 +350,10 @@ export class LocalEventsInvokeTransport {
|
||||
this.#services.localworkerservice.dispatchEventsWorker(request),
|
||||
timeout,
|
||||
]);
|
||||
if (response === 'timeout') throw new Error('invocation timed out');
|
||||
if (response === 'timeout')
|
||||
throw new DOMException('invocation timed out', 'TimeoutError');
|
||||
if (response === null) return null;
|
||||
return {
|
||||
status: response.status,
|
||||
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
|
||||
};
|
||||
return answerFrom(response);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
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;
|
||||
@@ -67,6 +68,7 @@ const resetBudgets = async (env: PuterTestEnv) => {
|
||||
const user = await env.server.stores.user.getByUsername(name);
|
||||
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}`);
|
||||
@@ -123,6 +125,7 @@ describe('/kv/shares', () => {
|
||||
'handle',
|
||||
'holder',
|
||||
'label',
|
||||
'notify',
|
||||
'prefix',
|
||||
].sort(),
|
||||
);
|
||||
@@ -131,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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +208,7 @@ describe('/kv/shares', () => {
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Trip',
|
||||
notify: false,
|
||||
createdAt: shared.createdAt,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
@@ -190,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);
|
||||
@@ -622,6 +663,162 @@ describe('/kv/shares', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -69,6 +69,7 @@ const shareBody = (share: KvShareView) => ({
|
||||
appUid: publicAppUid(share.appUid),
|
||||
holder: share.holderUsername,
|
||||
label: share.label,
|
||||
notify: share.notify,
|
||||
createdAt: share.createdAt,
|
||||
});
|
||||
|
||||
@@ -128,8 +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. `recipients` shares it with several
|
||||
* at once and reports each one on its own item.
|
||||
* 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',
|
||||
@@ -145,6 +146,7 @@ export class KvShareController extends PuterController {
|
||||
access: body.access,
|
||||
appUid: body.appUid,
|
||||
label: body.label,
|
||||
notify: body.notify,
|
||||
};
|
||||
const recipients = batchRecipients(body);
|
||||
if (recipients) {
|
||||
@@ -206,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,
|
||||
@@ -234,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.
|
||||
|
||||
@@ -87,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
|
||||
@@ -100,6 +105,7 @@ const KNOWN_KEY_FLAGS: Record<keyof KnownContextFields, true> = {
|
||||
keepDriverSlot: true,
|
||||
upstreamSignal: true,
|
||||
strictUpstreamErrors: true,
|
||||
platformWrite: true,
|
||||
};
|
||||
const KNOWN_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_KEY_FLAGS));
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { isHttpError } from '../HttpError';
|
||||
import {
|
||||
createLargeJsonBodyParser,
|
||||
DEFAULT_JSON_LIMIT_BYTES,
|
||||
} from './largeJsonBody';
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
// A complete request with no socket reads as already consumed to body-parser,
|
||||
// so the parsers call straight through, leaving any preset `body` in place.
|
||||
const makeReq = (
|
||||
bytes: number | undefined,
|
||||
user: string | null = 'u1',
|
||||
body?: unknown,
|
||||
) =>
|
||||
({
|
||||
complete: true,
|
||||
body,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(bytes === undefined ? {} : { 'content-length': String(bytes) }),
|
||||
...(user ? { authorization: user } : {}),
|
||||
},
|
||||
}) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const res = new EventEmitter() as EventEmitter & {
|
||||
setHeader: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
res.setHeader = vi.fn();
|
||||
return res;
|
||||
};
|
||||
|
||||
const authProbe = vi.fn<RequestHandler>((req, _res, next) => {
|
||||
if (req.headers.authorization) {
|
||||
(req as { actor?: unknown }).actor = {
|
||||
user: { uuid: req.headers.authorization },
|
||||
};
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
const run = (mw: RequestHandler, req: Request, res = makeRes()) =>
|
||||
new Promise<{ err: unknown; res: ReturnType<typeof makeRes> }>(
|
||||
(resolve) => {
|
||||
mw(
|
||||
req,
|
||||
res as unknown as Response,
|
||||
((err?: unknown) => resolve({ err, res })) as NextFunction,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const statusOf = (err: unknown) => (isHttpError(err) ? err.statusCode : err);
|
||||
|
||||
describe('createLargeJsonBodyParser', () => {
|
||||
const create = ({
|
||||
budgetBytes = 200 * MiB,
|
||||
perUserBytes = 128 * MiB,
|
||||
accepts,
|
||||
}: {
|
||||
budgetBytes?: number;
|
||||
perUserBytes?: number;
|
||||
accepts?: (req: Request) => boolean;
|
||||
} = {}) =>
|
||||
createLargeJsonBodyParser({
|
||||
authProbe,
|
||||
limitBytes: 128 * MiB,
|
||||
budgetBytes,
|
||||
perUserBytes,
|
||||
accepts,
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no Content-Length', undefined],
|
||||
['a body within the default limit', DEFAULT_JSON_LIMIT_BYTES],
|
||||
['a body over the large limit', 128 * MiB + 1],
|
||||
])('leaves %s to the global parser without probing', async (_, bytes) => {
|
||||
authProbe.mockClear();
|
||||
const { err } = await run(create(), makeReq(bytes));
|
||||
expect(err).toBeUndefined();
|
||||
expect(authProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an unauthenticated large body to the global parser', async () => {
|
||||
const mw = create({ budgetBytes: 60 * MiB });
|
||||
const { err } = await run(mw, makeReq(60 * MiB, null));
|
||||
expect(err).toBeUndefined();
|
||||
// Nothing was charged, so a signed-in caller still fits.
|
||||
expect((await run(mw, makeReq(60 * MiB))).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it('charges nothing for a client that left during the probe', async () => {
|
||||
const mw = create({ budgetBytes: 60 * MiB });
|
||||
const gone = Object.assign(makeRes(), { closed: true });
|
||||
expect((await run(mw, makeReq(60 * MiB), gone)).err).toBeUndefined();
|
||||
expect((await run(mw, makeReq(60 * MiB))).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses with 503 past the budget until a response closes', async () => {
|
||||
const mw = create({ budgetBytes: 130 * MiB });
|
||||
const first = await run(mw, makeReq(60 * MiB, 'u1'));
|
||||
await run(mw, makeReq(60 * MiB, 'u2'));
|
||||
|
||||
const refused = await run(mw, makeReq(60 * MiB, 'u3'));
|
||||
expect(statusOf(refused.err)).toBe(503);
|
||||
// Load shedding is recorded, not paged.
|
||||
expect(isHttpError(refused.err) && refused.err.alarmSeverity).toBe(
|
||||
'info',
|
||||
);
|
||||
expect(refused.res.setHeader).toHaveBeenCalledWith('Retry-After', '5');
|
||||
|
||||
first.res.emit('close');
|
||||
expect((await run(mw, makeReq(60 * MiB, 'u3'))).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses one user's bodies with 429 past their share", async () => {
|
||||
const mw = create({ perUserBytes: 100 * MiB });
|
||||
const first = await run(mw, makeReq(60 * MiB, 'u1'));
|
||||
|
||||
const refused = await run(mw, makeReq(60 * MiB, 'u1'));
|
||||
expect(statusOf(refused.err)).toBe(429);
|
||||
expect(refused.res.setHeader).toHaveBeenCalledWith('Retry-After', '5');
|
||||
// Another user isn't held back by it.
|
||||
expect((await run(mw, makeReq(60 * MiB, 'u2'))).err).toBeUndefined();
|
||||
|
||||
first.res.emit('close');
|
||||
expect((await run(mw, makeReq(60 * MiB, 'u1'))).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses with 413 a parsed body `accepts` turns down', async () => {
|
||||
const mw = create({
|
||||
accepts: (req) => (req.body as { ok?: boolean }).ok === true,
|
||||
});
|
||||
const refused = await run(mw, makeReq(60 * MiB, 'u1', { ok: false }));
|
||||
expect(statusOf(refused.err)).toBe(413);
|
||||
expect(
|
||||
(await run(mw, makeReq(60 * MiB, 'u2', { ok: true }))).err,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import express, { type Request, type RequestHandler } from 'express';
|
||||
import { actorUid } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
|
||||
/** The global JSON parser's limit, which applies before auth. */
|
||||
export const DEFAULT_JSON_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
interface LargeJsonBodyOptions {
|
||||
/** The global auth probe; it skips requests that already carry an actor. */
|
||||
authProbe: RequestHandler;
|
||||
limitBytes: number;
|
||||
/** Declared bytes of large bodies a process holds at once. */
|
||||
budgetBytes: number;
|
||||
/** Declared bytes of large bodies one user holds at once. */
|
||||
perUserBytes: number;
|
||||
/** Checked once the body is parsed; a body it refuses gets a 413. */
|
||||
accepts?: (req: Request) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses JSON bodies over the default limit, up to `limitBytes`, once the
|
||||
* caller's header or cookie credential resolves. Unauthenticated requests and
|
||||
* requests without a Content-Length fall through to the global parser and its
|
||||
* limit.
|
||||
*
|
||||
* The parsed body lives as long as the request, so a body's bytes count against
|
||||
* both budgets until its response closes. Over the user's budget gets a 429,
|
||||
* over the process budget a 503.
|
||||
*/
|
||||
export const createLargeJsonBodyParser = ({
|
||||
authProbe,
|
||||
limitBytes,
|
||||
budgetBytes,
|
||||
perUserBytes,
|
||||
accepts,
|
||||
}: LargeJsonBodyOptions): RequestHandler => {
|
||||
const json = express.json({ limit: limitBytes });
|
||||
const textAsJson = express.json({
|
||||
limit: limitBytes,
|
||||
type: (req) =>
|
||||
req.headers['content-type'] === 'text/plain;actually=json',
|
||||
});
|
||||
let inFlightBytes = 0;
|
||||
const inFlightByUser = new Map<string, number>();
|
||||
|
||||
return (req, res, next) => {
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (!(declared > DEFAULT_JSON_LIMIT_BYTES && declared <= limitBytes)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
void authProbe(req, res, () => {
|
||||
// `res.closed`: the client left during the probe, so `close` has
|
||||
// already fired and would never release a charge.
|
||||
if (!req.actor || res.closed) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const user = req.actor.user.uuid ?? actorUid(req.actor);
|
||||
const userBytes = inFlightByUser.get(user) ?? 0;
|
||||
if (userBytes + declared > perUserBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(
|
||||
429,
|
||||
'Too many large requests in progress for this account',
|
||||
{ legacyCode: 'too_many_requests' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inFlightBytes + declared > budgetBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(503, 'Too many large requests in progress', {
|
||||
code: 'large_body_capacity',
|
||||
// Load shedding, not a fault: record it, don't page.
|
||||
alarmSeverity: 'info',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inFlightBytes += declared;
|
||||
inFlightByUser.set(user, userBytes + declared);
|
||||
res.once('close', () => {
|
||||
inFlightBytes -= declared;
|
||||
const left = (inFlightByUser.get(user) ?? 0) - declared;
|
||||
if (left > 0) inFlightByUser.set(user, left);
|
||||
else inFlightByUser.delete(user);
|
||||
});
|
||||
json(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
textAsJson(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
if (accepts && !accepts(req)) {
|
||||
// The same answer the default limit gives.
|
||||
next(
|
||||
new HttpError(413, 'request entity too large', {
|
||||
legacyCode: 'client_bad_request',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -84,6 +84,10 @@ export function sweepMemoryWindows() {
|
||||
)
|
||||
memoryWindows.delete(k);
|
||||
}
|
||||
for (const [k, entry] of memoryCounters) {
|
||||
if (entry.index < Math.floor(now / entry.windowMs) - 1)
|
||||
memoryCounters.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
@@ -155,6 +159,97 @@ async function peekMemory(key, limit, windowMs) {
|
||||
return timestamps.length < limit;
|
||||
}
|
||||
|
||||
// -- Counted claims --------------------------------------------------
|
||||
//
|
||||
// `claim` keeps a member per unit, which is fine for a few units and not for
|
||||
// thousands. The counted form keeps two counters per key, the current fixed
|
||||
// window and the one before it, and slides by weighting the previous one by
|
||||
// how much of it the window still covers. It can be off by a little at window
|
||||
// edges, and its buckets are separate from the other forms'.
|
||||
|
||||
/** The sliding-window estimate from the two fixed windows it overlaps. */
|
||||
const slidingCount = (previous, current, now, windowMs) =>
|
||||
previous * (1 - (now % windowMs) / windowMs) + current;
|
||||
|
||||
/** How many of `units` a counted claim that left `used` spent may keep. */
|
||||
const countedAdmits = (used, limit, units) =>
|
||||
units - Math.min(units, Math.max(0, Math.ceil(used - limit)));
|
||||
|
||||
/** Key → `{ index, current, previous, windowMs }`, swept with the windows. */
|
||||
const memoryCounters = new Map();
|
||||
|
||||
async function claimCountMemory(key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
const index = Math.floor(now / windowMs);
|
||||
let entry = memoryCounters.get(key);
|
||||
if (!entry) {
|
||||
if (memoryCounters.size >= MEMORY_MAX_KEYS) {
|
||||
memoryCounters.delete(memoryCounters.keys().next().value);
|
||||
}
|
||||
entry = { index, current: 0, previous: 0, windowMs };
|
||||
memoryCounters.set(key, entry);
|
||||
} else if (entry.index !== index) {
|
||||
entry.previous = entry.index === index - 1 ? entry.current : 0;
|
||||
entry.current = 0;
|
||||
entry.index = index;
|
||||
}
|
||||
entry.windowMs = windowMs;
|
||||
const used = slidingCount(entry.previous, entry.current, now, windowMs);
|
||||
const admitted = countedAdmits(used + units, limit, units);
|
||||
entry.current += admitted;
|
||||
return admitted;
|
||||
}
|
||||
|
||||
async function claimCountRedis(redis, key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
const index = Math.floor(now / windowMs);
|
||||
// The hash tag keeps both windows in one slot, so one MULTI reads both.
|
||||
const current = `ratec:{${key}}:${index}`;
|
||||
const results = await redis
|
||||
.multi()
|
||||
.incrby(current, units)
|
||||
.pexpire(current, 2 * windowMs)
|
||||
.get(`ratec:{${key}}:${index - 1}`)
|
||||
.exec();
|
||||
const used = slidingCount(
|
||||
Number(multiResult(results, 2) ?? 0),
|
||||
Number(multiResult(results, 0)),
|
||||
now,
|
||||
windowMs,
|
||||
);
|
||||
const admitted = countedAdmits(used, limit, units);
|
||||
if (admitted < units) await redis.decrby(current, units - admitted);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
async function claimCountKv(kv, key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
const index = Math.floor(now / windowMs);
|
||||
const current = `ratec:${key}:${index}`;
|
||||
const [counted, previous] = await Promise.all([
|
||||
kv.incr({
|
||||
key: current,
|
||||
pathAndAmountMap: { '': units },
|
||||
expireAt: Math.ceil((now + 2 * windowMs) / 1000),
|
||||
}),
|
||||
kv.get({ key: `ratec:${key}:${index - 1}` }),
|
||||
]);
|
||||
const used = slidingCount(
|
||||
Number(previous.res ?? 0),
|
||||
Number(counted.res),
|
||||
now,
|
||||
windowMs,
|
||||
);
|
||||
const admitted = countedAdmits(used, limit, units);
|
||||
if (admitted < units) {
|
||||
await kv.decr({
|
||||
key: current,
|
||||
pathAndAmountMap: { '': units - admitted },
|
||||
});
|
||||
}
|
||||
return admitted;
|
||||
}
|
||||
|
||||
// -- Redis backend ---------------------------------------------------
|
||||
|
||||
// ioredis MULTI/EXEC reports per-command failures inside the exec() result
|
||||
@@ -475,6 +570,10 @@ function instrumentBackendPair(name, pair) {
|
||||
withSpan('rate_limit.claim', attrs, () =>
|
||||
pair.claim(key, limit, windowMs, units),
|
||||
),
|
||||
count: (key, limit, windowMs, units) =>
|
||||
withSpan('rate_limit.count', attrs, () =>
|
||||
pair.count(key, limit, windowMs, units),
|
||||
),
|
||||
acquire: (key, limit) =>
|
||||
withSpan('rate_limit.acquire', attrs, () =>
|
||||
pair.acquire(key, limit),
|
||||
@@ -486,6 +585,7 @@ const memoryBackendPair = instrumentBackendPair('memory', {
|
||||
rate: checkMemory,
|
||||
peek: peekMemory,
|
||||
claim: claimMemory,
|
||||
count: claimCountMemory,
|
||||
acquire: acquireMemoryConcurrent,
|
||||
});
|
||||
|
||||
@@ -536,6 +636,8 @@ export function configureRateLimit({
|
||||
peekRedis(redis, key, limit, windowMs),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimRedis(redis, key, limit, windowMs, units),
|
||||
count: (key, limit, windowMs, units) =>
|
||||
claimCountRedis(redis, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -545,6 +647,8 @@ export function configureRateLimit({
|
||||
peek: (key, limit) => peekKv(kv, key, limit),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimKv(kv, key, limit, windowMs, units),
|
||||
count: (key, limit, windowMs, units) =>
|
||||
claimCountKv(kv, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -874,6 +978,32 @@ export async function claimRateLimit(key, limit, windowMs, units, backend) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claimRateLimit` for budgets measured in many units per call: a claim costs
|
||||
* the same however many units it spends (see "Counted claims" above). Its
|
||||
* buckets are its own, so a key is only ever claimed one way. Fails open,
|
||||
* admitting every unit.
|
||||
*/
|
||||
export async function claimCountedRateLimit(
|
||||
key,
|
||||
limit,
|
||||
windowMs,
|
||||
units,
|
||||
backend,
|
||||
) {
|
||||
if (!(units > 0)) return 0;
|
||||
const bk = resolveBackend(backend);
|
||||
try {
|
||||
return await bk.count(key, limit, windowMs, units);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[rate-limit] imperative counted claim failed, failing open:',
|
||||
err,
|
||||
);
|
||||
return units;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge a route-shaped rate-limit spec from inside a handler.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
checkRateLimit,
|
||||
claimCountedRateLimit,
|
||||
claimRateLimit,
|
||||
concurrencyGate,
|
||||
consumeRouteRateLimit,
|
||||
@@ -1487,6 +1488,13 @@ describe('rate + concurrent limiting — kv backend', () => {
|
||||
expect(await claimRateLimit(key, 5, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('claims counted units, admitting only what fits', async () => {
|
||||
const key = uniqueScope();
|
||||
expect(await claimCountedRateLimit(key, 5, 60_000, 3)).toBe(3);
|
||||
expect(await claimCountedRateLimit(key, 5, 60_000, 3)).toBe(2);
|
||||
expect(await claimCountedRateLimit(key, 5, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('claims against a full bucket as full while expired rows are unswept', async () => {
|
||||
const key = uniqueScope();
|
||||
await seedUnswept(`rate:${key}:`, 3);
|
||||
@@ -2010,6 +2018,103 @@ describe('acquireConcurrent (imperative)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimCountedRateLimit', () => {
|
||||
const uniqueKey = () =>
|
||||
`counted-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const WINDOW = 10_000;
|
||||
/** The start of a fixed window, so nothing before it carries in. */
|
||||
const windowStart = (n) => vi.setSystemTime(n * WINDOW);
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['memory', () => configureRateLimit()],
|
||||
[
|
||||
'redis',
|
||||
() =>
|
||||
configureRateLimit({
|
||||
default: 'redis',
|
||||
redis: new RedisMock(),
|
||||
}),
|
||||
],
|
||||
])('%s backend', (_name, configure) => {
|
||||
beforeEach(() => {
|
||||
configure();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
windowStart(1_000);
|
||||
});
|
||||
|
||||
it('admits up to the limit and spends only what fits', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 5, WINDOW, 3)).toBe(3);
|
||||
expect(await claimCountedRateLimit(key, 5, WINDOW, 3)).toBe(2);
|
||||
expect(await claimCountedRateLimit(key, 5, WINDOW, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('spends thousands of units in one claim', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(
|
||||
await claimCountedRateLimit(key, 15_000, WINDOW, 6_000),
|
||||
).toBe(6_000);
|
||||
expect(
|
||||
await claimCountedRateLimit(key, 15_000, WINDOW, 10_000),
|
||||
).toBe(9_000);
|
||||
});
|
||||
|
||||
it('carries the previous window by how much of it is still inside', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 100, WINDOW, 100)).toBe(
|
||||
100,
|
||||
);
|
||||
// A quarter into the next window, 75 of those still count.
|
||||
vi.setSystemTime(1_001 * WINDOW + WINDOW / 4);
|
||||
expect(await claimCountedRateLimit(key, 100, WINDOW, 50)).toBe(25);
|
||||
// Two windows on, nothing carries.
|
||||
windowStart(1_003);
|
||||
expect(await claimCountedRateLimit(key, 100, WINDOW, 100)).toBe(
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps its buckets apart from claimRateLimit', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 2, WINDOW, 2)).toBe(2);
|
||||
expect(await claimRateLimit(key, 2, WINDOW, 2)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('claims nothing for zero units', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 1, WINDOW, 0)).toBe(0);
|
||||
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('fails open, admitting every unit, when the backend throws', async () => {
|
||||
const redis = new RedisMock();
|
||||
redis.multi = () => {
|
||||
throw new Error('redis down');
|
||||
};
|
||||
configureRateLimit({ default: 'redis', redis });
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await claimCountedRateLimit(uniqueKey(), 1, WINDOW, 4)).toBe(4);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('drops a memory counter once its windows have passed', async () => {
|
||||
configureRateLimit();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
windowStart(2_000);
|
||||
const key = uniqueKey();
|
||||
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
|
||||
windowStart(2_002);
|
||||
sweepMemoryWindows();
|
||||
windowStart(2_000);
|
||||
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimRateLimit', () => {
|
||||
const uniqueKey = () => `claim-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ afterAll(async () => {
|
||||
const attemptsFor = async (
|
||||
model: string,
|
||||
thrown: unknown = new Error('upstream down'),
|
||||
extra: { provider?: string; fallback?: boolean } = {},
|
||||
) => {
|
||||
createMock.mockRejectedValue(thrown);
|
||||
let caught: HttpError | undefined;
|
||||
@@ -217,6 +218,7 @@ const attemptsFor = async (
|
||||
driver.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -886,6 +888,59 @@ describe('ChatCompletionDriver duplicate-model fallback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatCompletionDriver fallback opt-out', () => {
|
||||
const SHARED = 'deepseek-v4-pro';
|
||||
|
||||
it('fails on the first route when the caller sets fallback: false', async () => {
|
||||
const attempts = await attemptsFor(SHARED, undefined, {
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
expect(attempts).toHaveLength(1);
|
||||
expect(attempts[0]).toMatchObject({ provider: 'deepseek' });
|
||||
});
|
||||
|
||||
it('tries only the pinned provider', async () => {
|
||||
const attempts = await attemptsFor(SHARED, undefined, {
|
||||
provider: 'openrouter',
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
expect(attempts).toHaveLength(1);
|
||||
expect(attempts[0]).toMatchObject({ provider: 'openrouter' });
|
||||
});
|
||||
|
||||
it('rejects a pinned provider that has no route for the model', async () => {
|
||||
createMock.mockClear();
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model: SHARED,
|
||||
provider: 'claude',
|
||||
fallback: false,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still marks the failed route for the next caller', async () => {
|
||||
const [only] = await attemptsFor(SHARED, undefined, {
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
expect(isRouteUnhealthy(only.provider, only.model)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the chain when fallback is true or unset', async () => {
|
||||
expect(
|
||||
await attemptsFor(SHARED, undefined, { fallback: true }),
|
||||
).toHaveLength(3);
|
||||
expect(await attemptsFor(SHARED)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// Each attempt in a fallback chain is a whole completion at that model's
|
||||
// prices, so each one has to clear the credit gate on its own. The chain used
|
||||
// to re-check with a nominal 1-microcent amount, which any account with a
|
||||
|
||||
@@ -709,6 +709,20 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// A pinned provider without a route for the model normally resolves
|
||||
// to another vendor; with `fallback: false` that is the one thing the
|
||||
// caller asked us not to do.
|
||||
if (
|
||||
args.fallback === false &&
|
||||
intendedProvider &&
|
||||
model.provider !== intendedProvider
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Model ${args.model} is not served by provider ${intendedProvider}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
args.messages = normalizeMediaParts(normalize_messages(args.messages));
|
||||
if (args.tools) {
|
||||
@@ -946,8 +960,15 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const bucketKey = model.id;
|
||||
const tried = new Set([routeId(model.provider!, model.id)]);
|
||||
let lastError: Error | null = e as Error;
|
||||
// With `fallback: false` the caller wants this route's answer or
|
||||
// its error, not a bill for another provider's attempt.
|
||||
const fallbackAllowed = args.fallback !== false;
|
||||
|
||||
while (lastError && attempts.length < MAX_ATTEMPTS) {
|
||||
while (
|
||||
fallbackAllowed &&
|
||||
lastError &&
|
||||
attempts.length < MAX_ATTEMPTS
|
||||
) {
|
||||
const fallback = this.#findFallback(bucketKey, tried);
|
||||
if (!fallback) break;
|
||||
|
||||
|
||||
@@ -809,6 +809,11 @@ describe('Alibaba official catalog', () => {
|
||||
expect(models.some((model) => model.id === 'qwen-image-fixture')).toBe(
|
||||
false,
|
||||
);
|
||||
const fixture = models.find(
|
||||
(model) => model.id === 'qwen-catalog-fixture',
|
||||
)!;
|
||||
expect(fixture.puterId).toBe('alibaba:qwen/qwen-catalog-fixture');
|
||||
expect(modelLookupNames(models)).toContain('qwen-catalog-fixture');
|
||||
const plus = models.find((model) => model.id === 'qwen-plus')!;
|
||||
const original = ALIBABA_MODELS.find(
|
||||
(model) => model.id === 'qwen-plus',
|
||||
@@ -909,6 +914,115 @@ describe('Alibaba official catalog', () => {
|
||||
expect(model.costs).toEqual({ tokens: 1_000_000 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['qwen3.9-plus-2027-01-01', 'alibaba:qwen/qwen3.9-plus-2027-01-01'],
|
||||
['kimi/kimi-k3', 'alibaba:kimi/kimi-k3'],
|
||||
[
|
||||
'deepseek-v4.1-flash-1017',
|
||||
'alibaba:deepseek/deepseek-v4.1-flash-1017',
|
||||
],
|
||||
['glm-5.3-prime', 'alibaba:zhipu/glm-5.3-prime'],
|
||||
['decision-model-preview', 'alibaba:qwen/decision-model-preview'],
|
||||
])(
|
||||
'gives uncurated model %s a puterId in the alibaba:<vendor>/<id> form',
|
||||
(id, puterId) => {
|
||||
const model = mapAlibabaCatalogModel({
|
||||
model: id,
|
||||
capabilities: ['TG'],
|
||||
})!;
|
||||
expect(model.puterId).toBe(puterId);
|
||||
},
|
||||
);
|
||||
|
||||
it('skips a vendor-prefixed spelling of a curated model', () => {
|
||||
const model = mapAlibabaCatalogModel({
|
||||
model: 'zhipu/glm-5.2',
|
||||
capabilities: ['TG', 'Reasoning'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
});
|
||||
expect(model).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'ASR',
|
||||
{
|
||||
capabilities: ['ASR'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Audio'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'rerank',
|
||||
{
|
||||
capabilities: ['TR'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'image generation',
|
||||
{
|
||||
capabilities: ['IG'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text'],
|
||||
response_modality: ['Text', 'Image'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'realtime omni',
|
||||
{
|
||||
capabilities: ['Realtime-Omni'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text', 'Image', 'Audio', 'Video'],
|
||||
response_modality: ['Text', 'Audio'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'TTS',
|
||||
{
|
||||
capabilities: ['TTS'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Audio'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
])('rejects %s models that cannot serve chat completions', (_kind, raw) => {
|
||||
expect(
|
||||
mapAlibabaCatalogModel({
|
||||
model: 'non-chat-fixture',
|
||||
...(raw as AlibabaApiModel),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('admits a vision model', () => {
|
||||
const model = mapAlibabaCatalogModel({
|
||||
model: 'vision-fixture',
|
||||
capabilities: ['VU'],
|
||||
inference_metadata: {
|
||||
request_modality: ['Text', 'Image'],
|
||||
response_modality: ['Text'],
|
||||
},
|
||||
})!;
|
||||
expect(model.modalities?.input).toEqual(['text', 'image']);
|
||||
});
|
||||
|
||||
it('does not admit an uncurated entry without capability or modality data', () => {
|
||||
expect(mapAlibabaCatalogModel({ model: 'mystery-model' })).toBeNull();
|
||||
expect(mapAlibabaCatalogModel({ model: 'qwen-plus' })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('refuses inference with unverified catalog prices', async () => {
|
||||
seedPages();
|
||||
const { provider } = makeProvider();
|
||||
|
||||
@@ -82,6 +82,23 @@ const positiveLimit = (value: unknown): number | undefined =>
|
||||
? value
|
||||
: undefined;
|
||||
|
||||
const chatCapabilities = ['TG', 'Reasoning', 'VU'];
|
||||
|
||||
const curatedPuterIds = new Set(ALIBABA_MODELS.map((model) => model.puterId));
|
||||
|
||||
const inferPuterId = (id: string): string => {
|
||||
if (id.includes('/')) return `alibaba:${id}`;
|
||||
const family = id.split('-')[0];
|
||||
const sibling = ALIBABA_MODELS.find(
|
||||
(model) => model.puterId && model.id.split('-')[0] === family,
|
||||
);
|
||||
// Unprefixed feed ids are Model Studio's own family unless a curated
|
||||
// sibling points at a third-party org.
|
||||
const org =
|
||||
sibling?.puterId?.slice('alibaba:'.length).split('/')[0] ?? 'qwen';
|
||||
return `alibaba:${org}/${id}`;
|
||||
};
|
||||
|
||||
export const mapAlibabaCatalogModel = (
|
||||
raw: AlibabaApiModel,
|
||||
previous?: IChatModel,
|
||||
@@ -90,16 +107,25 @@ export const mapAlibabaCatalogModel = (
|
||||
const id = raw.model.trim();
|
||||
const existing =
|
||||
previous ?? ALIBABA_MODELS.find((model) => model.id === id);
|
||||
const input = raw.inference_metadata?.request_modality;
|
||||
const output = raw.inference_metadata?.response_modality;
|
||||
if (output && !output.includes('Text')) return null;
|
||||
if (
|
||||
!output &&
|
||||
!existing &&
|
||||
!raw.capabilities?.some((capability) =>
|
||||
['TG', 'Reasoning', 'VU'].includes(capability),
|
||||
raw.capabilities &&
|
||||
!raw.capabilities.some((capability) =>
|
||||
chatCapabilities.includes(capability),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (input && !input.includes('Text')) return null;
|
||||
if (output && !output.includes('Text')) return null;
|
||||
if (!raw.capabilities && !input && !output && !existing) return null;
|
||||
let puterId = existing?.puterId;
|
||||
if (!puterId) {
|
||||
puterId = inferPuterId(id);
|
||||
// A feed entry spelling a curated model differently would duplicate
|
||||
// the curated entry's puterId and list the model twice.
|
||||
if (curatedPuterIds.has(puterId)) return null;
|
||||
}
|
||||
const catalogPricing: AlibabaCatalogTier[] = (raw.prices ?? []).map(
|
||||
(tier) => {
|
||||
// Abbreviated ranges do not specify whether k means 1000 or 1024 tokens.
|
||||
@@ -149,6 +175,7 @@ export const mapAlibabaCatalogModel = (
|
||||
return {
|
||||
...existing,
|
||||
id,
|
||||
puterId,
|
||||
name: raw.name ?? existing?.name ?? id,
|
||||
description: raw.description ?? existing?.description,
|
||||
aliases: existing?.aliases,
|
||||
|
||||
@@ -175,6 +175,11 @@ export interface UsageDetails {
|
||||
export interface ICompleteArguments {
|
||||
messages: PuterMessage[];
|
||||
provider?: string;
|
||||
/**
|
||||
* `false` makes the first route's failure final instead of retrying the
|
||||
* request on another provider that serves the same model.
|
||||
*/
|
||||
fallback?: boolean;
|
||||
stream?: boolean;
|
||||
model: string;
|
||||
test_mode?: boolean;
|
||||
|
||||
@@ -39,9 +39,49 @@ describe('estimateTextTokens', () => {
|
||||
const long = estimateTextTokens('hello world '.repeat(100));
|
||||
expect(long).toBeGreaterThan(short);
|
||||
});
|
||||
|
||||
it('counts words the way splitting on \\s+ does', () => {
|
||||
const splitEstimate = (text: string) =>
|
||||
Math.floor(
|
||||
(text.length / 4 + text.split(/\s+/).length * (4 / 3)) / 2,
|
||||
);
|
||||
for (const text of [
|
||||
'a',
|
||||
' a',
|
||||
'a ',
|
||||
' a b\t\nc ',
|
||||
'x y z w
v',
|
||||
'é ü 日本語 テキスト',
|
||||
'\r\n\v\f',
|
||||
]) {
|
||||
expect(estimateTextTokens(text), JSON.stringify(text)).toBe(
|
||||
splitEstimate(text),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives text past the scan budget the word density scanned so far', () => {
|
||||
const text = 'ab cd '.repeat(1000);
|
||||
const budget = { chars: 12, scannedChars: 0, scannedRuns: 0 };
|
||||
// Uniform text extrapolates to the full count.
|
||||
expect(estimateTextTokens(text, budget)).toBe(estimateTextTokens(text));
|
||||
expect(budget.chars).toBe(0);
|
||||
// A later part with no budget left takes the same density.
|
||||
expect(estimateTextTokens(text, budget)).toBe(estimateTextTokens(text));
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimatePromptTokens', () => {
|
||||
it('scans a bounded amount of text per prompt', () => {
|
||||
// Many parts, each under the budget alone, past it together; the
|
||||
// unscanned ones are extrapolated, which is exact for uniform text.
|
||||
const part = 'lorem ipsum dolor '.repeat(30_000);
|
||||
const parts = Array.from({ length: 8 }, () => ({ text: part }));
|
||||
expect(estimatePromptTokens([{ role: 'user', content: parts }])).toBe(
|
||||
8 * estimateTextTokens(part),
|
||||
);
|
||||
});
|
||||
|
||||
it('counts plain string content', () => {
|
||||
const tokens = estimatePromptTokens([
|
||||
{ role: 'user', content: 'a fairly ordinary sentence to price' },
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
* passes every gate.
|
||||
*/
|
||||
|
||||
import { estimateTextTokens } from '../../util/tokenEstimate.js';
|
||||
import {
|
||||
createWordScanBudget,
|
||||
estimateTextTokens,
|
||||
type WordScanBudget,
|
||||
} from '../../util/tokenEstimate.js';
|
||||
|
||||
// Re-exported so existing consumers keep one import site for all estimators.
|
||||
export { estimateTextTokens } from '../../util/tokenEstimate.js';
|
||||
@@ -91,23 +95,25 @@ const attachmentTokens = (value: unknown): number => {
|
||||
* recognises is charged the unmeasured estimate — the alternative is a content
|
||||
* type nobody has taught the gate about arriving for free.
|
||||
*/
|
||||
const partTokens = (part: unknown): number => {
|
||||
if (typeof part === 'string') return estimateTextTokens(part);
|
||||
const partTokens = (part: unknown, budget: WordScanBudget): number => {
|
||||
if (typeof part === 'string') return estimateTextTokens(part, budget);
|
||||
if (!part || typeof part !== 'object') return 0;
|
||||
|
||||
const p = part as ContentPart;
|
||||
|
||||
if (typeof p.text === 'string') return estimateTextTokens(p.text);
|
||||
if (typeof p.text === 'string') return estimateTextTokens(p.text, budget);
|
||||
|
||||
// Model-output shapes replayed as history: Anthropic thinking blocks,
|
||||
// our own streamed `reasoning` chunks, OpenAI refusals. All text — they
|
||||
// must not fall through to the attachment default below, which would
|
||||
// price a few sentences of reasoning like a full-frame image.
|
||||
if (typeof p.thinking === 'string') return estimateTextTokens(p.thinking);
|
||||
if (typeof p.thinking === 'string')
|
||||
return estimateTextTokens(p.thinking, budget);
|
||||
if (typeof p.reasoning === 'string') {
|
||||
return estimateTextTokens(p.reasoning);
|
||||
return estimateTextTokens(p.reasoning, budget);
|
||||
}
|
||||
if (typeof p.refusal === 'string') return estimateTextTokens(p.refusal);
|
||||
if (typeof p.refusal === 'string')
|
||||
return estimateTextTokens(p.refusal, budget);
|
||||
|
||||
// OpenAI-style `{ image_url: { url } }` (or the flattened string form).
|
||||
if (p.image_url) {
|
||||
@@ -136,6 +142,7 @@ const partTokens = (part: unknown): number => {
|
||||
if (p.type === 'tool_use' && p.input !== undefined) {
|
||||
return estimateTextTokens(
|
||||
typeof p.input === 'string' ? p.input : JSON.stringify(p.input),
|
||||
budget,
|
||||
);
|
||||
}
|
||||
if (p.type === 'tool_result') {
|
||||
@@ -143,10 +150,11 @@ const partTokens = (part: unknown): number => {
|
||||
typeof p.content === 'string'
|
||||
? p.content
|
||||
: JSON.stringify(p.content ?? ''),
|
||||
budget,
|
||||
);
|
||||
}
|
||||
if (p.type === 'compaction') {
|
||||
return estimateTextTokens(String(p.encrypted_content ?? ''));
|
||||
return estimateTextTokens(String(p.encrypted_content ?? ''), budget);
|
||||
}
|
||||
|
||||
return UNMEASURED_ATTACHMENT_TOKENS;
|
||||
@@ -164,21 +172,22 @@ const partTokens = (part: unknown): number => {
|
||||
export const estimatePromptTokens = (messages: unknown): number => {
|
||||
if (!Array.isArray(messages)) return 0;
|
||||
|
||||
const budget = createWordScanBudget();
|
||||
let tokens = 0;
|
||||
for (const message of messages) {
|
||||
if (typeof message === 'string') {
|
||||
tokens += estimateTextTokens(message);
|
||||
tokens += estimateTextTokens(message, budget);
|
||||
continue;
|
||||
}
|
||||
if (!message || typeof message !== 'object') continue;
|
||||
|
||||
const content = (message as ContentPart).content;
|
||||
if (typeof content === 'string') {
|
||||
tokens += estimateTextTokens(content);
|
||||
tokens += estimateTextTokens(content, budget);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) tokens += partTokens(part);
|
||||
for (const part of content) tokens += partTokens(part, budget);
|
||||
} else if (content) {
|
||||
tokens += partTokens(content);
|
||||
tokens += partTokens(content, budget);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
|
||||
@@ -32,10 +32,12 @@ import {
|
||||
validateIconDataUrl,
|
||||
} from '../../util/appIcon.js';
|
||||
import { toAppReadView } from '../../util/appView.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import {
|
||||
buildHostedSubdomainIndexUrlCandidates,
|
||||
extractPuterHostedSubdomain,
|
||||
resolveCustomHostSubdomain,
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import {
|
||||
normalizeLimit,
|
||||
@@ -201,30 +203,38 @@ export class AppDriver extends PuterDriver {
|
||||
|
||||
const fields = await this.#validateInput(object, { isCreate: true });
|
||||
|
||||
// Puter-hosted index_url handling. Order matches v1 AppES:
|
||||
// 1. Refuse if the index_url's subdomain isn't owned by this user.
|
||||
// Puter-hosted index_url handling (custom domains included). Order
|
||||
// matches v1 AppES:
|
||||
// 1. Refuse if the index_url's site isn't owned by this user.
|
||||
// 2. Try to merge into an existing row with the same index_url
|
||||
// (origin-bootstrap takeover, or claiming an unowned row).
|
||||
// 3. Otherwise enforce index_url uniqueness so two rows can't
|
||||
// share a hosted URL.
|
||||
// The name lookup runs alongside so the host checks add no round
|
||||
// trip of their own.
|
||||
const nameTaken = awaitLater(this.appStore.existsByName(fields.name));
|
||||
const customSite = await this.#customSiteForIndexUrl(fields.index_url);
|
||||
await this.#ensurePuterSiteSubdomainIsOwned(
|
||||
fields.index_url,
|
||||
actor.user,
|
||||
customSite,
|
||||
);
|
||||
const joinedApp = await this.#maybeJoinOwnedHostedIndexUrlApp({
|
||||
object,
|
||||
options,
|
||||
user: actor.user,
|
||||
customSite,
|
||||
});
|
||||
if (joinedApp) {
|
||||
return joinedApp;
|
||||
}
|
||||
await this.#ensureIndexUrlNotAlreadyInUse({
|
||||
indexUrl: fields.index_url,
|
||||
customSite,
|
||||
});
|
||||
|
||||
// Name conflict handling
|
||||
if (await this.appStore.existsByName(fields.name)) {
|
||||
if (await nameTaken) {
|
||||
if (options?.dedupe_name) {
|
||||
let candidate;
|
||||
let i = 0;
|
||||
@@ -424,13 +434,23 @@ export class AppDriver extends PuterDriver {
|
||||
existing: app,
|
||||
});
|
||||
|
||||
// Started before the index_url checks so they add no round trip.
|
||||
const renaming = !!fields.name && fields.name !== app.name;
|
||||
const nameTaken = renaming
|
||||
? awaitLater(this.appStore.existsByName(fields.name))
|
||||
: null;
|
||||
|
||||
// Puter-hosted index_url handling on update — same flow as create.
|
||||
// #validateInput already dropped an unchanged index_url. Self-app is
|
||||
// excluded from the conflict search via `excludeAppId`.
|
||||
if (fields.index_url) {
|
||||
const customSite = await this.#customSiteForIndexUrl(
|
||||
fields.index_url,
|
||||
);
|
||||
await this.#ensurePuterSiteSubdomainIsOwned(
|
||||
fields.index_url,
|
||||
actor.user,
|
||||
customSite,
|
||||
);
|
||||
const joinedApp = await this.#maybeJoinOwnedHostedIndexUrlApp({
|
||||
object,
|
||||
@@ -438,6 +458,7 @@ export class AppDriver extends PuterDriver {
|
||||
user: actor.user,
|
||||
sourceAppUid: app.uid,
|
||||
excludeAppId: app.id,
|
||||
customSite,
|
||||
});
|
||||
if (joinedApp) {
|
||||
return joinedApp;
|
||||
@@ -445,6 +466,7 @@ export class AppDriver extends PuterDriver {
|
||||
await this.#ensureIndexUrlNotAlreadyInUse({
|
||||
indexUrl: fields.index_url,
|
||||
excludeAppId: app.id,
|
||||
customSite,
|
||||
});
|
||||
if (
|
||||
app.is_private &&
|
||||
@@ -465,14 +487,10 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
// Name conflict check (only if name is changing)
|
||||
if (fields.name && fields.name !== app.name) {
|
||||
if (await this.appStore.existsByName(fields.name)) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'An app with this name already exists',
|
||||
{ legacyCode: 'conflict' },
|
||||
);
|
||||
}
|
||||
if (nameTaken && (await nameTaken)) {
|
||||
throw new HttpError(409, 'An app with this name already exists', {
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
}
|
||||
|
||||
const filetypes = fields.filetype_associations;
|
||||
@@ -863,6 +881,26 @@ export class AppDriver extends PuterDriver {
|
||||
return !!this.#extractPuterHostedSubdomain(indexUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ host, subdomain }` when `indexUrl` is on a custom domain that serves
|
||||
* one of our sites, else null. Such a host gets the hosted-subdomain rules:
|
||||
* owner-only, one app per URL, bootstrap rows merged.
|
||||
*/
|
||||
async #customSiteForIndexUrl(indexUrl) {
|
||||
if (!indexUrl || this.#isPuterHostedIndexUrl(indexUrl)) return null;
|
||||
const parsed = URL.parse(indexUrl);
|
||||
if (parsed?.protocol !== 'https:' && parsed?.protocol !== 'http:') {
|
||||
return null;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase().replace(/\.+$/, '');
|
||||
const subdomain = await resolveCustomHostSubdomain(
|
||||
host,
|
||||
this.config,
|
||||
this.clients.event,
|
||||
);
|
||||
return subdomain ? { host, subdomain } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read normalized origin-alias groups from config. Each group is a deduped
|
||||
* list of lowercased, trimmed bare hosts. Malformed entries are skipped so
|
||||
@@ -942,9 +980,18 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async #findIndexUrlConflictRow({ indexUrl, excludeAppId } = {}) {
|
||||
async #findIndexUrlConflictRow({
|
||||
indexUrl,
|
||||
excludeAppId,
|
||||
customSite,
|
||||
} = {}) {
|
||||
const aliasGroup = this.#findOriginAliasGroupForIndexUrl(indexUrl);
|
||||
if (!this.#isPuterHostedIndexUrl(indexUrl) && !aliasGroup) return null;
|
||||
if (
|
||||
!this.#isPuterHostedIndexUrl(indexUrl) &&
|
||||
!aliasGroup &&
|
||||
!customSite
|
||||
)
|
||||
return null;
|
||||
|
||||
const candidates = new Set(
|
||||
this.#buildEquivalentIndexUrlCandidates(indexUrl),
|
||||
@@ -963,15 +1010,18 @@ export class AppDriver extends PuterDriver {
|
||||
|
||||
// For alias-group hosts, treat the group as a host-level reservation:
|
||||
// any row whose index_url is the root URL of any group member counts
|
||||
// as a conflict, so a single app owns the whole group.
|
||||
if (aliasGroup) {
|
||||
for (const host of aliasGroup) {
|
||||
for (const proto of ['https', 'http']) {
|
||||
const base = `${proto}://${host}`;
|
||||
candidates.add(base);
|
||||
candidates.add(`${base}/`);
|
||||
candidates.add(`${base}/index.html`);
|
||||
}
|
||||
// as a conflict, so a single app owns the whole group. A custom
|
||||
// domain's root is reserved the same way.
|
||||
const reservedHosts = [
|
||||
...(aliasGroup ?? []),
|
||||
...(customSite ? [customSite.host] : []),
|
||||
];
|
||||
for (const host of reservedHosts) {
|
||||
for (const proto of ['https', 'http']) {
|
||||
const base = `${proto}://${host}`;
|
||||
candidates.add(base);
|
||||
candidates.add(`${base}/`);
|
||||
candidates.add(`${base}/index.html`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,10 +1034,15 @@ export class AppDriver extends PuterDriver {
|
||||
});
|
||||
}
|
||||
|
||||
async #ensureIndexUrlNotAlreadyInUse({ indexUrl, excludeAppId } = {}) {
|
||||
async #ensureIndexUrlNotAlreadyInUse({
|
||||
indexUrl,
|
||||
excludeAppId,
|
||||
customSite,
|
||||
} = {}) {
|
||||
const conflictRow = await this.#findIndexUrlConflictRow({
|
||||
indexUrl,
|
||||
excludeAppId,
|
||||
customSite,
|
||||
});
|
||||
if (conflictRow) {
|
||||
throw new HttpError(400, 'App index_url already in use', {
|
||||
@@ -1000,9 +1055,11 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async #ensurePuterSiteSubdomainIsOwned(indexUrl, user) {
|
||||
async #ensurePuterSiteSubdomainIsOwned(indexUrl, user, customSite) {
|
||||
if (!user) return;
|
||||
const subdomain = this.#extractPuterHostedSubdomain(indexUrl);
|
||||
const subdomain =
|
||||
customSite?.subdomain ??
|
||||
this.#extractPuterHostedSubdomain(indexUrl);
|
||||
if (!subdomain) return;
|
||||
|
||||
let row = await this.stores.subdomain.getBySubdomain(subdomain);
|
||||
@@ -1015,10 +1072,19 @@ export class AppDriver extends PuterDriver {
|
||||
});
|
||||
}
|
||||
if (!row || row.user_id !== user.id) {
|
||||
throw new HttpError(400, 'Subdomain not owned by user', {
|
||||
legacyCode: 'subdomain_not_owned',
|
||||
fields: { subdomain },
|
||||
});
|
||||
// A custom domain's site name isn't the caller's to learn.
|
||||
throw new HttpError(
|
||||
400,
|
||||
customSite
|
||||
? 'Domain not owned by user'
|
||||
: 'Subdomain not owned by user',
|
||||
{
|
||||
legacyCode: 'subdomain_not_owned',
|
||||
fields: customSite
|
||||
? { hostname: customSite.host }
|
||||
: { subdomain },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,17 +1197,19 @@ export class AppDriver extends PuterDriver {
|
||||
user,
|
||||
sourceAppUid,
|
||||
excludeAppId,
|
||||
customSite,
|
||||
} = {}) {
|
||||
const indexUrl = object?.index_url;
|
||||
// Alias-group hosts (`app_origin_aliases`) get the same merge
|
||||
// treatment as puter-hosted subdomains. Without this, a bootstrap
|
||||
// stub on a custom domain could never be absorbed — and since
|
||||
// `#findIndexUrlConflictRow` *does* honor alias groups, the
|
||||
// uniqueness check below would hard-reject the owner's own
|
||||
// create/update instead of merging into the stub.
|
||||
// Alias-group hosts (`app_origin_aliases`) and custom domains get the
|
||||
// same merge treatment as puter-hosted subdomains. Without this, a
|
||||
// bootstrap stub on such a host could never be absorbed — and since
|
||||
// `#findIndexUrlConflictRow` *does* honor them, the uniqueness check
|
||||
// below would hard-reject the owner's own create/update instead of
|
||||
// merging into the stub.
|
||||
if (
|
||||
!this.#isPuterHostedIndexUrl(indexUrl) &&
|
||||
!this.#findOriginAliasGroupForIndexUrl(indexUrl)
|
||||
!this.#findOriginAliasGroupForIndexUrl(indexUrl) &&
|
||||
!customSite
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -1149,6 +1217,7 @@ export class AppDriver extends PuterDriver {
|
||||
const conflictRow = await this.#findIndexUrlConflictRow({
|
||||
indexUrl,
|
||||
excludeAppId,
|
||||
customSite,
|
||||
});
|
||||
if (!conflictRow) return null;
|
||||
|
||||
|
||||
@@ -1912,6 +1912,188 @@ describe('AppDriver alias-group index_url merge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── custom-domain index_url rules ───────────────────────────────────
|
||||
//
|
||||
// A host a `site.resolveHost` listener maps to a site gets the hosted
|
||||
// rules: only the site owner may point an app at it, one app per URL, and
|
||||
// creating there absorbs the origin-bootstrap row.
|
||||
|
||||
describe('AppDriver custom-domain index_url', () => {
|
||||
const hosts = new Map<string, string>();
|
||||
let failResolve = false;
|
||||
const resolveHost = (
|
||||
_key: unknown,
|
||||
data: { host: string; result: { subdomain: string | null } },
|
||||
) => {
|
||||
if (failResolve) throw new Error('resolver down');
|
||||
const sub = hosts.get(data.host);
|
||||
if (sub) data.result.subdomain = sub;
|
||||
};
|
||||
const driverConfig = () =>
|
||||
(driver as unknown as { config: Record<string, unknown> }).config;
|
||||
|
||||
beforeAll(() => {
|
||||
driverConfig().custom_domains_enabled = true;
|
||||
server.clients.event.on('site.resolveHost', resolveHost);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete driverConfig().custom_domains_enabled;
|
||||
server.clients.event.off('site.resolveHost', resolveHost);
|
||||
});
|
||||
|
||||
const attachDomain = async (userId: number) => {
|
||||
const subdomain = uniqueName('cd');
|
||||
await server.stores.subdomain.create({ userId, subdomain });
|
||||
const host = `${uniqueName('www')}.custom-domain.test`;
|
||||
hosts.set(host, subdomain);
|
||||
return { host, subdomain };
|
||||
};
|
||||
|
||||
it("refuses an index_url on another user's custom domain", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const { host, subdomain } = await attachDomain(owner.userId);
|
||||
|
||||
const err = await withActor(other.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('cd-squat'),
|
||||
title: 't',
|
||||
index_url: `https://${host}/`,
|
||||
},
|
||||
}),
|
||||
).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'subdomain_not_owned',
|
||||
});
|
||||
expect(JSON.stringify(err)).not.toContain(subdomain);
|
||||
});
|
||||
|
||||
it("refuses repointing an app onto another user's custom domain", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const { host } = await attachDomain(owner.userId);
|
||||
const created = await withActor(other.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('cd-repoint'),
|
||||
title: 't',
|
||||
index_url: uniqueIndexUrl(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
withActor(other.actor, () =>
|
||||
driver.update({
|
||||
uid: created.uid,
|
||||
object: { index_url: `http://${host}/sub/` },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'subdomain_not_owned',
|
||||
});
|
||||
});
|
||||
|
||||
it("absorbs the owner's bootstrap row on create", async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const { host } = await attachDomain(userId);
|
||||
// AuthController stamps the site owner on the bootstrap row.
|
||||
const stubUid = `app-${uuidv4()}`;
|
||||
await server.stores.app.createFromOrigin(stubUid, `https://${host}`, {
|
||||
ownerUserId: userId,
|
||||
});
|
||||
const name = uniqueName('cd-create');
|
||||
|
||||
const result = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: { name, title: 'Mine', index_url: `https://${host}/` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.uid).toBe(stubUid);
|
||||
expect(result.name).toBe(name);
|
||||
expect((await server.stores.app.getByUid(stubUid))?.owner_user_id).toBe(
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps one app per custom-domain URL', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const { host } = await attachDomain(userId);
|
||||
const first = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('cd-first'),
|
||||
title: 't',
|
||||
index_url: `https://${host}/`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('cd-second'),
|
||||
title: 't',
|
||||
index_url: `http://${host}/index.html`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'app_index_url_already_in_use',
|
||||
fields: { app_uid: first.uid },
|
||||
});
|
||||
});
|
||||
|
||||
it('treats every host as external without a resolver (no prod extension)', async () => {
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
const url = uniqueIndexUrl();
|
||||
server.clients.event.off('site.resolveHost', resolveHost);
|
||||
try {
|
||||
for (const { actor } of [a, b]) {
|
||||
await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('cd-none'),
|
||||
title: 't',
|
||||
index_url: url,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
server.clients.event.on('site.resolveHost', resolveHost);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the resolver errors', async () => {
|
||||
const { actor } = await makeUser();
|
||||
failResolve = true;
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('cd-down'),
|
||||
title: 't',
|
||||
index_url: uniqueIndexUrl(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 503 });
|
||||
} finally {
|
||||
failResolve = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── hosted-subdomain ownership check ────────────────────────────────
|
||||
//
|
||||
// `#ensurePuterSiteSubdomainIsOwned` gates puter-hosted index_urls on a
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
kvSharePermission,
|
||||
type KvShareAccess,
|
||||
} from '../../services/kvShare/kvShares.js';
|
||||
import { claimCountedRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { kvNamespace } from '../../stores/systemKv/SystemKVStore.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { openCursor, sealCursor } from '../../util/pagination.js';
|
||||
import type { KVStoreDriver } from './KVStoreDriver.js';
|
||||
import { KV_HANDLE_NAMESPACE_BUDGET } from './limits.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
const GLOBAL = 'os-global';
|
||||
@@ -243,6 +246,36 @@ afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
/**
|
||||
* The handle budget calls into `owner`'s namespace spend from: every holder's,
|
||||
* or with `holder`, that holder's own.
|
||||
*/
|
||||
const budgetKey = (owner: TestUser, holder?: TestUser, appUid = GLOBAL) => {
|
||||
const namespace = `kv:handles:${kvNamespace(owner.uuid, appUid)}`;
|
||||
return holder ? `${namespace}:holder:${holder.id}` : namespace;
|
||||
};
|
||||
|
||||
/** A budget's counters for this window and the one before. */
|
||||
const budgetCounters = (key: string) => {
|
||||
const index = Math.floor(Date.now() / KV_HANDLE_NAMESPACE_BUDGET.window);
|
||||
return [index - 1, index].map((at) => `ratec:{${key}}:${at}`);
|
||||
};
|
||||
|
||||
/** Units spent from a budget since it was last cleared. */
|
||||
const spent = async (key: string) => {
|
||||
let total = 0;
|
||||
for (const counter of budgetCounters(key))
|
||||
total += Number(await env.server.clients.redis.get(counter));
|
||||
return total;
|
||||
};
|
||||
|
||||
const clearBudget = (key: string) =>
|
||||
env.server.clients.redis.del(...budgetCounters(key));
|
||||
|
||||
/** Spend all of a budget, as though other calls had. */
|
||||
const exhaust = (key: string, limit: number) =>
|
||||
claimCountedRateLimit(key, limit, KV_HANDLE_NAMESPACE_BUDGET.window, limit);
|
||||
|
||||
// Every mint spends the 60/min budget, and this file makes more than that.
|
||||
beforeEach(async () => {
|
||||
for (const user of [owner, guest, stranger]) {
|
||||
@@ -250,7 +283,9 @@ beforeEach(async () => {
|
||||
`rate:${EVENTS_KV_HANDLE_LIMIT.scope}:${user.id}`,
|
||||
);
|
||||
await env.server.clients.redis.del(`rate:events:subscribe:${user.id}`);
|
||||
await clearBudget(budgetKey(owner, user));
|
||||
}
|
||||
await clearBudget(budgetKey(owner));
|
||||
});
|
||||
|
||||
describe('a call through a handle', () => {
|
||||
@@ -410,6 +445,17 @@ describe('the level a call needs', () => {
|
||||
legacyCode: 'kv_handle_access_denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps working after the owner stops a legacy share spelled in another case', async () => {
|
||||
const tag = uuidv4().slice(0, 8);
|
||||
const legacy = await legacyShare(`Notes-${tag}:`, 'read');
|
||||
await ownerSet(`notes-${tag}:x:k`, 'v');
|
||||
const { handle } = await share(`notes-${tag}:x:`, 'read');
|
||||
|
||||
await shares().removeKvShare(owner.actor, legacy.handle);
|
||||
|
||||
expect(await guestGet(handle, 'k')).toBe('v');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a call refused before the handle is looked at', () => {
|
||||
@@ -867,11 +913,12 @@ describe('listing through a handle', () => {
|
||||
});
|
||||
|
||||
describe('a private entry in the region', () => {
|
||||
it('is absent to the holder, uncounted, and refuses every write', async () => {
|
||||
it('is absent to the holder, uncounted, and gives nothing away to a write', async () => {
|
||||
const prefix = region('priv');
|
||||
await ownerSet(`${prefix}open`, 1);
|
||||
await ownerSet(`${prefix}secret`, 's', { disableSharing: true });
|
||||
const { handle } = await share(prefix, 'write');
|
||||
const { handle: readOnly } = await share(region('privro'), 'read');
|
||||
|
||||
expect(await guestGet(handle, 'secret')).toBeNull();
|
||||
expect(await guestGet(handle, ['open', 'secret'])).toEqual([1, null]);
|
||||
@@ -886,18 +933,78 @@ describe('a private entry in the region', () => {
|
||||
),
|
||||
).toEqual({ items: ['open'], total: 1 });
|
||||
|
||||
// Deleting it answers as deleting a key that isn't there.
|
||||
expect(
|
||||
await as(guest.actor, (d) =>
|
||||
d.del({ key: 'secret', ...via(handle) }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await as(guest.actor, (d) =>
|
||||
d.del({ key: 'never-written', ...via(handle) }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await as(guest.actor, (d) =>
|
||||
d.remove({ key: 'secret', paths: [''], ...via(handle) }),
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
// Anything else is refused as a call the handle doesn't allow.
|
||||
let levelRefusal: unknown;
|
||||
try {
|
||||
await guestSet(readOnly, 'k', 1);
|
||||
} catch (err) {
|
||||
levelRefusal = err;
|
||||
}
|
||||
const { statusCode, legacyCode, message } = levelRefusal as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(legacyCode).toBe('kv_handle_access_denied');
|
||||
for (const call of [
|
||||
(d: KVStoreDriver) =>
|
||||
d.set({ key: 'secret', value: 'x', ...via(handle) }),
|
||||
(d: KVStoreDriver) => d.del({ key: 'secret', ...via(handle) }),
|
||||
(d: KVStoreDriver) =>
|
||||
d.set({
|
||||
key: 'secret',
|
||||
value: 'x',
|
||||
...via(handle, { onlyIfAbsent: true }),
|
||||
}),
|
||||
(d: KVStoreDriver) =>
|
||||
d.update({
|
||||
key: 'secret',
|
||||
pathAndValueMap: { a: 1 },
|
||||
...via(handle),
|
||||
}),
|
||||
(d: KVStoreDriver) =>
|
||||
d.add({
|
||||
key: 'secret',
|
||||
pathAndValueMap: { list: 1 },
|
||||
...via(handle),
|
||||
}),
|
||||
(d: KVStoreDriver) =>
|
||||
d.remove({ key: 'secret', paths: ['a'], ...via(handle) }),
|
||||
(d: KVStoreDriver) =>
|
||||
d.incr({
|
||||
key: 'secret',
|
||||
pathAndAmountMap: { n: 1 },
|
||||
...via(handle),
|
||||
}),
|
||||
(d: KVStoreDriver) =>
|
||||
d.decr({
|
||||
key: 'secret',
|
||||
pathAndAmountMap: { n: 1 },
|
||||
...via(handle),
|
||||
}),
|
||||
(d: KVStoreDriver) =>
|
||||
d.expire({ key: 'secret', ttl: 60, ...via(handle) }),
|
||||
(d: KVStoreDriver) =>
|
||||
d.expireAt({
|
||||
key: 'secret',
|
||||
timestamp: later(),
|
||||
...via(handle),
|
||||
}),
|
||||
(d: KVStoreDriver) =>
|
||||
d.batchPut({
|
||||
items: [
|
||||
@@ -908,18 +1015,23 @@ describe('a private entry in the region', () => {
|
||||
}),
|
||||
])
|
||||
await expect(as(guest.actor, call)).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
statusCode,
|
||||
legacyCode,
|
||||
message,
|
||||
});
|
||||
|
||||
// A live key that isn't private still refuses `onlyIfAbsent` quietly.
|
||||
expect(
|
||||
await as(guest.actor, (d) =>
|
||||
d.set({
|
||||
key: 'secret',
|
||||
key: 'open',
|
||||
value: 'x',
|
||||
...via(handle, { onlyIfAbsent: true }),
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(await ownerGet(`${prefix}secret`)).toBe('s');
|
||||
expect(await ownerGet(`${prefix}open`)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1175,6 +1287,44 @@ describe('what a write publishes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a holder`s delete of a missing key, and nothing for a private one', async () => {
|
||||
const prefix = region('pdel');
|
||||
await ownerSet(`${prefix}secret`, 's', { disableSharing: true });
|
||||
const { handle } = await share(prefix, 'write');
|
||||
const ownerSub = (
|
||||
await events().subscribe(owner.actor, SOCKET, {
|
||||
subject: `kv:${GLOBAL}:${prefix}*`,
|
||||
})
|
||||
).sub;
|
||||
const guestSub = (
|
||||
await events().subscribe(guest.actor, SOCKET, {
|
||||
subject: `kv:${handle}:*`,
|
||||
})
|
||||
).sub;
|
||||
delivered.length = 0;
|
||||
|
||||
await as(guest.actor, (d) => d.del({ key: 'secret', ...via(handle) }));
|
||||
await as(guest.actor, (d) => d.del({ key: 'missing', ...via(handle) }));
|
||||
|
||||
expect(await deliveryFor(guestSub.subId)).toMatchObject({
|
||||
key: 'missing',
|
||||
});
|
||||
expect(await deliveryFor(ownerSub.subId)).toMatchObject({
|
||||
key: `${prefix}missing`,
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, EVENTS_COALESCE_WINDOW_MS * 2),
|
||||
);
|
||||
const keys = delivered
|
||||
.filter((one) =>
|
||||
[ownerSub.subId, guestSub.subId].includes(one.subId),
|
||||
)
|
||||
.map((one) => (one.event as { key?: string }).key);
|
||||
expect(keys).not.toContain('secret');
|
||||
expect(keys).not.toContain(`${prefix}secret`);
|
||||
expect(await ownerGet(`${prefix}secret`)).toBe('s');
|
||||
});
|
||||
|
||||
it('marks the owner`s own writes as theirs, and a worker`s or handler`s as via a worker', async () => {
|
||||
const prefix = region('via');
|
||||
const sub = (
|
||||
@@ -1297,6 +1447,157 @@ describe('what a call costs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the handle budget of a namespace', () => {
|
||||
const { limit, perHolder } = KV_HANDLE_NAMESPACE_BUDGET;
|
||||
|
||||
it('is spent by every holder together, and refuses them all once it is gone', async () => {
|
||||
const prefix = region('budget');
|
||||
await ownerSet(`${prefix}k`, 'v');
|
||||
const forGuest = await share(prefix, 'read');
|
||||
const forStranger = await share(prefix, 'read', { holder: stranger });
|
||||
|
||||
expect(await guestGet(forGuest.handle, 'k')).toBe('v');
|
||||
expect(await guestGet(forStranger.handle, 'k', stranger.actor)).toBe(
|
||||
'v',
|
||||
);
|
||||
expect(await spent(budgetKey(owner))).toBe(2);
|
||||
expect(await spent(budgetKey(owner, guest))).toBe(1);
|
||||
|
||||
await exhaust(budgetKey(owner), limit);
|
||||
for (const call of [
|
||||
() => guestGet(forGuest.handle, 'k'),
|
||||
() => guestGet(forStranger.handle, 'k', stranger.actor),
|
||||
])
|
||||
await expect(call()).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
// The owner's own calls spend none of it, with a handle or without.
|
||||
expect(await ownerGet(`${prefix}k`)).toBe('v');
|
||||
expect(await guestGet(forGuest.handle, 'k', owner.actor)).toBe('v');
|
||||
// Another owner's namespace has its own.
|
||||
const { handle: elsewhere } = await shares().mintKvHandle(
|
||||
guest.actor,
|
||||
{
|
||||
prefix: region('budget-other'),
|
||||
recipient: { username: owner.username },
|
||||
access: 'read',
|
||||
},
|
||||
{ defaultAccess: 'read' },
|
||||
);
|
||||
expect(await guestGet(elsewhere, 'k', owner.actor)).toBeNull();
|
||||
|
||||
await clearBudget(budgetKey(owner));
|
||||
expect(await guestGet(forGuest.handle, 'k')).toBe('v');
|
||||
});
|
||||
|
||||
it('keeps one holder to its share, while the others get through', async () => {
|
||||
const prefix = region('fair');
|
||||
await ownerSet(`${prefix}k`, 'v');
|
||||
const forGuest = await share(prefix, 'read');
|
||||
const forStranger = await share(prefix, 'read', { holder: stranger });
|
||||
expect(perHolder).toBeLessThan(limit);
|
||||
|
||||
await exhaust(budgetKey(owner, guest), perHolder);
|
||||
await expect(guestGet(forGuest.handle, 'k')).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
expect(await guestGet(forStranger.handle, 'k', stranger.actor)).toBe(
|
||||
'v',
|
||||
);
|
||||
// The throttled holder's refusals spent nothing of the namespace's.
|
||||
expect(await spent(budgetKey(owner))).toBe(1);
|
||||
});
|
||||
|
||||
it('spends nothing when the owner calls through a handle', async () => {
|
||||
const prefix = region('own');
|
||||
await ownerSet(`${prefix}k`, 'v');
|
||||
const { handle } = await share(prefix, 'read');
|
||||
await exhaust(budgetKey(owner, owner), perHolder);
|
||||
|
||||
for (let i = 0; i < 3; i++)
|
||||
expect(await guestGet(handle, 'k', owner.actor)).toBe('v');
|
||||
expect(await spent(budgetKey(owner))).toBe(0);
|
||||
await exhaust(budgetKey(owner), limit);
|
||||
expect(await guestGet(handle, 'k', owner.actor)).toBe('v');
|
||||
});
|
||||
|
||||
it('refuses a throttled holder before looking at its grant', async () => {
|
||||
const { handle } = await share(region('early'), 'read');
|
||||
await exhaust(budgetKey(owner, guest), perHolder);
|
||||
const checks = vi.spyOn(env.server.services.permission, 'check');
|
||||
try {
|
||||
await expect(guestGet(handle, 'k')).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
});
|
||||
expect(checks).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
checks.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('is only heard by a caller the grant admits', async () => {
|
||||
const { handle } = await share(region('heard'), 'watch');
|
||||
await exhaust(budgetKey(owner), limit);
|
||||
// Below the level: refused for that, not for the budget.
|
||||
await expect(guestGet(handle, 'k')).rejects.toMatchObject({
|
||||
legacyCode: 'kv_handle_access_denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('tells another app of the holder the handle is not there, however spent', async () => {
|
||||
const app = await makeApp();
|
||||
const otherApp = await makeApp();
|
||||
await authorizeApp(guest, app);
|
||||
await authorizeApp(guest, otherApp);
|
||||
const { handle } = await share(region('apps'), 'read', {
|
||||
appUid: app.uid,
|
||||
});
|
||||
const holderBudget = budgetKey(owner, guest, app.uid);
|
||||
await clearBudget(holderBudget);
|
||||
await exhaust(holderBudget, perHolder);
|
||||
|
||||
await expect(
|
||||
guestGet(handle, 'k', appActorFor(guest, otherApp)),
|
||||
).rejects.toMatchObject({ legacyCode: 'kv_handle_not_found' });
|
||||
await expect(
|
||||
guestGet(handle, 'k', appActorFor(guest, app)),
|
||||
).rejects.toMatchObject({ statusCode: 429 });
|
||||
await clearBudget(holderBudget);
|
||||
});
|
||||
|
||||
it('charges a call by the storage it reads and writes', async () => {
|
||||
const prefix = region('cost');
|
||||
const { handle } = await share(prefix, 'write');
|
||||
const big = 'x'.repeat(100 * 1024);
|
||||
for (const key of ['a', 'b', 'c', 'd'])
|
||||
await ownerSet(`${prefix}big:${key}`, big);
|
||||
const both = async () => [
|
||||
await spent(budgetKey(owner)),
|
||||
await spent(budgetKey(owner, guest)),
|
||||
];
|
||||
expect(await both()).toEqual([0, 0]);
|
||||
|
||||
await guestSet(handle, 'small', 'x');
|
||||
await vi.waitFor(async () => expect(await both()).toEqual([3, 3]));
|
||||
expect(await guestGet(handle, 'small')).toBe('x');
|
||||
await vi.waitFor(async () => expect(await both()).toEqual([4, 4]));
|
||||
|
||||
const page = (await as(guest.actor, (d) =>
|
||||
d.list({ as: 'entries', pattern: 'big:*', ...via(handle) }),
|
||||
)) as unknown[];
|
||||
expect(page).toHaveLength(4);
|
||||
// About 400 KB read, at a unit per 8 KB.
|
||||
await vi.waitFor(async () => {
|
||||
for (const units of await both()) {
|
||||
expect(units).toBeGreaterThanOrEqual(4 + 50);
|
||||
expect(units).toBeLessThan(4 + 60);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a holder is ever shown', () => {
|
||||
it('names no owner, prefix or whole key in any answer, success or error', async () => {
|
||||
const prefix = region('scan');
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { claimCountedRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { type Actor, isAccountContext, isAppActor } from '../../core/actor.js';
|
||||
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
|
||||
import {
|
||||
APP_DATA_KV_METHOD_OPS,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
import {
|
||||
KV_GLOBAL_APP_KEY,
|
||||
KV_MAX_KEY_BYTES,
|
||||
KvPrivateEntryError,
|
||||
kvNamespace,
|
||||
type KVOpts,
|
||||
type KVUsage,
|
||||
@@ -55,8 +57,11 @@ import {
|
||||
sealCursor,
|
||||
unsealCursor,
|
||||
} from '../../util/pagination.js';
|
||||
import { predatesApp } from '../../util/appGeneration.js';
|
||||
import { KV_COSTS } from './costs.js';
|
||||
import {
|
||||
KV_HANDLE_NAMESPACE_BUDGET,
|
||||
KV_HANDLE_WRITE_UNIT_WEIGHT,
|
||||
KV_MAX_BATCH_PUT_ITEMS,
|
||||
KV_MAX_GET_KEYS,
|
||||
KV_MAX_LIST_LIMIT,
|
||||
@@ -93,7 +98,20 @@ type KvCallArgs = {
|
||||
* Store options plus, for a call through a share handle, the handle and its
|
||||
* region's prefix: keys go in under it and come back out relative to it.
|
||||
*/
|
||||
type DriverKvOpts = KVOpts & { handle?: string; handlePrefix?: string };
|
||||
type DriverKvOpts = KVOpts & {
|
||||
handle?: string;
|
||||
handlePrefix?: string;
|
||||
/** Set when the call spends from a namespace's handle budget. */
|
||||
handleBudget?: HandleBudget;
|
||||
};
|
||||
|
||||
/** The budgets a holder's call through a handle spends from. */
|
||||
interface HandleBudget {
|
||||
/** Every holder of a share into the namespace together. */
|
||||
namespace: string;
|
||||
/** This holder alone, within the namespace. */
|
||||
holder: string;
|
||||
}
|
||||
|
||||
type KvListResult =
|
||||
unknown[] | { items: unknown[]; cursor?: string; total?: number };
|
||||
@@ -164,6 +182,43 @@ const handlePatternInvalid = (): HttpError =>
|
||||
{ legacyCode: 'kv_handle_key_invalid' },
|
||||
);
|
||||
|
||||
/** The holder's budget in the namespace, or the namespace's, is used up. */
|
||||
const handleBudgetSpent = (): HttpError =>
|
||||
new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
/** The budgets a holder's calls into `namespace` spend from. */
|
||||
const handleBudget = (
|
||||
namespace: string,
|
||||
holderUserId: number,
|
||||
): HandleBudget => ({
|
||||
namespace: `kv:handles:${namespace}`,
|
||||
holder: `kv:handles:${namespace}:holder:${holderUserId}`,
|
||||
});
|
||||
|
||||
/** Spend `units` from a budget, and say whether any of them fit. */
|
||||
const spendHandleBudget = async (
|
||||
key: string,
|
||||
limit: number,
|
||||
units: number,
|
||||
): Promise<boolean> =>
|
||||
(await claimCountedRateLimit(
|
||||
key,
|
||||
limit,
|
||||
KV_HANDLE_NAMESPACE_BUDGET.window,
|
||||
units,
|
||||
)) > 0;
|
||||
|
||||
/**
|
||||
* Whether a share's grant can answer for this actor at all: the holder's own
|
||||
* account, or the app whose data is shared. Any other caller is refused as if
|
||||
* the handle weren't there.
|
||||
*/
|
||||
const onShareSurface = (actor: Actor, appUid: string): boolean =>
|
||||
isAccountContext(actor) ||
|
||||
(isAppActor(actor) && actor.effectiveApp?.uid === appUid);
|
||||
|
||||
const invalidCursor = (): HttpError =>
|
||||
new HttpError(400, 'invalid kv: cursor', { legacyCode: 'bad_request' });
|
||||
|
||||
@@ -331,7 +386,8 @@ export class KVStoreDriver extends PuterDriver {
|
||||
|
||||
/**
|
||||
* Run one store call. Through a handle, an error naming a stored key names
|
||||
* the caller's relative key instead.
|
||||
* the caller's relative key instead, and a write refused for a private
|
||||
* entry gets the refusal any call the handle doesn't allow gets.
|
||||
*/
|
||||
async #inRegion<T>(
|
||||
opts: DriverKvOpts,
|
||||
@@ -342,6 +398,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
try {
|
||||
return await call();
|
||||
} catch (err) {
|
||||
if (err instanceof KvPrivateEntryError) throw handleAccessDenied();
|
||||
throw relativizeError(err, opts.handlePrefix, keys);
|
||||
}
|
||||
}
|
||||
@@ -435,11 +492,31 @@ export class KVStoreDriver extends PuterDriver {
|
||||
if (userId !== row.granteeUserId && userId !== row.ownerUserId)
|
||||
throw handleNotFound();
|
||||
|
||||
// Side by side, so the app lookup costs no round trip of its own.
|
||||
// The holder's own budget goes before the grant lookups, so throttled
|
||||
// retries cost none. It only counts the holder's own calls, so it says
|
||||
// nothing about the namespace. The owner spends no handle budget.
|
||||
const { limit, perHolder } = KV_HANDLE_NAMESPACE_BUDGET;
|
||||
const budget =
|
||||
userId === row.ownerUserId
|
||||
? undefined
|
||||
: handleBudget(kvNamespace(ownerUuid, row.appUid), userId);
|
||||
const holderFirst =
|
||||
budget !== undefined && onShareSurface(actor, row.appUid);
|
||||
if (
|
||||
holderFirst &&
|
||||
!(await spendHandleBudget(budget.holder, perHolder, 1))
|
||||
)
|
||||
throw handleBudgetSpent();
|
||||
|
||||
// Side by side, so the app lookup costs no round trip of its own. A
|
||||
// handle older than its app was made for an earlier app under the
|
||||
// same uid, and is gone with it.
|
||||
const [appGone, allowed] = await Promise.all([
|
||||
row.appUid === KV_GLOBAL_APP_KEY
|
||||
? false
|
||||
: this.stores.app.getByUid(row.appUid).then((app) => !app),
|
||||
: this.stores.app
|
||||
.getByUid(row.appUid)
|
||||
.then((app) => !app || predatesApp(row.createdAt, app)),
|
||||
this.services.permission.check(
|
||||
actor,
|
||||
kvSharePermission(ownerUuid, row.appUid, row.keyPrefix, level),
|
||||
@@ -466,6 +543,17 @@ export class KVStoreDriver extends PuterDriver {
|
||||
Buffer.byteLength(row.keyPrefix, 'utf8') > KV_SHARE_PREFIX_MAX_BYTES
|
||||
)
|
||||
throw handleAccessDenied();
|
||||
// The namespace's budget tells how busy other holders are, so only a
|
||||
// caller the grant admits may hear it. `#meter` spends the rest.
|
||||
if (budget) {
|
||||
if (
|
||||
!holderFirst &&
|
||||
!(await spendHandleBudget(budget.holder, perHolder, 1))
|
||||
)
|
||||
throw handleBudgetSpent();
|
||||
if (!(await spendHandleBudget(budget.namespace, limit, 1)))
|
||||
throw handleBudgetSpent();
|
||||
}
|
||||
|
||||
return {
|
||||
actor,
|
||||
@@ -473,6 +561,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
namespaceAppUuid: row.appUid,
|
||||
handle,
|
||||
handlePrefix: row.keyPrefix,
|
||||
...(budget ? { handleBudget: budget } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -672,7 +761,23 @@ export class KVStoreDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
#meter(actor: Actor | undefined, usage: KVUsage): void {
|
||||
/**
|
||||
* Bill the caller for what the call consumed. A holder's call through a
|
||||
* handle also spends it from its budgets, past the unit admission took.
|
||||
*/
|
||||
#meter(opts: DriverKvOpts, usage: KVUsage): void {
|
||||
const budget = opts.handleBudget;
|
||||
if (budget) {
|
||||
const units = Math.ceil(
|
||||
usage.read + usage.write * KV_HANDLE_WRITE_UNIT_WEIGHT,
|
||||
);
|
||||
if (units > 1) {
|
||||
const { limit, perHolder } = KV_HANDLE_NAMESPACE_BUDGET;
|
||||
void spendHandleBudget(budget.holder, perHolder, units - 1);
|
||||
void spendHandleBudget(budget.namespace, limit, units - 1);
|
||||
}
|
||||
}
|
||||
const actor = opts.actor;
|
||||
if (!actor) return;
|
||||
const metering = this.services.metering;
|
||||
if (usage.read > 0) {
|
||||
@@ -750,7 +855,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -758,7 +863,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.get({ key: this.#storeKey(coerced, opts) }, opts),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -793,7 +898,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -859,7 +964,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -872,7 +977,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.del({ key: this.#storeKey(coerced, opts) }, opts),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -928,7 +1033,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
},
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
if (!listing) return res;
|
||||
return this.#foreignResult(res as KvListResult, args.as, listing);
|
||||
}
|
||||
@@ -936,7 +1041,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
async flush(args: { optConfig?: KvOptConfig }): Promise<boolean> {
|
||||
const opts = await this.#opts('flush', args);
|
||||
const { res, usage } = await this.stores.kv.flush(opts);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -964,7 +1069,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -992,7 +1097,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1017,7 +1122,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1039,7 +1144,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1066,7 +1171,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1091,7 +1196,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1113,7 +1218,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
this.#meter(opts, usage);
|
||||
// The result is the entry's value, so a delete-only grant gets none. A
|
||||
// handle's `write` level reads too.
|
||||
if (
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Per-call caps on the public KV driver. Enforced here rather than in the
|
||||
// store, whose internal callers batch past them.
|
||||
// Caps on the public KV driver. Enforced in the driver rather than the store,
|
||||
// whose internal callers batch past them.
|
||||
|
||||
/** Keys one array `get` may name. */
|
||||
export const KV_MAX_GET_KEYS = 1000;
|
||||
@@ -31,3 +31,23 @@ export const KV_MAX_BATCH_PUT_ITEMS = 1000;
|
||||
* the cursor carries the rest. Matches the SDK's own page size.
|
||||
*/
|
||||
export const KV_MAX_LIST_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* Storage capacity calls through share handles may spend in one owner's
|
||||
* namespace per window: `limit` for every holder together, and `perHolder` for
|
||||
* any one of them, so no holder can starve the others. The owner's own calls
|
||||
* through a handle spend none. Sized to leave about half of what one namespace
|
||||
* can serve to the owner's own app.
|
||||
*/
|
||||
export const KV_HANDLE_NAMESPACE_BUDGET = {
|
||||
limit: 15_000,
|
||||
perHolder: 3_750,
|
||||
window: 10_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* What a call spends from those budgets: its read units, plus this many per
|
||||
* write unit, since a write costs the store about three times what a read does.
|
||||
* Every call spends at least one.
|
||||
*/
|
||||
export const KV_HANDLE_WRITE_UNIT_WEIGHT = 3;
|
||||
@@ -145,6 +145,18 @@ const captureBus = async (
|
||||
return seen;
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60_000;
|
||||
|
||||
/** Exactly `count` slots of a window are spent. */
|
||||
const expectSpent = async (
|
||||
key: string,
|
||||
windowMs: number,
|
||||
count: number,
|
||||
): Promise<void> => {
|
||||
expect(await peekRateLimit(key, count, windowMs)).toBe(false);
|
||||
expect(await peekRateLimit(key, count + 1, windowMs)).toBe(true);
|
||||
};
|
||||
|
||||
const bootServer = (config: Partial<IConfig>) => {
|
||||
const state = {
|
||||
server: null as unknown as PuterServer,
|
||||
@@ -171,16 +183,15 @@ describe('app.message with live delivery over the socket wire', () => {
|
||||
const app = await makeApp(state.server, holder.user.id!);
|
||||
|
||||
let created: Record<string, unknown> | null = null;
|
||||
const pushed = await captureBus(
|
||||
state.server,
|
||||
'outer.gui.notif.message',
|
||||
async () => {
|
||||
let desktop: Array<Record<string, unknown>> = [];
|
||||
const written = await captureBus(state.server, 'notif.written', () =>
|
||||
captureBus(state.server, 'outer.gui.notif.message', async () => {
|
||||
created = (await withActor(asApp(holder, app), () =>
|
||||
state.driver.create(
|
||||
message({ text: 'Body', channel: 'replies' }),
|
||||
),
|
||||
)) as Record<string, unknown>;
|
||||
},
|
||||
}).then((seen) => (desktop = seen)),
|
||||
);
|
||||
|
||||
const uid = created!.uid as string;
|
||||
@@ -202,13 +213,16 @@ describe('app.message with live delivery over the socket wire', () => {
|
||||
audience: 'app-user',
|
||||
type: 'app.message',
|
||||
});
|
||||
// Not silent: it went out on the wire the desktop listens to.
|
||||
expect(pushed).toEqual([
|
||||
{
|
||||
user_id_list: [holder.user.id],
|
||||
response: { uid, notification: created!.value },
|
||||
},
|
||||
// Not silent, but sent for the app alone: the desktop wire stays quiet.
|
||||
expect(written).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: holder.user.id,
|
||||
uid,
|
||||
type: 'app.message',
|
||||
appUid: app.uid,
|
||||
}),
|
||||
]);
|
||||
expect(desktop).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets the app's worker and the tokens it issued notify their user", async () => {
|
||||
@@ -300,6 +314,42 @@ describe('app.message with live delivery over the socket wire', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('names the field over its own limit before the total size', async () => {
|
||||
const holder = await makeUser(state.server);
|
||||
const app = await makeApp(state.server, holder.user.id!);
|
||||
for (const [fields, field] of [
|
||||
[{ data: { blob: 'x'.repeat(20_000) } }, 'data'],
|
||||
[{ text: 'x'.repeat(20_000) }, 'text'],
|
||||
[{ extra: 'x'.repeat(20_000) }, 'extra'],
|
||||
] as const) {
|
||||
await expect(
|
||||
withActor(asApp(holder, app), () =>
|
||||
state.driver.create(message(fields)),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
fields: { field },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('still caps the whole value when every field passes', async () => {
|
||||
const holder = await makeUser(state.server);
|
||||
const app = await makeApp(state.server, holder.user.id!);
|
||||
// Invisible marks are dropped from the title, so only the total is over.
|
||||
await expect(
|
||||
withActor(asApp(holder, app), () =>
|
||||
state.driver.create(
|
||||
message({ title: `${'\u200b'.repeat(20_000)}Hi` }),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'notification_value_too_large',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the holder creating other types silently', async () => {
|
||||
const holder = await makeUser(state.server);
|
||||
const pushed = await captureBus(
|
||||
@@ -465,6 +515,176 @@ describe('app.message with live delivery over the socket wire', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('app.message groups', () => {
|
||||
const state = bootServer({
|
||||
notificationAppMessageLimits: { perMinute: 5, perDay: 4 },
|
||||
});
|
||||
|
||||
const setup = async () => {
|
||||
const holder = await makeUser(state.server);
|
||||
const app = await makeApp(state.server, holder.user.id!);
|
||||
const send = async (fields: Record<string, unknown> = {}) =>
|
||||
(await withActor(asApp(holder, app), () =>
|
||||
state.driver.create(message(fields)),
|
||||
)) as Record<string, unknown>;
|
||||
const rows = () =>
|
||||
state.server.stores.notification.listByUserId(holder.user.id!, {});
|
||||
return {
|
||||
holder,
|
||||
app,
|
||||
send,
|
||||
rows,
|
||||
minuteKey: `notif:app-message:minute:${holder.user.id}:${app.uid}`,
|
||||
dayKey: `notif:app-message:day:${holder.user.id}:${app.uid}`,
|
||||
};
|
||||
};
|
||||
|
||||
it('replaces the unseen message with the same group, spending no day slot', async () => {
|
||||
const { holder, app, send, rows, minuteKey, dayKey } = await setup();
|
||||
const first = await send({ title: 'One', group: 'chat-1' });
|
||||
|
||||
let second: Record<string, unknown> = {};
|
||||
const written = await captureBus(
|
||||
state.server,
|
||||
'notif.written',
|
||||
async () => {
|
||||
second = await send({
|
||||
title: 'Two',
|
||||
text: 'Hi',
|
||||
group: 'chat-1',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
expect(second.uid).toBe(first.uid);
|
||||
expect(second.value).toMatchObject({ title: 'Two', text: 'Hi' });
|
||||
expect(await rows()).toEqual([
|
||||
expect.objectContaining({
|
||||
uid: first.uid,
|
||||
value: expect.objectContaining({ title: 'Two' }),
|
||||
}),
|
||||
]);
|
||||
expect(written).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: holder.user.id,
|
||||
uid: first.uid,
|
||||
appUid: app.uid,
|
||||
updated: true,
|
||||
}),
|
||||
]);
|
||||
await expectSpent(minuteKey, 60_000, 2);
|
||||
await expectSpent(dayKey, DAY_MS, 1);
|
||||
});
|
||||
|
||||
it('starts a new message once the last one was shown or dismissed', async () => {
|
||||
const { holder, app, send, rows, dayKey } = await setup();
|
||||
const asTheApp = asApp(holder, app);
|
||||
|
||||
const first = await send({ group: 'chat-1' });
|
||||
await withActor(asTheApp, () =>
|
||||
state.driver.mark_shown({ uid: first.uid }),
|
||||
);
|
||||
const second = await send({ group: 'chat-1' });
|
||||
expect(second.uid).not.toBe(first.uid);
|
||||
|
||||
await withActor(asTheApp, () =>
|
||||
state.driver.mark_acknowledged({ uid: second.uid }),
|
||||
);
|
||||
const third = await send({ group: 'chat-1' });
|
||||
expect(third.uid).not.toBe(second.uid);
|
||||
|
||||
expect(await rows()).toHaveLength(3);
|
||||
await expectSpent(dayKey, DAY_MS, 3);
|
||||
});
|
||||
|
||||
it('keeps messages with no group, or another group, apart', async () => {
|
||||
const { send, rows } = await setup();
|
||||
const uids = [
|
||||
(await send()).uid,
|
||||
(await send()).uid,
|
||||
(await send({ group: 'a' })).uid,
|
||||
(await send({ group: 'b' })).uid,
|
||||
];
|
||||
expect(new Set(uids).size).toBe(4);
|
||||
expect(await rows()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('still spends the minute window on a replacement', async () => {
|
||||
const { send, rows, dayKey } = await setup();
|
||||
for (let i = 0; i < 5; i++) await send({ group: 'chat-1' });
|
||||
await expect(send({ group: 'chat-1' })).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
expect(await rows()).toHaveLength(1);
|
||||
await expectSpent(dayKey, DAY_MS, 1);
|
||||
});
|
||||
|
||||
it('replaces with the day window spent', async () => {
|
||||
const { send, rows } = await setup();
|
||||
const grouped = await send({ group: 'chat-1' });
|
||||
for (let i = 0; i < 3; i++) await send();
|
||||
await expect(send()).rejects.toMatchObject({ statusCode: 429 });
|
||||
|
||||
const replaced = await send({ title: 'Later', group: 'chat-1' });
|
||||
expect(replaced.uid).toBe(grouped.uid);
|
||||
expect(await rows()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('writes a new message when the old one is shown before the replace lands', async () => {
|
||||
const { holder, app, send, rows, minuteKey, dayKey } = await setup();
|
||||
const first = await send({ group: 'chat-1' });
|
||||
|
||||
charges.held.add(minuteKey);
|
||||
const pending = send({ title: 'Raced', group: 'chat-1' });
|
||||
let second: Record<string, unknown>;
|
||||
try {
|
||||
await until(() => charges.waiting.length === 1);
|
||||
await withActor(asApp(holder, app), () =>
|
||||
state.driver.mark_shown({ uid: first.uid }),
|
||||
);
|
||||
} finally {
|
||||
charges.held.clear();
|
||||
charges.waiting.shift()?.();
|
||||
second = await pending;
|
||||
}
|
||||
|
||||
expect(second.uid).not.toBe(first.uid);
|
||||
expect(await rows()).toHaveLength(2);
|
||||
await expectSpent(dayKey, DAY_MS, 2);
|
||||
});
|
||||
|
||||
it('folds concurrent messages to an empty group into one, spending one day slot', async () => {
|
||||
const { send, rows, minuteKey, dayKey } = await setup();
|
||||
const sent = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, i) =>
|
||||
send({ title: `Message ${i}`, group: 'chat-1' }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(new Set(sent.map((s) => s.uid)).size).toBe(1);
|
||||
expect(await rows()).toHaveLength(1);
|
||||
await expectSpent(minuteKey, 60_000, 4);
|
||||
await expectSpent(dayKey, DAY_MS, 1);
|
||||
});
|
||||
|
||||
it('still sends when the group stays locked past the wait', async () => {
|
||||
const { holder, app, send, rows, dayKey } = await setup();
|
||||
await state.server.clients.redis.set(
|
||||
`notif:app-message:group:${holder.user.id}:${app.uid}:chat-1`,
|
||||
'stalled-sender',
|
||||
'PX',
|
||||
60_000,
|
||||
);
|
||||
|
||||
const sent = await send({ group: 'chat-1' });
|
||||
expect(await rows()).toEqual([
|
||||
expect.objectContaining({ uid: sent.uid }),
|
||||
]);
|
||||
await expectSpent(dayKey, DAY_MS, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('app.message with the events fold-in on', () => {
|
||||
const state = bootServer({
|
||||
events: { enabled: true, notificationsFoldIn: true },
|
||||
@@ -503,6 +723,34 @@ describe('app.message with the events fold-in on', () => {
|
||||
).toBe(`notif:${app.uid}:app-user`);
|
||||
});
|
||||
|
||||
it('dispatches a replacement again under the same uid', async () => {
|
||||
const holder = await makeUser(state.server);
|
||||
const app = await makeApp(state.server, holder.user.id!);
|
||||
const first = (await withActor(asApp(holder, app), () =>
|
||||
state.driver.create(message({ group: 'g' })),
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
const created = await captureBus(state.server, 'notif.created', () =>
|
||||
withActor(asApp(holder, app), () =>
|
||||
state.driver.create(message({ title: 'Again', group: 'g' })),
|
||||
),
|
||||
);
|
||||
expect(created).toEqual([
|
||||
expect.objectContaining({
|
||||
uid: first.uid,
|
||||
appUid: app.uid,
|
||||
audience: 'app-user',
|
||||
value: expect.objectContaining({ title: 'Again' }),
|
||||
replaced: true,
|
||||
}),
|
||||
]);
|
||||
const row = await state.server.stores.notification.getByUid(
|
||||
first.uid as string,
|
||||
{ userId: holder.user.id, primary: true },
|
||||
);
|
||||
expect(Number(row.replaced_at)).toBe(created[0].createdAt);
|
||||
});
|
||||
|
||||
it('limits each app per user per day, without spending the minute', async () => {
|
||||
const holder = await makeUser(state.server);
|
||||
const app = await makeApp(state.server, holder.user.id!);
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** `app.message` sent to the other party of a KV share through its handle. */
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
EVENTS_KV_HANDLE_LIMIT,
|
||||
KV_SHARE_NOTIFY_LIMITS,
|
||||
} from '../../controllers/events/limits.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import {
|
||||
claimRateLimit,
|
||||
peekRateLimit,
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import type { NotificationDriver } from './NotificationDriver.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
const DAY_MS = 24 * 60 * 60_000;
|
||||
|
||||
interface TestUser {
|
||||
actor: Actor;
|
||||
username: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
let env: PuterTestEnv;
|
||||
let owner: TestUser;
|
||||
let guest: TestUser;
|
||||
let stranger: TestUser;
|
||||
|
||||
const driver = () =>
|
||||
env.server.drivers.notifications as unknown as NotificationDriver;
|
||||
|
||||
const userFor = async (username: string): Promise<TestUser> => {
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
return {
|
||||
actor: makeActor({ user: row as never }),
|
||||
username,
|
||||
id: row!.id!,
|
||||
};
|
||||
};
|
||||
|
||||
const makeApp = async () => {
|
||||
const name = `notify-to-${uuidv4().slice(0, 8)}`;
|
||||
return env.server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'Handle App',
|
||||
index_url: `https://${name}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: owner.id },
|
||||
) as Promise<{ uid: string; id: number }>;
|
||||
};
|
||||
|
||||
const inApp = (user: TestUser, app: { uid: string; id: number }) =>
|
||||
makeActor({
|
||||
user: user.actor.user as never,
|
||||
app: { uid: app.uid, id: app.id },
|
||||
});
|
||||
|
||||
/** A share of `app`'s data from the owner to the guest. */
|
||||
const share = async (app: { uid: string }, notify = true) =>
|
||||
env.server.services.kvShare.mintKvHandle(
|
||||
owner.actor,
|
||||
{
|
||||
prefix: `room-${uuidv4().slice(0, 8)}:`,
|
||||
recipient: { username: guest.username },
|
||||
appUid: app.uid,
|
||||
notify,
|
||||
},
|
||||
{ defaultAccess: 'write' },
|
||||
);
|
||||
|
||||
const send = (actor: Actor, fields: Record<string, unknown> = {}) =>
|
||||
runWithContext({ actor }, () =>
|
||||
driver().create({
|
||||
object: { value: { type: 'app.message', title: 'Hi', ...fields } },
|
||||
}),
|
||||
) as Promise<Record<string, unknown>>;
|
||||
|
||||
const rowsOf = (user: TestUser) =>
|
||||
env.server.stores.notification.listByUserId(user.id, {});
|
||||
|
||||
/** The user's `app.message` rows from `app`, newest first. */
|
||||
const appMessagesOf = async (user: TestUser, app: { uid: string }) =>
|
||||
(await rowsOf(user)).filter(
|
||||
(row: Record<string, unknown>) =>
|
||||
row.app_uid === app.uid && row.type === 'app.message',
|
||||
);
|
||||
|
||||
/** What a refusal shows the sender. */
|
||||
const refusalOf = async (sending: Promise<unknown>) => {
|
||||
try {
|
||||
await sending;
|
||||
} catch (err) {
|
||||
const { statusCode, legacyCode, message, fields } = err as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return { statusCode, legacyCode, message, fields };
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
};
|
||||
|
||||
const tooMany = {
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
message: 'Too many notifications.',
|
||||
};
|
||||
|
||||
const minuteKey = (user: TestUser, appUid: string) =>
|
||||
`notif:app-message:minute:${user.id}:${appUid}`;
|
||||
const dayKey = (user: TestUser, appUid: string) =>
|
||||
`notif:app-message:day:${user.id}:${appUid}`;
|
||||
|
||||
/** Exactly `count` slots of a window are spent. */
|
||||
const expectSpent = async (key: string, windowMs: number, count: number) => {
|
||||
if (count > 0)
|
||||
expect(await peekRateLimit(key, count, windowMs)).toBe(false);
|
||||
expect(await peekRateLimit(key, count + 1, windowMs)).toBe(true);
|
||||
};
|
||||
|
||||
/** Every payload the bus carried under `key` while `fn` ran. */
|
||||
const captureBus = async (
|
||||
key: string,
|
||||
fn: () => Promise<unknown>,
|
||||
): Promise<Array<Record<string, unknown>>> => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const handler = (_k: string, data: unknown) =>
|
||||
seen.push(data as Record<string, unknown>);
|
||||
env.server.clients.event.on(key, handler);
|
||||
try {
|
||||
await fn();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
} finally {
|
||||
env.server.clients.event.off?.(key, handler);
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
notificationAppMessageLimits: { perMinute: 5, perDay: 4 },
|
||||
} as IConfig);
|
||||
const strangerName = `notify-to-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username: strangerName,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
owner = await userFor(env.users.user.username);
|
||||
guest = await userFor(env.users.other.username);
|
||||
stranger = await userFor(strangerName);
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await env.server.clients.redis.del(
|
||||
`rate:${EVENTS_KV_HANDLE_LIMIT.scope}:${owner.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('app.message to the other party of a share', () => {
|
||||
it('writes the owner’s message to the holder, and answers with nothing', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
|
||||
let sent: Record<string, unknown> | null = null;
|
||||
const written = await captureBus('notif.written', async () => {
|
||||
sent = await send(inApp(owner, app), {
|
||||
title: 'New message',
|
||||
to: { handle },
|
||||
});
|
||||
});
|
||||
|
||||
expect(sent).toEqual({});
|
||||
const [row] = await appMessagesOf(guest, app);
|
||||
expect(row).toMatchObject({
|
||||
user_id: guest.id,
|
||||
app_uid: app.uid,
|
||||
type: 'app.message',
|
||||
audience: 'app-user',
|
||||
});
|
||||
expect(row!.value).toEqual({
|
||||
type: 'app.message',
|
||||
title: 'New message',
|
||||
priority: 'normal',
|
||||
ttl: 3600,
|
||||
app: { uid: app.uid, name: 'Handle App' },
|
||||
});
|
||||
// The push extension bills and checks consent on this recipient.
|
||||
expect(written).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: guest.id,
|
||||
uid: row.uid,
|
||||
appUid: app.uid,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes the holder’s message to the owner', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
const before = (await rowsOf(guest)).length;
|
||||
|
||||
const sent = await send(inApp(guest, app), {
|
||||
title: 'From the holder',
|
||||
to: { handle },
|
||||
});
|
||||
|
||||
expect(sent).toEqual({});
|
||||
const [row] = await appMessagesOf(owner, app);
|
||||
expect(row).toMatchObject({ user_id: owner.id });
|
||||
expect(row.value).toMatchObject({ title: 'From the holder' });
|
||||
expect(await rowsOf(guest)).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("charges the recipient's limits, not the sender's", async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
|
||||
await send(inApp(owner, app), { to: { handle } });
|
||||
|
||||
await expectSpent(minuteKey(guest, app.uid), 60_000, 1);
|
||||
await expectSpent(dayKey(guest, app.uid), DAY_MS, 1);
|
||||
await expectSpent(minuteKey(owner, app.uid), 60_000, 0);
|
||||
await expectSpent(dayKey(owner, app.uid), DAY_MS, 0);
|
||||
});
|
||||
|
||||
it('answers the pair limit and the recipient’s limit the same way', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
|
||||
// The recipient's own day window runs out.
|
||||
for (let i = 0; i < 4; i++)
|
||||
await send(inApp(guest, app), { title: `own ${i}` });
|
||||
const byRecipient = await refusalOf(
|
||||
send(inApp(owner, app), { to: { handle } }),
|
||||
);
|
||||
|
||||
const other = await makeApp();
|
||||
const { handle: otherHandle } = await share(other);
|
||||
const [minute] = KV_SHARE_NOTIFY_LIMITS;
|
||||
await claimRateLimit(
|
||||
`${minute.scope}:${owner.id}:${guest.id}:${other.uid}`,
|
||||
minute.limit,
|
||||
minute.window,
|
||||
minute.limit,
|
||||
);
|
||||
const byPair = await refusalOf(
|
||||
send(inApp(owner, other), { to: { handle: otherHandle } }),
|
||||
);
|
||||
|
||||
expect(byRecipient).toEqual({ ...tooMany, fields: undefined });
|
||||
expect(byPair).toEqual(byRecipient);
|
||||
await expectSpent(dayKey(guest, other.uid), DAY_MS, 0);
|
||||
});
|
||||
|
||||
it("replaces the recipient's newest message in the same group", async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
|
||||
await send(inApp(owner, app), {
|
||||
title: 'One',
|
||||
group: 'room-1',
|
||||
to: { handle },
|
||||
});
|
||||
const second = await send(inApp(owner, app), {
|
||||
title: 'Two',
|
||||
group: 'room-1',
|
||||
to: { handle },
|
||||
});
|
||||
|
||||
expect(second).toEqual({});
|
||||
const rows = await appMessagesOf(guest, app);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].value).toMatchObject({ title: 'Two' });
|
||||
await expectSpent(dayKey(guest, app.uid), DAY_MS, 1);
|
||||
});
|
||||
|
||||
it('folds concurrent messages to one group into one, through the share or not', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
|
||||
await Promise.all([
|
||||
send(inApp(owner, app), { group: 'room-1', to: { handle } }),
|
||||
send(inApp(owner, app), { group: 'room-1', to: { handle } }),
|
||||
send(inApp(guest, app), { group: 'room-1' }),
|
||||
send(inApp(guest, app), { group: 'room-1' }),
|
||||
]);
|
||||
|
||||
expect(await appMessagesOf(guest, app)).toHaveLength(1);
|
||||
await expectSpent(dayKey(guest, app.uid), DAY_MS, 1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a string', 'kvh-1'],
|
||||
['an array', [{ handle: 'kvh-1' }]],
|
||||
['null', null],
|
||||
['no handle', {}],
|
||||
['a handle that is not a string', { handle: 7 }],
|
||||
['an empty handle', { handle: '' }],
|
||||
['another field beside the handle', { handle: 'kvh-1', user: 'x' }],
|
||||
])('refuses `to` as %s', async (_label, to) => {
|
||||
const app = await makeApp();
|
||||
await expect(send(inApp(owner, app), { to })).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
fields: { field: 'to' },
|
||||
});
|
||||
});
|
||||
|
||||
it('validates the message before spending the pair limit', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
const [minute] = KV_SHARE_NOTIFY_LIMITS;
|
||||
|
||||
await expect(
|
||||
send(inApp(owner, app), {
|
||||
icon: 'http://insecure.test/a.png',
|
||||
to: { handle },
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400, fields: { field: 'icon' } });
|
||||
expect(
|
||||
await env.server.clients.redis.zcard(
|
||||
`rate:${minute.scope}:${owner.id}:${guest.id}:${app.uid}`,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
describe('passes every refusal of the share through with its code', () => {
|
||||
const absent = {
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
};
|
||||
|
||||
it('an unknown handle, or one the sender is no party to', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
await expect(
|
||||
send(inApp(owner, app), { to: { handle: `kvh-${uuidv4()}` } }),
|
||||
).rejects.toMatchObject(absent);
|
||||
await expect(
|
||||
send(inApp(stranger, app), { to: { handle } }),
|
||||
).rejects.toMatchObject(absent);
|
||||
await expect(
|
||||
send(inApp(owner, await makeApp()), { to: { handle } }),
|
||||
).rejects.toMatchObject(absent);
|
||||
});
|
||||
|
||||
it('a share without notify', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app, false);
|
||||
await expect(
|
||||
send(inApp(owner, app), { to: { handle } }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'kv_share_notify_not_enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('a recipient who blocked the sender', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
await env.server.stores.userBlock.create(guest.id, owner.id);
|
||||
try {
|
||||
await expect(
|
||||
send(inApp(owner, app), { to: { handle } }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'recipient_not_accepting_shares',
|
||||
});
|
||||
} finally {
|
||||
await env.server.stores.userBlock.deleteByPair(
|
||||
guest.id,
|
||||
owner.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('a share that cannot be checked', async () => {
|
||||
const app = await makeApp();
|
||||
const { handle } = await share(app);
|
||||
const lookup = vi
|
||||
.spyOn(env.server.stores.kvShareHandle, 'getLive')
|
||||
.mockRejectedValue(new Error('lookup unavailable'));
|
||||
try {
|
||||
await expect(
|
||||
send(inApp(owner, app), { to: { handle } }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'service_unavailable',
|
||||
});
|
||||
} finally {
|
||||
lookup.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
APP_MESSAGE_TYPE,
|
||||
appMessageSender,
|
||||
parseAppMessage,
|
||||
type AppMessage,
|
||||
} from '../../services/notification/appMessage.js';
|
||||
import {
|
||||
APP_READABLE_AUDIENCES,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
resolveNotificationWrite,
|
||||
type NotificationTypeName,
|
||||
} from '../../services/notification/notificationTypes.js';
|
||||
import { withRedisLock } from '../../util/redisLock.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { DriverRateLimitConfig } from '../meta.js';
|
||||
@@ -58,6 +60,12 @@ import { CRUD_CONCURRENT } from '../util/crudLimits.js';
|
||||
const MAX_SELECT_LIMIT = 200;
|
||||
const MINUTE_MS = 60_000;
|
||||
const DAY_MS = 24 * 60 * MINUTE_MS;
|
||||
// A group's sends queue for up to ~1 s; a stalled holder blocks them for the TTL.
|
||||
const GROUP_LOCK_MS = 5_000;
|
||||
const GROUP_LOCK_ATTEMPTS = 40;
|
||||
const GROUP_LOCK_RETRY_MS = 25;
|
||||
|
||||
type AppMessageWindow = 'minute' | 'day';
|
||||
|
||||
/** The mailbox slice a call named, in the terms the query takes it. */
|
||||
interface MailboxScope {
|
||||
@@ -66,12 +74,56 @@ interface MailboxScope {
|
||||
appUid: string | null | undefined;
|
||||
}
|
||||
|
||||
const assertValueFits = (value: Record<string, unknown>): void => {
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(value), 'utf8') >
|
||||
NOTIFICATION_VALUE_MAX_BYTES
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`value\` may not exceed ${NOTIFICATION_VALUE_MAX_BYTES} bytes`,
|
||||
{ legacyCode: 'notification_value_too_large' },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const typeNotAllowed = (message: string): HttpError =>
|
||||
new HttpError(403, message, {
|
||||
legacyCode: 'forbidden',
|
||||
code: 'notification_type_not_allowed',
|
||||
});
|
||||
|
||||
const tooManyAppMessages = (): HttpError =>
|
||||
new HttpError(429, 'Too many notifications from this app.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
/** One refusal for either limit a notification through a share can hit. */
|
||||
const tooManySharedNotifications = (): HttpError =>
|
||||
new HttpError(429, 'Too many notifications.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
/** The handle in `to`, which must be `{ handle }` and nothing else. */
|
||||
const parseNotifyTarget = (to: unknown): string => {
|
||||
const target = to as Record<string, unknown> | null;
|
||||
if (
|
||||
!target ||
|
||||
typeof target !== 'object' ||
|
||||
Array.isArray(target) ||
|
||||
Object.keys(target).length !== 1 ||
|
||||
typeof target.handle !== 'string' ||
|
||||
target.handle === ''
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`to` must be an object with only a string `handle`',
|
||||
{ legacyCode: 'bad_request', fields: { field: 'to' } },
|
||||
);
|
||||
}
|
||||
return target.handle;
|
||||
};
|
||||
|
||||
/** Whether a row belongs to the slice, as the same SQL scope selected it. */
|
||||
const inScope = (
|
||||
row: Record<string, unknown>,
|
||||
@@ -102,8 +154,8 @@ const inScope = (
|
||||
* grant changes that. A slice an actor may not see comes back empty rather
|
||||
* than refused — the mailbox is not an oracle.
|
||||
* - The holder creates any registered type but `app.message`, silently. An actor
|
||||
* holding an app creates `app.message` only, for its own user, and it is
|
||||
* delivered live.
|
||||
* holding an app creates `app.message` only, for its own user or, through a
|
||||
* KV share handle, for the share's other party, and it is delivered live.
|
||||
*
|
||||
* `read` and `select` also take an optional `subject` naming one slice
|
||||
* (`notif:<appUid>:<audience>`); the two-segment form expands from the actor's
|
||||
@@ -155,17 +207,7 @@ export class NotificationDriver extends PuterDriver {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(value), 'utf8') >
|
||||
NOTIFICATION_VALUE_MAX_BYTES
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`value\` may not exceed ${NOTIFICATION_VALUE_MAX_BYTES} bytes`,
|
||||
{ legacyCode: 'notification_value_too_large' },
|
||||
);
|
||||
}
|
||||
|
||||
// Its own field checks come first, so an app hears which field is wrong.
|
||||
if (actor.effectiveApp) {
|
||||
return this.#createAppMessage(
|
||||
actor,
|
||||
@@ -174,6 +216,7 @@ export class NotificationDriver extends PuterDriver {
|
||||
value,
|
||||
);
|
||||
}
|
||||
assertValueFits(value);
|
||||
if (type === APP_MESSAGE_TYPE) {
|
||||
throw typeNotAllowed(`Only apps can create ${APP_MESSAGE_TYPE}`);
|
||||
}
|
||||
@@ -198,11 +241,12 @@ export class NotificationDriver extends PuterDriver {
|
||||
// Silent: a driver `create` has never pushed to the live socket, only
|
||||
// ever written the row — moving through the registry does not change
|
||||
// that.
|
||||
return this.#write(actor, value, {
|
||||
const uid = await this.#write(actor.user.id, value, {
|
||||
type: registeredType,
|
||||
appUid,
|
||||
silent: true,
|
||||
});
|
||||
return this.#readBack(uid, actor.user.id);
|
||||
}
|
||||
|
||||
async read(args: Record<string, unknown>): Promise<unknown> {
|
||||
@@ -300,14 +344,17 @@ export class NotificationDriver extends PuterDriver {
|
||||
return { success: !!ok };
|
||||
}
|
||||
|
||||
/** An app's message to its own user; the actor names recipient and app. */
|
||||
/**
|
||||
* An app's message to its own user, or with `to.handle` to the other party
|
||||
* of a KV share. The actor names the app; the share names the recipient.
|
||||
*/
|
||||
async #createAppMessage(
|
||||
actor: UserActor,
|
||||
appUid: string,
|
||||
object: Record<string, unknown>,
|
||||
value: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const { type, ...fields } = value;
|
||||
const { type, to, ...fields } = value;
|
||||
if (type !== APP_MESSAGE_TYPE) {
|
||||
throw typeNotAllowed(`Apps can create only ${APP_MESSAGE_TYPE}`);
|
||||
}
|
||||
@@ -320,70 +367,171 @@ export class NotificationDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
}
|
||||
const handle = to === undefined ? undefined : parseNotifyTarget(to);
|
||||
const message = parseAppMessage(fields);
|
||||
assertValueFits(value);
|
||||
|
||||
if (!(await this.#withinAppMessageLimits(actor.user.id, appUid))) {
|
||||
throw new HttpError(429, 'Too many notifications from this app.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
if (handle === undefined) {
|
||||
const uid = await this.#sendAppMessage(
|
||||
actor.user.id,
|
||||
appUid,
|
||||
message,
|
||||
);
|
||||
return this.#readBack(uid, actor.user.id);
|
||||
}
|
||||
try {
|
||||
const recipient =
|
||||
await this.services.kvShare.resolveNotifyRecipient(
|
||||
actor,
|
||||
handle,
|
||||
);
|
||||
await this.#sendAppMessage(
|
||||
recipient.recipientUserId,
|
||||
recipient.appUid,
|
||||
message,
|
||||
);
|
||||
} catch (err) {
|
||||
// Which limit refused would say how much the recipient is sent.
|
||||
if (err instanceof HttpError && err.statusCode === 429)
|
||||
throw tooManySharedNotifications();
|
||||
throw err;
|
||||
}
|
||||
// Even a uid would say whether the recipient's last one was replaced.
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with a `group` replaces the newest one in that group unless it
|
||||
* was shown or dismissed, which spends a minute slot but no day slot.
|
||||
* Limits are keyed on the recipient, who need not be the actor.
|
||||
*/
|
||||
async #sendAppMessage(
|
||||
recipientId: number,
|
||||
appUid: string,
|
||||
message: AppMessage,
|
||||
): Promise<string> {
|
||||
const send = () =>
|
||||
this.#replaceOrWriteAppMessage(recipientId, appUid, message);
|
||||
if (!message.group) return send();
|
||||
// One send per group at a time, so a burst into an empty group writes
|
||||
// one row that the rest replace. A lock that is busy past the wait or
|
||||
// unreachable still sends: the race costs a second row and its day slot.
|
||||
return withRedisLock(
|
||||
this.clients.redis,
|
||||
`notif:app-message:group:${recipientId}:${appUid}:${message.group}`,
|
||||
send,
|
||||
{
|
||||
ttlMs: GROUP_LOCK_MS,
|
||||
attempts: GROUP_LOCK_ATTEMPTS,
|
||||
retryMs: GROUP_LOCK_RETRY_MS,
|
||||
onUnavailable: 'run',
|
||||
onBusy: send,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async #replaceOrWriteAppMessage(
|
||||
recipientId: number,
|
||||
appUid: string,
|
||||
message: AppMessage,
|
||||
): Promise<string> {
|
||||
// Primary: a lagging replica would turn a replacement into a new
|
||||
// notification, which spends a day slot.
|
||||
const open = message.group
|
||||
? await this.stores.notification.getUnseenByGroup(
|
||||
recipientId,
|
||||
appUid,
|
||||
message.group,
|
||||
{ primary: true },
|
||||
)
|
||||
: null;
|
||||
await this.#spendAppMessageLimits(
|
||||
recipientId,
|
||||
appUid,
|
||||
open ? ['minute'] : ['minute', 'day'],
|
||||
);
|
||||
|
||||
message.app = appMessageSender(
|
||||
appUid,
|
||||
await this.stores.app.getByUid(appUid),
|
||||
);
|
||||
|
||||
return this.#write(actor, message, {
|
||||
if (open) {
|
||||
const uid = String(open.uid);
|
||||
if (
|
||||
await this.services.notification.notifyUpdate(
|
||||
uid,
|
||||
recipientId,
|
||||
message,
|
||||
{ type: APP_MESSAGE_TYPE, replace: true },
|
||||
)
|
||||
) {
|
||||
return uid;
|
||||
}
|
||||
// Shown or dismissed since the lookup, so this one is new after all.
|
||||
await this.#spendAppMessageLimits(recipientId, appUid, ['day']);
|
||||
}
|
||||
|
||||
return this.#write(recipientId, message, {
|
||||
type: APP_MESSAGE_TYPE,
|
||||
appUid,
|
||||
silent: false,
|
||||
group: message.group ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Both windows are read before either is spent, so a refusal costs none.
|
||||
* Every window named is read before any is spent, so a refusal costs none.
|
||||
* The minute window is spent first: it is the one most likely to run out
|
||||
* between the read and the spend, and a refusal there leaves the day
|
||||
* alone.
|
||||
*/
|
||||
async #withinAppMessageLimits(
|
||||
async #spendAppMessageLimits(
|
||||
userId: number,
|
||||
appUid: string,
|
||||
): Promise<boolean> {
|
||||
spend: readonly AppMessageWindow[],
|
||||
): Promise<void> {
|
||||
const configured = this.config.notificationAppMessageLimits ?? {};
|
||||
const all: Array<[string, number, number]> = [
|
||||
[
|
||||
const all: Record<AppMessageWindow, [string, number, number]> = {
|
||||
minute: [
|
||||
`notif:app-message:minute:${userId}:${appUid}`,
|
||||
configured.perMinute ?? APP_MESSAGE_RATE_LIMITS.perMinute,
|
||||
MINUTE_MS,
|
||||
],
|
||||
[
|
||||
day: [
|
||||
`notif:app-message:day:${userId}:${appUid}`,
|
||||
configured.perDay ?? APP_MESSAGE_RATE_LIMITS.perDay,
|
||||
DAY_MS,
|
||||
],
|
||||
];
|
||||
const windows = all.filter(([, limit]) => limit > 0);
|
||||
};
|
||||
const windows = spend
|
||||
.map((name) => all[name])
|
||||
.filter(([, limit]) => limit > 0);
|
||||
for (const [key, limit, windowMs] of windows) {
|
||||
if (!(await peekRateLimit(key, limit, windowMs))) return false;
|
||||
if (!(await peekRateLimit(key, limit, windowMs))) {
|
||||
throw tooManyAppMessages();
|
||||
}
|
||||
}
|
||||
for (const [key, limit, windowMs] of windows) {
|
||||
if (!(await checkRateLimit(key, limit, windowMs))) return false;
|
||||
if (!(await checkRateLimit(key, limit, windowMs))) {
|
||||
throw tooManyAppMessages();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Writes for `userId`, who need not be the actor, and names the row. */
|
||||
async #write(
|
||||
actor: UserActor,
|
||||
userId: number,
|
||||
value: Record<string, unknown>,
|
||||
opts: {
|
||||
type: NotificationTypeName;
|
||||
appUid: string | null;
|
||||
silent: boolean;
|
||||
group?: string | null;
|
||||
},
|
||||
): Promise<unknown> {
|
||||
): Promise<string> {
|
||||
const uid = await this.services.notification.notify(
|
||||
[actor.user.id],
|
||||
[userId],
|
||||
value,
|
||||
opts,
|
||||
);
|
||||
@@ -392,10 +540,17 @@ export class NotificationDriver extends PuterDriver {
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
}
|
||||
// `notify` hands back only the uid; the primary has the rest.
|
||||
return uid;
|
||||
}
|
||||
|
||||
/** The service hands back only the uid; the primary has the rest. */
|
||||
async #readBack(
|
||||
uid: string,
|
||||
userId: number,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
return this.#toClient(
|
||||
await this.stores.notification.getByUid(uid, {
|
||||
userId: actor.user.id,
|
||||
userId,
|
||||
primary: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,49 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** The code units `\s` matches. */
|
||||
const isSpace = (c: number): boolean =>
|
||||
c === 0x20 ||
|
||||
(c >= 0x09 && c <= 0x0d) ||
|
||||
(c >= 0xa0 &&
|
||||
(c === 0xa0 ||
|
||||
c === 0x1680 ||
|
||||
(c >= 0x2000 && c <= 0x200a) ||
|
||||
c === 0x2028 ||
|
||||
c === 0x2029 ||
|
||||
c === 0x202f ||
|
||||
c === 0x205f ||
|
||||
c === 0x3000 ||
|
||||
c === 0xfeff));
|
||||
|
||||
/** Whitespace runs in `text.slice(0, end)`, one less than `split(/\s+/)` gives. */
|
||||
const countSpaceRuns = (text: string, end: number): number => {
|
||||
let runs = 0;
|
||||
let inSpace = false;
|
||||
for (let i = 0; i < end; i++) {
|
||||
const space = isSpace(text.charCodeAt(i));
|
||||
if (space && !inSpace) runs++;
|
||||
inSpace = space;
|
||||
}
|
||||
return runs;
|
||||
};
|
||||
|
||||
/** How much text is left to scan for word boundaries, and what was seen. */
|
||||
export interface WordScanBudget {
|
||||
chars: number;
|
||||
scannedChars: number;
|
||||
scannedRuns: number;
|
||||
}
|
||||
|
||||
/** About 5 ms of scanning. */
|
||||
const WORD_SCAN_CHARS = 1024 * 1024;
|
||||
|
||||
export const createWordScanBudget = (): WordScanBudget => ({
|
||||
chars: WORD_SCAN_CHARS,
|
||||
scannedChars: 0,
|
||||
scannedRuns: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Tokens a run of text is worth.
|
||||
*
|
||||
@@ -28,11 +71,27 @@
|
||||
* Shared by the chat credit gate, the chat unreported-stream backstop, and
|
||||
* image-prompt pricing, so a calibration moves all of them at once.
|
||||
*
|
||||
* Words are counted over at most `budget.chars` characters, which this call
|
||||
* spends; text past that is given the word density scanned so far. Pass one
|
||||
* budget across a request's parts to bound the scan per request.
|
||||
*
|
||||
* @see https://help.openai.com/en/articles/4936856
|
||||
*/
|
||||
export const estimateTextTokens = (text: string): number => {
|
||||
export const estimateTextTokens = (
|
||||
text: string,
|
||||
budget: WordScanBudget = createWordScanBudget(),
|
||||
): number => {
|
||||
if (!text) return 0;
|
||||
return Math.floor(
|
||||
(text.length / 4 + text.split(/\s+/).length * (4 / 3)) / 2,
|
||||
);
|
||||
const scanned = Math.min(text.length, budget.chars);
|
||||
const runs = countSpaceRuns(text, scanned);
|
||||
budget.chars -= scanned;
|
||||
budget.scannedChars += scanned;
|
||||
budget.scannedRuns += runs;
|
||||
const unscannedRuns =
|
||||
budget.scannedChars > 0
|
||||
? ((text.length - scanned) * budget.scannedRuns) /
|
||||
budget.scannedChars
|
||||
: 0;
|
||||
const words = 1 + runs + unscannedRuns;
|
||||
return Math.floor((text.length / 4 + words * (4 / 3)) / 2);
|
||||
};
|
||||
@@ -238,6 +238,8 @@ describe('WorkerDriver.create with a configured deploy backend', () => {
|
||||
success: true,
|
||||
errors: [],
|
||||
url: `https://${name}.puter.work`,
|
||||
app_uid: null,
|
||||
app_name: null,
|
||||
});
|
||||
|
||||
const [url, init] = putCalls()[0]!;
|
||||
@@ -255,6 +257,165 @@ describe('WorkerDriver.create with a configured deploy backend', () => {
|
||||
expect(Number(row!.root_dir_id)).toBe(entry.id);
|
||||
});
|
||||
|
||||
it('names the app a bound worker runs as, and lists it with the worker', async () => {
|
||||
const { user, actor } = await makeUser();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `bound-${user.username}`,
|
||||
title: `bound-${user.username}`,
|
||||
index_url: `https://bound-${user.username}.example.com/`,
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const path = `/${user.username}/bound.js`;
|
||||
await writeSource(actor, user.id, path, 'export default {}');
|
||||
const name = `bound-${user.username}`;
|
||||
|
||||
const result = await inCtx(actor, () =>
|
||||
target.create({ appId: app.uid, workerName: name, filePath: path }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
app_uid: app.uid,
|
||||
app_name: app.name,
|
||||
});
|
||||
const listed = (
|
||||
(await inCtx(actor, () =>
|
||||
target.getFilePaths({ workerName: name }),
|
||||
)) as Array<{ app_uid: string | null; app_name: string | null }>
|
||||
)[0];
|
||||
expect(listed).toMatchObject({ app_uid: app.uid, app_name: app.name });
|
||||
});
|
||||
|
||||
describe('redeployed under a different app', () => {
|
||||
const seedBound = async () => {
|
||||
const { user, actor } = await makeUser();
|
||||
const makeApp = (label: string) =>
|
||||
server.stores.app.create(
|
||||
{
|
||||
name: `${label}-${user.username}`,
|
||||
title: `${label}-${user.username}`,
|
||||
index_url: `https://${label}-${user.username}.example.com/`,
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const first = await makeApp('first');
|
||||
const second = await makeApp('second');
|
||||
const path = `/${user.username}/rebind.js`;
|
||||
await writeSource(actor, user.id, path, 'v1');
|
||||
const name = `rebind-${user.username}`;
|
||||
const deploy = (appId: string) =>
|
||||
inCtx(actor, () =>
|
||||
target.create({ appId, workerName: name, filePath: path }),
|
||||
);
|
||||
await deploy(first.uid);
|
||||
const firstToken = deployedToken();
|
||||
return {
|
||||
user,
|
||||
actor,
|
||||
path,
|
||||
name,
|
||||
first,
|
||||
second,
|
||||
deploy,
|
||||
firstToken,
|
||||
};
|
||||
};
|
||||
|
||||
const appOf = async (token: string) =>
|
||||
(await server.services.auth.authenticate(token)).actor
|
||||
?.effectiveApp?.uid;
|
||||
|
||||
const listed = async (actor: Actor, name: string) =>
|
||||
(
|
||||
(await inCtx(actor, () =>
|
||||
target.getFilePaths({ workerName: name }),
|
||||
)) as Array<{ app_uid: string | null; app_name: string | null }>
|
||||
)[0];
|
||||
|
||||
it('moves the worker row to the app its new token acts as', async () => {
|
||||
const { actor, name, second, deploy } = await seedBound();
|
||||
|
||||
const result = await deploy(second.uid);
|
||||
|
||||
expect(result).toMatchObject({ app_uid: second.uid });
|
||||
expect(await listed(actor, name)).toMatchObject({
|
||||
app_uid: second.uid,
|
||||
app_name: second.name,
|
||||
});
|
||||
const row = await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
);
|
||||
expect(Number(row!.app_owner)).toBe(second.id);
|
||||
});
|
||||
|
||||
it('hot-reloads as the new app, not the one it was first bound to', async () => {
|
||||
const { user, actor, path, second, deploy } = await seedBound();
|
||||
await deploy(second.uid);
|
||||
const notifySpy = vi.spyOn(server.services.notification, 'notify');
|
||||
|
||||
await writeSource(actor, user.id, path, 'v2');
|
||||
|
||||
await waitFor(
|
||||
() => notifySpy.mock.calls.length > 0,
|
||||
'hot-reload notification',
|
||||
);
|
||||
expect(notifySpy).toHaveBeenCalledWith(
|
||||
[user.id],
|
||||
expect.anything(),
|
||||
expect.objectContaining({ appUid: second.uid }),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the binding when redeployed with no app', async () => {
|
||||
const { actor, name, deploy, firstToken } = await seedBound();
|
||||
|
||||
expect(await deploy('')).toMatchObject({ app_uid: null });
|
||||
expect(await listed(actor, name)).toMatchObject({
|
||||
app_uid: null,
|
||||
app_name: null,
|
||||
});
|
||||
expect(await server.services.auth.authenticate(firstToken)).toEqual(
|
||||
{ invalid: true },
|
||||
);
|
||||
expect(
|
||||
(await server.services.auth.authenticate(deployedToken()))
|
||||
.actor,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('revokes the old app`s token once the new one is deployed', async () => {
|
||||
const { second, deploy, firstToken } = await seedBound();
|
||||
|
||||
await deploy(second.uid);
|
||||
|
||||
expect(await server.services.auth.authenticate(firstToken)).toEqual(
|
||||
{ invalid: true },
|
||||
);
|
||||
expect(await appOf(deployedToken())).toBe(second.uid);
|
||||
});
|
||||
|
||||
it('leaves the old binding and its token alone when the redeploy fails', async () => {
|
||||
const { name, first, second, deploy, firstToken } =
|
||||
await seedBound();
|
||||
fetchSpy.mockResolvedValue(
|
||||
edgeResponse({
|
||||
success: false,
|
||||
errors: [{ message: 'script too large' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await deploy(second.uid)).toMatchObject({ success: false });
|
||||
|
||||
expect(await appOf(firstToken)).toBe(first.uid);
|
||||
const row = await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
);
|
||||
expect(Number(row!.app_owner)).toBe(first.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the puter_auth secret and puter_endpoint binding in the deploy metadata', async () => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/worker.js`;
|
||||
@@ -1319,6 +1480,7 @@ describe('WorkerDriver.getFilePaths source resolution', () => {
|
||||
file_path: string | null;
|
||||
file_uid: string | null;
|
||||
app_uid: string | null;
|
||||
app_name: string | null;
|
||||
created_at: string | null;
|
||||
}>;
|
||||
|
||||
@@ -1328,6 +1490,7 @@ describe('WorkerDriver.getFilePaths source resolution', () => {
|
||||
expect(row.file_path).toBe(path);
|
||||
expect(row.file_uid).toBe(entry.uuid);
|
||||
expect(row.app_uid).toBeNull();
|
||||
expect(row.app_name).toBeNull();
|
||||
expect(row.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
|
||||
@@ -509,7 +509,7 @@ describe('WorkerDriver', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns app_uid for sandboxed/app-bound workers and null for user-scoped workers', async () => {
|
||||
it('returns app_uid and app_name for sandboxed/app-bound workers and null for user-scoped workers', async () => {
|
||||
const tag = `wkuid${Math.random().toString(36).slice(2, 8)}`;
|
||||
const owner = await makeUser(tag);
|
||||
const app = await server.stores.app.create(
|
||||
@@ -538,15 +538,21 @@ describe('WorkerDriver', () => {
|
||||
const seen = (await inCtx(
|
||||
() => target.getFilePaths({}),
|
||||
actorFor(owner),
|
||||
)) as Array<{ name: string; app_uid: string | null }>;
|
||||
)) as Array<{
|
||||
name: string;
|
||||
app_uid: string | null;
|
||||
app_name: string | null;
|
||||
}>;
|
||||
|
||||
const bound = seen.find((r) => r.name === `${tag}-bound`);
|
||||
const plain = seen.find((r) => r.name === `${tag}-plain`);
|
||||
|
||||
expect(bound).toBeDefined();
|
||||
expect(bound?.app_uid).toBe(app.uid);
|
||||
expect(bound?.app_name).toBe(`app-${tag}`);
|
||||
expect(plain).toBeDefined();
|
||||
expect(plain?.app_uid).toBeNull();
|
||||
expect(plain?.app_name).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -613,6 +619,7 @@ describe('WorkerDriver', () => {
|
||||
file_path: dir.path,
|
||||
file_uid: dir.uuid,
|
||||
app_uid: app.uid,
|
||||
app_name: app.name,
|
||||
created_at: expect.any(String),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -635,7 +635,18 @@ export class WorkerDriver extends PuterDriver {
|
||||
runtimePreamble.source + sourceCode,
|
||||
deployTarget,
|
||||
);
|
||||
return cfResult;
|
||||
if (existingSub && cfResult.success === true)
|
||||
await this.#rebindAfterDeploy(existingSub, actor, workerName, {
|
||||
appOwner: appOwnerId ?? null,
|
||||
appUid: boundApp?.uid ?? null,
|
||||
});
|
||||
// The app the worker's token acts as: its KV, AppData, notifications
|
||||
// and pushes are that app's.
|
||||
return {
|
||||
...cfResult,
|
||||
app_uid: boundApp?.uid ?? null,
|
||||
app_name: boundApp?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tear down the caller's worker; a refused edge delete is a 502. */
|
||||
@@ -700,6 +711,35 @@ export class WorkerDriver extends PuterDriver {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A redeploy under a different app (or none) moves the row to the app the
|
||||
* new token acts as, so the listing, hot-reload and `create()` agree, and
|
||||
* revokes the session the old binding ran on. Only once the new script is
|
||||
* live: a failed redeploy leaves the old one running on its old token.
|
||||
*/
|
||||
async #rebindAfterDeploy(
|
||||
row: SubdomainRow,
|
||||
actor: Actor & { user: { id: number } },
|
||||
workerName: string,
|
||||
next: { appOwner: number | null; appUid: string | null },
|
||||
): Promise<void> {
|
||||
const current =
|
||||
row.app_owner === null || row.app_owner === undefined
|
||||
? null
|
||||
: Number(row.app_owner);
|
||||
if (current === next.appOwner) return;
|
||||
await this.stores.subdomain.setAppOwner(
|
||||
String(row.uuid),
|
||||
next.appOwner,
|
||||
{ userId: actor.user.id },
|
||||
);
|
||||
await this.services.auth.deleteWorkerSessionsByName(
|
||||
workerName,
|
||||
actor.user.id,
|
||||
{ exceptAppUid: next.appUid },
|
||||
);
|
||||
}
|
||||
|
||||
/** Script, sessions, then row: a failed step leaves the row for a retry. */
|
||||
async #teardown(
|
||||
row: SubdomainRow,
|
||||
@@ -827,17 +867,17 @@ export class WorkerDriver extends PuterDriver {
|
||||
file_path = loaded?.path;
|
||||
file_uid = loaded?.uuid;
|
||||
}
|
||||
let app_uid = null;
|
||||
if (typeof r.app_owner === 'number') {
|
||||
const loadedApp = appsById.get(r.app_owner);
|
||||
app_uid = loadedApp?.uid ?? null;
|
||||
}
|
||||
const loadedApp =
|
||||
typeof r.app_owner === 'number'
|
||||
? appsById.get(r.app_owner)
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
url: `https://${name}.puter.work`,
|
||||
file_path,
|
||||
file_uid,
|
||||
app_uid,
|
||||
app_uid: loadedApp?.uid ?? null,
|
||||
app_name: loadedApp?.name ?? null,
|
||||
created_at: r.ts
|
||||
? new Date(r.ts as string).toISOString()
|
||||
: null,
|
||||
@@ -1147,15 +1187,18 @@ export class WorkerDriver extends PuterDriver {
|
||||
async #resolveWorkerAppBinding(
|
||||
actor: Actor & { user: { id: number } },
|
||||
requestedAppUid?: string,
|
||||
): Promise<{ uid: string; id?: number } | null> {
|
||||
): Promise<{ uid: string; id?: number; name: string | null } | null> {
|
||||
// Self-binding, either implicit or named. Not every actor shape carries
|
||||
// `app.id`, so fall back to a lookup rather than leaving the subdomain
|
||||
// row unowned.
|
||||
// `app.id`, and none carries the name, so both come from the row.
|
||||
const ownApp = actor.effectiveApp;
|
||||
const selfBinding = async () => ({
|
||||
uid: ownApp!.uid,
|
||||
id: ownApp!.id ?? (await this.stores.app.getByUid(ownApp!.uid))?.id,
|
||||
});
|
||||
const selfBinding = async () => {
|
||||
const row = await this.stores.app.getByUid(ownApp!.uid);
|
||||
return {
|
||||
uid: ownApp!.uid,
|
||||
id: ownApp!.id ?? row?.id,
|
||||
name: row?.name ?? null,
|
||||
};
|
||||
};
|
||||
if (!requestedAppUid) {
|
||||
return ownApp ? await selfBinding() : null;
|
||||
}
|
||||
@@ -1168,7 +1211,11 @@ export class WorkerDriver extends PuterDriver {
|
||||
// Root session: unchanged: bind to whatever it names. An unknown
|
||||
// uid still mints a token, as it did before, and simply leaves the
|
||||
// subdomain row unowned by any app.
|
||||
return { uid: requestedAppUid, id: app?.id };
|
||||
return {
|
||||
uid: requestedAppUid,
|
||||
id: app?.id,
|
||||
name: app?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!app || !(await this.#appIsOwnedByActorApp(app.id, actor))) {
|
||||
@@ -1176,7 +1223,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
return { uid: app.uid, id: app.id };
|
||||
return { uid: app.uid, id: app.id, name: app.name ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.24.6",
|
||||
"nodemailer": "^9.1.1",
|
||||
"nodemailer": "^10.1.4",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
"pg": "^8.21.0",
|
||||
@@ -67,7 +67,8 @@
|
||||
"ua-parser-js": "^1.0.41",
|
||||
"undici": "^7.30.1",
|
||||
"uuid": "^14.0.3",
|
||||
"validator": "^13.15.35"
|
||||
"validator": "^13.15.35",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
@@ -81,6 +82,7 @@
|
||||
"@types/pg": "^8.6.1",
|
||||
"@types/smtp-server": "^3.5.13",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/ws": "^8.18.2",
|
||||
"pgmock": "^1.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.1.6",
|
||||
|
||||
@@ -36,7 +36,11 @@ import { Controller, Get } from './core/http/decorators.ts';
|
||||
import { HttpError } from './core/http/HttpError.ts';
|
||||
import { extension, extensionStore } from './extensions.ts';
|
||||
import { PuterServer } from './server.ts';
|
||||
import { allocateEphemeralPort, setupTestServer } from './testUtil.ts';
|
||||
import {
|
||||
allocateEphemeralPort,
|
||||
createTestUser,
|
||||
setupTestServer,
|
||||
} from './testUtil.ts';
|
||||
import type { IConfig } from './types';
|
||||
|
||||
/**
|
||||
@@ -523,6 +527,7 @@ describe('PuterServer host header validation — custom domains with every host
|
||||
describe('PuterServer request parsing', () => {
|
||||
let server: PuterServer;
|
||||
let port: number;
|
||||
let token: string;
|
||||
const routeCount = { before: 0 };
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -551,6 +556,10 @@ describe('PuterServer request parsing', () => {
|
||||
} as unknown as IConfig,
|
||||
{ listen: true },
|
||||
);
|
||||
({ token } = await createTestUser(server, {
|
||||
username: 'large_body_user',
|
||||
password: 'large-body-password',
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -560,6 +569,85 @@ describe('PuterServer request parsing', () => {
|
||||
|
||||
const host = () => `puter.localhost:${port}`;
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const postJson = (
|
||||
path: string,
|
||||
payload: string,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
rawRequest(
|
||||
port,
|
||||
path,
|
||||
{
|
||||
host: `api.puter.localhost:${port}`,
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(payload)),
|
||||
...headers,
|
||||
},
|
||||
'POST',
|
||||
payload,
|
||||
);
|
||||
|
||||
it.each([
|
||||
'/drivers/call',
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/anthropic/v1/messages',
|
||||
'/puterai/anthropic/v1/messages/count_tokens',
|
||||
])(
|
||||
'keeps the default JSON limit on %s without a credential',
|
||||
async (path) => {
|
||||
const payload = JSON.stringify({ text: 'x'.repeat(51 * MiB) });
|
||||
expect((await postJson(path, payload)).status).toBe(413);
|
||||
},
|
||||
);
|
||||
|
||||
it('parses a chat call above 50 MiB for a signed-in caller', async () => {
|
||||
const payload = JSON.stringify({
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'no-such-method',
|
||||
pad: 'x'.repeat(51 * MiB),
|
||||
});
|
||||
for (const contentType of [
|
||||
'application/json',
|
||||
'text/plain;actually=json',
|
||||
]) {
|
||||
const res = await postJson('/drivers/call', payload, {
|
||||
'content-type': contentType,
|
||||
authorization: `Bearer ${token}`,
|
||||
});
|
||||
// The controller read `method` out of the body.
|
||||
expect(res.status, contentType).toBe(404);
|
||||
expect(res.body).toContain('no-such-method');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the default JSON limit for other drivers', async () => {
|
||||
const payload = JSON.stringify({
|
||||
interface: 'puter-kvstore',
|
||||
method: 'get',
|
||||
pad: 'x'.repeat(51 * MiB),
|
||||
});
|
||||
// Routing matches these spellings too.
|
||||
for (const path of ['/drivers/call', '/Drivers/Call/']) {
|
||||
const res = await postJson(path, payload, {
|
||||
authorization: `Bearer ${token}`,
|
||||
});
|
||||
expect(res.status, path).toBe(413);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/drivers/call', 128],
|
||||
['/puterai/anthropic/v1/messages', 128],
|
||||
['/raw-probe', 50],
|
||||
])('rejects bodies above the JSON limit on %s', async (path, limitMiB) => {
|
||||
const payload = JSON.stringify({ text: 'x'.repeat(limitMiB * MiB) });
|
||||
const res = await postJson(path, payload, {
|
||||
authorization: `Bearer ${token}`,
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
|
||||
it('parses the user agent the first time a handler reads it', async () => {
|
||||
const agent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36';
|
||||
@@ -1400,7 +1488,7 @@ describe('PuterServer keep-alive timeout', () => {
|
||||
});
|
||||
|
||||
describe('PuterServer lifecycle hooks', () => {
|
||||
it('runs prepare-shutdown bottom-up, once, before tearing down', async () => {
|
||||
it('runs drain-start and prepare-shutdown bottom-up, once, before tearing down', async () => {
|
||||
const port = await allocateEphemeralPort();
|
||||
const server = await setupTestServer({ port } as unknown as IConfig, {
|
||||
listen: true,
|
||||
@@ -1408,6 +1496,9 @@ describe('PuterServer lifecycle hooks', () => {
|
||||
|
||||
const order: string[] = [];
|
||||
const probe = (label: string) => ({
|
||||
onServerDrainStart: (info?: { drainMs?: number }) => {
|
||||
order.push(`drain:${label}:${info?.drainMs}`);
|
||||
},
|
||||
onServerPrepareShutdown: () => {
|
||||
order.push(`prepare:${label}`);
|
||||
},
|
||||
@@ -1425,10 +1516,15 @@ describe('PuterServer lifecycle hooks', () => {
|
||||
(layer as Record<string, unknown>).hookOrderProbe = probe(label);
|
||||
}
|
||||
|
||||
await server.prepareShutdown();
|
||||
await server.prepareShutdown({ drainMs: 1_000 });
|
||||
await server.shutdown();
|
||||
|
||||
expect(order).toEqual([
|
||||
'drain:client:1000',
|
||||
'drain:store:1000',
|
||||
'drain:service:1000',
|
||||
'drain:controller:1000',
|
||||
'drain:driver:1000',
|
||||
'prepare:client',
|
||||
'prepare:store',
|
||||
'prepare:service',
|
||||
|
||||
+73
-13
@@ -36,6 +36,10 @@ import { createAuthProbe } from './core/http/middleware/authProbe';
|
||||
import { createRequestContextMiddleware } from './core/http/middleware/requestContext';
|
||||
import { createFingerprintMiddleware } from './core/http/middleware/fingerprint';
|
||||
import { createErrorHandler } from './core/http/middleware/errorHandler';
|
||||
import {
|
||||
createLargeJsonBodyParser,
|
||||
DEFAULT_JSON_LIMIT_BYTES,
|
||||
} from './core/http/middleware/largeJsonBody';
|
||||
import { actorUid, type Actor } from './core/actor';
|
||||
import { isHttpError } from './core/http/HttpError';
|
||||
import {
|
||||
@@ -117,6 +121,7 @@ import type {
|
||||
IConfig,
|
||||
LayerInstances,
|
||||
PagerSeverity,
|
||||
ServerDrainInfo,
|
||||
WithControllerRegistration,
|
||||
WithLifecycle,
|
||||
} from './types';
|
||||
@@ -124,6 +129,33 @@ import type {
|
||||
/** Idle keep-alive timeout used when `keep_alive_timeout` is unset. */
|
||||
const DEFAULT_KEEP_ALIVE_TIMEOUT = 920_000;
|
||||
|
||||
/**
|
||||
* AI request routes, which take bodies up to the size Claude's Messages API
|
||||
* accepts from a signed-in caller. puter.ai.chat goes through `/drivers/call`.
|
||||
*/
|
||||
const LARGE_JSON_ROUTES = [
|
||||
'/drivers/call',
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/openai/v1/completions',
|
||||
'/puterai/openai/v1/responses',
|
||||
'/puterai/anthropic/v1/messages',
|
||||
'/puterai/anthropic/v1/messages/count_tokens',
|
||||
];
|
||||
const LARGE_JSON_LIMIT_BYTES = 128 * 1024 * 1024;
|
||||
/** Two max-size bodies per process. */
|
||||
const LARGE_JSON_BUDGET_BYTES = 2 * LARGE_JSON_LIMIT_BYTES;
|
||||
|
||||
/**
|
||||
* Through `/drivers/call`, only chat takes a large body; other drivers keep the
|
||||
* default limit. Keyed on the body rather than the path, which routing matches
|
||||
* loosely (case, trailing slash). Wire API bodies carry no `interface`.
|
||||
*/
|
||||
const acceptsLargeJson = (req: Request): boolean => {
|
||||
const iface = (req.body as { interface?: unknown } | null | undefined)
|
||||
?.interface;
|
||||
return iface === undefined || iface === 'puter-chat-completion';
|
||||
};
|
||||
|
||||
/** Route options a claimed subdomain's routes may use. */
|
||||
const CLAIMED_ROUTE_OPTIONS = new Set([
|
||||
'subdomain',
|
||||
@@ -524,10 +556,34 @@ export class PuterServer {
|
||||
// body-parser hands over a fresh buffer and never writes to it.
|
||||
(req as { rawBody?: Buffer }).rawBody = buf;
|
||||
};
|
||||
this.#app.use(express.json({ limit: '50mb', verify: captureRawBody }));
|
||||
const authService = this.services.auth as AuthService | undefined;
|
||||
const authProbe = authService
|
||||
? createAuthProbe({
|
||||
authService,
|
||||
cookieName: this.#config.cookie_name,
|
||||
})
|
||||
: undefined;
|
||||
if (authProbe) {
|
||||
this.#app.post(
|
||||
LARGE_JSON_ROUTES,
|
||||
createLargeJsonBodyParser({
|
||||
authProbe,
|
||||
limitBytes: LARGE_JSON_LIMIT_BYTES,
|
||||
budgetBytes: LARGE_JSON_BUDGET_BYTES,
|
||||
perUserBytes: LARGE_JSON_LIMIT_BYTES,
|
||||
accepts: acceptsLargeJson,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.#app.use(
|
||||
express.json({
|
||||
limit: '50mb',
|
||||
limit: DEFAULT_JSON_LIMIT_BYTES,
|
||||
verify: captureRawBody,
|
||||
}),
|
||||
);
|
||||
this.#app.use(
|
||||
express.json({
|
||||
limit: DEFAULT_JSON_LIMIT_BYTES,
|
||||
type: (req) =>
|
||||
req.headers['content-type'] === 'text/plain;actually=json',
|
||||
verify: captureRawBody,
|
||||
@@ -541,14 +597,8 @@ export class PuterServer {
|
||||
this.#app.use(express.urlencoded({ extended: true, limit: '100kb' }));
|
||||
|
||||
// -- Auth probe ----------------------------------------------
|
||||
const authService = this.services.auth as AuthService | undefined;
|
||||
if (authService) {
|
||||
this.#app.use(
|
||||
createAuthProbe({
|
||||
authService,
|
||||
cookieName: this.#config.cookie_name,
|
||||
}),
|
||||
);
|
||||
if (authProbe) {
|
||||
this.#app.use(authProbe);
|
||||
}
|
||||
|
||||
// -- Request fingerprints ------------------------------------
|
||||
@@ -1693,7 +1743,12 @@ export class PuterServer {
|
||||
*/
|
||||
async #runHook(
|
||||
order: 'bottomUp' | 'topDown',
|
||||
hook: 'onServerStart' | 'onServerPrepareShutdown' | 'onServerShutdown',
|
||||
hook:
|
||||
| 'onServerStart'
|
||||
| 'onServerDrainStart'
|
||||
| 'onServerPrepareShutdown'
|
||||
| 'onServerShutdown',
|
||||
info?: ServerDrainInfo,
|
||||
) {
|
||||
const layers = [
|
||||
this.clients,
|
||||
@@ -1705,7 +1760,7 @@ export class PuterServer {
|
||||
if (order === 'topDown') layers.reverse();
|
||||
for (const layer of layers) {
|
||||
for (const instance of Object.values(layer) as WithLifecycle[]) {
|
||||
await instance[hook]?.();
|
||||
await instance[hook]?.(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1722,8 +1777,13 @@ export class PuterServer {
|
||||
await this.#runHook('bottomUp', 'onServerPrepareShutdown');
|
||||
}
|
||||
|
||||
async prepareShutdown() {
|
||||
async prepareShutdown(info: ServerDrainInfo = {}) {
|
||||
if (this.#server) {
|
||||
try {
|
||||
await this.#runHook('bottomUp', 'onServerDrainStart', info);
|
||||
} catch (err) {
|
||||
console.error('[server] onServerDrainStart failed', err);
|
||||
}
|
||||
this.#server.close(async () => {
|
||||
console.log(
|
||||
'PuterServer has stopped accepting new connections',
|
||||
|
||||
@@ -2041,6 +2041,45 @@ describe('AuthService (integration)', () => {
|
||||
);
|
||||
expect((await authService.authenticate(token)).actor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('records no activity for an events handler`s calls', async () => {
|
||||
const user = await makeUser();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `ev-app-${uuidv4()}`,
|
||||
title: 'Events app',
|
||||
index_url: `https://${uuidv4()}.example.com/`,
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const handler = await authService.createWorkerAppToken(
|
||||
makeActor({ user }),
|
||||
app.uid,
|
||||
EVENTS_WORKER_SESSION_NAME,
|
||||
{ handlerDepth: 1, expiresInSeconds: 900 },
|
||||
);
|
||||
const interactive = await authService.getUserAppToken(
|
||||
makeActor({ user }),
|
||||
app.uid,
|
||||
);
|
||||
|
||||
const touch = vi.spyOn(server.stores.session, 'touch');
|
||||
try {
|
||||
const ctx = { ip: '9.9.9.9', userAgent: 'worker-runtime' };
|
||||
expect(
|
||||
(await authService.authenticate(handler, ctx)).actor,
|
||||
).toBeTruthy();
|
||||
expect(touch).not.toHaveBeenCalled();
|
||||
|
||||
// The same app in a tab is the user being active.
|
||||
expect(
|
||||
(await authService.authenticate(interactive, ctx)).actor,
|
||||
).toBeTruthy();
|
||||
expect(touch).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
touch.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting worker sessions', () => {
|
||||
@@ -2722,6 +2761,48 @@ describe('AuthService (integration)', () => {
|
||||
authService.subdomainOwnerIdFromOrigin('not-a-url'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a custom domain to its site owner', async () => {
|
||||
const user = await makeUser();
|
||||
const subdomain = `own-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: user.id,
|
||||
subdomain,
|
||||
});
|
||||
const host = `cd-${uuidv4().slice(0, 8)}.example.com`;
|
||||
const resolveHost = (
|
||||
_key: unknown,
|
||||
data: { host: string; result: { subdomain: string | null } },
|
||||
) => {
|
||||
if (data.host === host) data.result.subdomain = subdomain;
|
||||
};
|
||||
const config = (
|
||||
authService as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
config.custom_domains_enabled = true;
|
||||
server.clients.event.on('site.resolveHost', resolveHost);
|
||||
try {
|
||||
await expect(
|
||||
authService.subdomainOwnerIdFromOrigin(`https://${host}`),
|
||||
).resolves.toBe(user.id);
|
||||
// A private app can't take another user's custom domain.
|
||||
await expect(
|
||||
authService.originHeldByOtherUser(
|
||||
`https://${host}/`,
|
||||
user.id + 1,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
authService.originHeldByOtherUser(
|
||||
`https://${host}/`,
|
||||
user.id,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
} finally {
|
||||
server.clients.event.off('site.resolveHost', resolveHost);
|
||||
delete config.custom_domains_enabled;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('app origin blocklist enforcement', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { WEB_AND_EXTENSION_PROTOCOLS } from '../../util/validation.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import { appGenerationStart } from '../../util/appGeneration.js';
|
||||
import {
|
||||
ASSET_WINDOW_SECONDS,
|
||||
GODMODE_TOKEN_MAX_AGE_SECONDS,
|
||||
@@ -39,6 +40,7 @@ import type { UserRow } from '../../stores/user/UserStore';
|
||||
import type { LayerInstances } from '../../types';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { isGodmodeApp } from '../../util/godmodeApps.js';
|
||||
import { resolveCustomHostSubdomain } from '../../util/hostedAppBacking.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
import type { puterServices } from '../index';
|
||||
import { EVENTS_WORKER_SESSION_NAME } from '../events/workerRuntime.js';
|
||||
@@ -56,9 +58,6 @@ import type {
|
||||
const APP_ORIGIN_UUID_NAMESPACE = '33de3768-8ee0-43e9-9e73-db192b97a5d8';
|
||||
// Successor uids an origin can derive after its earlier apps were repointed.
|
||||
const MAX_ORIGIN_UID_GENERATIONS = 8;
|
||||
// Allowed gap between a session's creation time (server clock) and its app's
|
||||
// (database clock) before the session counts as older than the app.
|
||||
const APP_SESSION_CLOCK_SKEW_SECONDS = 60;
|
||||
|
||||
// Revoked and expired sessions are kept this long, so a stale token still gets
|
||||
// `session_revoked`/`session_expired` rather than `invalid`, then deleted. Web
|
||||
@@ -561,7 +560,7 @@ export class AuthService extends PuterService {
|
||||
appUid,
|
||||
workerName,
|
||||
auth_id,
|
||||
notBefore: this.#appSessionsNotBefore(app),
|
||||
notBefore: appGenerationStart(app),
|
||||
},
|
||||
);
|
||||
if (!session) {
|
||||
@@ -952,13 +951,18 @@ export class AuthService extends PuterService {
|
||||
for (const entry of revoked) this.#announceRevocation(entry);
|
||||
}
|
||||
|
||||
/** Delete the owner's sessions for a worker that no longer exists. */
|
||||
/**
|
||||
* Delete the owner's sessions for a worker that no longer exists, or, with
|
||||
* `exceptAppUid`, the ones a worker rebound to that app (`null`: to none)
|
||||
* no longer runs on.
|
||||
*/
|
||||
async deleteWorkerSessionsByName(
|
||||
workerName: string,
|
||||
userId: number,
|
||||
{ exceptAppUid }: { exceptAppUid?: string | null } = {},
|
||||
): Promise<void> {
|
||||
if (!workerName || !userId) return;
|
||||
await this.#deleteWorkerSessions({ workerName, userId });
|
||||
await this.#deleteWorkerSessions({ workerName, userId, exceptAppUid });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1014,6 +1018,7 @@ export class AuthService extends PuterService {
|
||||
workerName?: string;
|
||||
userId?: number;
|
||||
appUids?: string[];
|
||||
exceptAppUid?: string | null;
|
||||
}): Promise<void> {
|
||||
const deleted = (await this.stores.session.deleteWorkerSessions(
|
||||
filter,
|
||||
@@ -1360,13 +1365,13 @@ export class AuthService extends PuterService {
|
||||
* Resolve the user who owns the hosted subdomain `origin` points at.
|
||||
* Returns the `subdomains` row's `user_id` when the origin's host sits
|
||||
* under a configured hosting domain (`static_hosting_domain(_alt)` /
|
||||
* `private_app_hosting_domain(_alt)`) and the subdomain is registered; null
|
||||
* for external origins, apex hosting-domain hosts, and unknown subdomains.
|
||||
* Used to stamp the site owner as the creator of origin-bootstrap app
|
||||
* rows.
|
||||
* `private_app_hosting_domain(_alt)`), or is a custom domain serving a
|
||||
* site, and the subdomain is registered; null for external origins, apex
|
||||
* hosting-domain hosts, and unknown subdomains. Used to stamp the site
|
||||
* owner as the creator of origin-bootstrap app rows.
|
||||
*/
|
||||
async subdomainOwnerIdFromOrigin(origin: string): Promise<number | null> {
|
||||
const subdomain = this.#hostedSubdomainForOrigin(origin);
|
||||
const subdomain = await this.#siteSubdomainForOrigin(origin);
|
||||
if (!subdomain) return null;
|
||||
const row = await this.stores.subdomain.getBySubdomain(subdomain);
|
||||
return positiveUserId(row?.user_id);
|
||||
@@ -1385,7 +1390,9 @@ export class AuthService extends PuterService {
|
||||
): Promise<boolean> {
|
||||
const origin = await this.#appOriginFor(indexUrl);
|
||||
if (!origin) return false;
|
||||
const siteOwnerId = await this.#siteOwnerIdForOrigin(origin);
|
||||
const siteOwnerId = await this.#siteOwnerIdForSubdomain(
|
||||
await this.#siteSubdomainForOrigin(origin),
|
||||
);
|
||||
if (siteOwnerId !== null) return siteOwnerId !== ownerUserId;
|
||||
|
||||
const path = new URL(indexUrl).pathname.toLowerCase();
|
||||
@@ -1398,12 +1405,20 @@ export class AuthService extends PuterService {
|
||||
return holderId !== null && holderId !== ownerUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link subdomainOwnerIdFromOrigin}, confirmed against the primary on a
|
||||
* miss so a site registered a moment ago isn't read as unregistered.
|
||||
*/
|
||||
/** Owner of the hosted site `origin` is on; custom domains aren't asked. */
|
||||
async #siteOwnerIdForOrigin(origin: string): Promise<number | null> {
|
||||
const subdomain = this.#hostedSubdomainForOrigin(origin);
|
||||
return this.#siteOwnerIdForSubdomain(
|
||||
this.#hostedSubdomainForOrigin(origin),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The site's owner, confirmed against the primary on a miss so a site
|
||||
* registered a moment ago isn't read as unregistered.
|
||||
*/
|
||||
async #siteOwnerIdForSubdomain(
|
||||
subdomain: string | null,
|
||||
): Promise<number | null> {
|
||||
if (!subdomain) return null;
|
||||
const row =
|
||||
(await this.stores.subdomain.getBySubdomain(subdomain)) ??
|
||||
@@ -1413,6 +1428,25 @@ export class AuthService extends PuterService {
|
||||
return positiveUserId(row?.user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site `origin` serves: a hosted subdomain, or a custom domain's site.
|
||||
* Custom domains cost a listener round trip, so identity resolution
|
||||
* (`appUidFromOrigin` and its batch) stays on hosted names only.
|
||||
*/
|
||||
async #siteSubdomainForOrigin(origin: string): Promise<string | null> {
|
||||
const hosted = this.#hostedSubdomainForOrigin(origin);
|
||||
if (hosted) return hosted;
|
||||
const parsed = URL.parse(origin);
|
||||
if (parsed?.protocol !== 'https:' && parsed?.protocol !== 'http:') {
|
||||
return null;
|
||||
}
|
||||
return resolveCustomHostSubdomain(
|
||||
parsed.hostname,
|
||||
this.config,
|
||||
this.clients.event,
|
||||
);
|
||||
}
|
||||
|
||||
#hostedSubdomainForOrigin(origin: string): string | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -1778,7 +1812,7 @@ export class AuthService extends PuterService {
|
||||
appUid,
|
||||
{
|
||||
auth_id: this.#authIdFor(actor.user as UserRow),
|
||||
notBefore: this.#appSessionsNotBefore(app),
|
||||
notBefore: appGenerationStart(app),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1836,7 +1870,7 @@ export class AuthService extends PuterService {
|
||||
appUid: app.uid,
|
||||
expiresAt: nowSeconds() + GODMODE_TOKEN_WINDOW_SECONDS,
|
||||
maxAgeSeconds: GODMODE_TOKEN_MAX_AGE_SECONDS,
|
||||
label: app.title || app.name || null,
|
||||
label: app.title || app.name!,
|
||||
auth_id: authId,
|
||||
},
|
||||
);
|
||||
@@ -1846,7 +1880,7 @@ export class AuthService extends PuterService {
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
}
|
||||
if (minted.replaced) await this.#retireGodmodeToken(minted.replaced);
|
||||
if (minted?.replaced) await this.#retireGodmodeToken(minted.replaced);
|
||||
|
||||
const token = this.services.token.sign('auth', {
|
||||
type: 'access-token',
|
||||
@@ -2655,14 +2689,17 @@ export class AuthService extends PuterService {
|
||||
// uid returns with the next app on that origin. A session older than
|
||||
// the app was made for the earlier one. Checked before `touch()` so a
|
||||
// rejected token doesn't slide the old session's expiry.
|
||||
const notBefore = this.#appSessionsNotBefore(app);
|
||||
const notBefore = appGenerationStart(app);
|
||||
const createdAt = Number(session.created_at);
|
||||
if (notBefore !== null && createdAt > 0 && createdAt < notBefore) {
|
||||
return { reauth: { reason: 'session_revoked' } };
|
||||
}
|
||||
if (!(await deployed)) return { invalid: true };
|
||||
|
||||
void this.#touchSession(session.uuid, user.id, ctx);
|
||||
// An events handler's calls are background work, not the user being
|
||||
// active, and its session never expires, so they record no activity.
|
||||
if (decoded.worker_name !== EVENTS_WORKER_SESSION_NAME)
|
||||
void this.#touchSession(session.uuid, user.id, ctx);
|
||||
|
||||
const actor = this.#buildAppUnderUserActor(user, app, session);
|
||||
this.#applyHandlerDepth(actor, decoded);
|
||||
@@ -2684,19 +2721,6 @@ export class AuthService extends PuterService {
|
||||
return block.blocked ? { reason: block.reason } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix seconds before which an app or worker session for `app` belongs to
|
||||
* an earlier app with the same uid; null when the creation time is
|
||||
* unknown.
|
||||
*/
|
||||
#appSessionsNotBefore(
|
||||
app: { created_epoch?: unknown } | null | undefined,
|
||||
): number | null {
|
||||
const created = Number(app?.created_epoch);
|
||||
if (!Number.isFinite(created) || created <= 0) return null;
|
||||
return created - APP_SESSION_CLOCK_SKEW_SECONDS;
|
||||
}
|
||||
|
||||
#createdBefore(row: SessionRow | null, notBefore: number | null): boolean {
|
||||
if (notBefore === null || !row) return false;
|
||||
const createdAt = Number(row.created_at);
|
||||
@@ -2772,7 +2796,7 @@ export class AuthService extends PuterService {
|
||||
if (blocked) return { blocked };
|
||||
// As with app sessions: a token minted under an earlier app that
|
||||
// held this uid doesn't authenticate as the current one.
|
||||
const notBefore = this.#appSessionsNotBefore(app);
|
||||
const notBefore = appGenerationStart(app);
|
||||
if (
|
||||
this.#createdBefore(session, notBefore) ||
|
||||
this.#createdBefore(parentSession, notBefore)
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
|
||||
import { EVENTS_COALESCE_WINDOW_MS } from '../../controllers/events/limits.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { createTestUser, setupTestServer } from '../../testUtil.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupTestServer,
|
||||
type TestUserCredentials,
|
||||
} from '../../testUtil.js';
|
||||
import type { DeliveryEnvelope } from '../events/EventsService.js';
|
||||
import {
|
||||
MAIL_CONTENT_TYPE,
|
||||
copyIntoInbox,
|
||||
@@ -28,6 +36,7 @@ const UUID_V7 =
|
||||
// which methods were called.
|
||||
let server: PuterServer;
|
||||
let dan: UserRow;
|
||||
let danCredentials: TestUserCredentials;
|
||||
|
||||
// `frankfurt` is wired to the same bucket the test server already has, so a
|
||||
// message filed for a Frankfurt account lands somewhere real and the region it
|
||||
@@ -42,8 +51,15 @@ beforeAll(async () => {
|
||||
bucketRegion: FRANKFURT_REGION,
|
||||
},
|
||||
},
|
||||
events: { enabled: true },
|
||||
// Seeded accounts have no email, which plans read as a temporary
|
||||
// account that holds no persistent subscriptions.
|
||||
unlimitedMetering: true,
|
||||
} as never);
|
||||
await createTestUser(server, { username: 'dan', password: 'secret123!' });
|
||||
danCredentials = await createTestUser(server, {
|
||||
username: 'dan',
|
||||
password: 'secret123!',
|
||||
});
|
||||
dan = (await server.stores.user.getByUsername('dan')) as UserRow;
|
||||
});
|
||||
|
||||
@@ -228,3 +244,83 @@ describe('copyIntoInbox', () => {
|
||||
expect(copied.size).toBe(source.size);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the filesystem events filing raises', () => {
|
||||
let actor: Actor;
|
||||
let subId: string;
|
||||
const delivered: DeliveryEnvelope[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
actor = (await server.services.auth.authenticate(danCredentials.token))
|
||||
.actor!;
|
||||
({
|
||||
sub: { subId },
|
||||
} = await server.services.events.subscribeDurable(actor, {
|
||||
subject: `fs:${mailboxPath('dan')}`,
|
||||
}));
|
||||
server.services.events.onDelivered = (envelope) =>
|
||||
delivered.push(envelope);
|
||||
});
|
||||
|
||||
const deliveredAt = (path: string) =>
|
||||
vi.waitFor(
|
||||
() => {
|
||||
const found = delivered.find(
|
||||
(envelope) =>
|
||||
envelope.subId === subId &&
|
||||
(envelope.event as { path?: string }).path === path,
|
||||
);
|
||||
expect(found).toBeDefined();
|
||||
return found!.event as { op: string; self: boolean };
|
||||
},
|
||||
{ timeout: EVENTS_COALESCE_WINDOW_MS * 12, interval: 25 },
|
||||
);
|
||||
|
||||
test("is not the owner's own change, even inside the owner's request", async () => {
|
||||
const entry = await runWithContext({ actor }, () =>
|
||||
storeInboxMessage(server.services.fs, dan, {
|
||||
subject: 'to myself',
|
||||
content: Buffer.from('raw'),
|
||||
size: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await deliveredAt(entry.path)).toMatchObject({
|
||||
op: 'add',
|
||||
self: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('nor is a message copied in', async () => {
|
||||
const source = await storeInboxMessage(server.services.fs, dan, {
|
||||
content: Buffer.from('original'),
|
||||
size: 8,
|
||||
day: '2021-01-01',
|
||||
name: `source-${uuidv4()}`,
|
||||
});
|
||||
|
||||
const copied = await runWithContext({ actor }, () =>
|
||||
copyIntoInbox(server.services.fs, dan, {
|
||||
source,
|
||||
name: `copied-${uuidv4()}`,
|
||||
day: '2021-01-02',
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await deliveredAt(copied.path)).self).toBe(false);
|
||||
});
|
||||
|
||||
test("while the owner's own write in the same request still is", async () => {
|
||||
const { fsEntry } = await runWithContext({ actor }, () =>
|
||||
server.services.fs.write(dan.id, {
|
||||
fileMetadata: {
|
||||
path: `${mailboxPath('dan')}/note-${uuidv4()}.txt`,
|
||||
size: 1,
|
||||
},
|
||||
fileContent: Buffer.from('x'),
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await deliveredAt(fsEntry.path)).self).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Readable } from 'node:stream';
|
||||
import { Context, runInDerivedContext } from '../../core/context.js';
|
||||
import type { FSService } from '../fs/FSService.js';
|
||||
import { resolveHomeRegion } from '../fs/homeRegion.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
@@ -133,6 +134,17 @@ export interface InboxMessage {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a mailbox write as a delivery to the owner rather than a write by
|
||||
* whoever's request it is in, so filing is never the holder's own change
|
||||
* (`self`), even for mail the owner sent to itself.
|
||||
*/
|
||||
const asDelivery = <T>(file: () => Promise<T>): Promise<T> =>
|
||||
runInDerivedContext(() => {
|
||||
Context.set('platformWrite', true);
|
||||
return file();
|
||||
});
|
||||
|
||||
/**
|
||||
* File one message in the owner's inbox. Written into the owner's home region
|
||||
* rather than wherever the request landed, so the mailbox reads locally.
|
||||
@@ -144,22 +156,24 @@ export async function storeInboxMessage(
|
||||
): Promise<FSEntry> {
|
||||
const day = message.day ?? mailDay();
|
||||
const name = message.name ?? mailObjectName(message.subject);
|
||||
const { fsEntry } = await fs.write(
|
||||
owner.id,
|
||||
{
|
||||
fileMetadata: {
|
||||
path: `${mailFolderPath(owner.username, 'objects', day)}/${name}`,
|
||||
size: message.size,
|
||||
contentType: MAIL_CONTENT_TYPE,
|
||||
createMissingParents: true,
|
||||
overwrite: false,
|
||||
dedupeName: false,
|
||||
const { fsEntry } = await asDelivery(() =>
|
||||
fs.write(
|
||||
owner.id,
|
||||
{
|
||||
fileMetadata: {
|
||||
path: `${mailFolderPath(owner.username, 'objects', day)}/${name}`,
|
||||
size: message.size,
|
||||
contentType: MAIL_CONTENT_TYPE,
|
||||
createMissingParents: true,
|
||||
overwrite: false,
|
||||
dedupeName: false,
|
||||
},
|
||||
fileContent: message.content,
|
||||
},
|
||||
fileContent: message.content,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
resolveHomeRegion(owner),
|
||||
undefined,
|
||||
undefined,
|
||||
resolveHomeRegion(owner),
|
||||
),
|
||||
);
|
||||
return fsEntry;
|
||||
}
|
||||
@@ -174,17 +188,19 @@ export async function copyIntoInbox(
|
||||
owner: UserRow,
|
||||
message: { source: FSEntry; name: string; day?: string },
|
||||
): Promise<FSEntry> {
|
||||
const parent = await fs.mkdir(owner.id, {
|
||||
path: mailFolderPath(
|
||||
owner.username,
|
||||
'objects',
|
||||
message.day ?? mailDay(),
|
||||
),
|
||||
createMissingParents: true,
|
||||
});
|
||||
return await fs.copy(owner.id, {
|
||||
source: message.source,
|
||||
destinationParent: parent,
|
||||
newName: message.name,
|
||||
return asDelivery(async () => {
|
||||
const parent = await fs.mkdir(owner.id, {
|
||||
path: mailFolderPath(
|
||||
owner.username,
|
||||
'objects',
|
||||
message.day ?? mailDay(),
|
||||
),
|
||||
createMissingParents: true,
|
||||
});
|
||||
return await fs.copy(owner.id, {
|
||||
source: message.source,
|
||||
destinationParent: parent,
|
||||
newName: message.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
EVENTS_COALESCE_WINDOW_MS,
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_KV_VALUE_DELIVERIES_PER_EVENT,
|
||||
EVENTS_KV_VALUE_MAX_BYTES,
|
||||
EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_SUBSCRIBE_LIMIT,
|
||||
@@ -205,6 +206,7 @@ let handles: Map<string, KvShareHandle>;
|
||||
|
||||
const kvShareHandleStore = {
|
||||
getByHandle: async (handle: string) => handles.get(handle) ?? null,
|
||||
predatesItsApp: async () => false,
|
||||
getLive: async (handle: string) => {
|
||||
const row = handles.get(handle);
|
||||
if (!row || row.revokedAt !== null) return null;
|
||||
@@ -2436,20 +2438,20 @@ describe('delivering a kv change', () => {
|
||||
expect(ownerPlanActors).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits values and gaps the 129th free-tier KV listener', async () => {
|
||||
it('keeps values on the delivered free-tier KV listeners and gaps the 129th', async () => {
|
||||
vi.useFakeTimers();
|
||||
await seedKvSubscriptions(129, { includeValue: true });
|
||||
|
||||
await dispatchKv(['cart'], { values: ['re-read'] });
|
||||
await dispatchKv(['cart'], { values: ['kept'] });
|
||||
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
|
||||
|
||||
const deliveries = sent.filter((one) => one.envelope.event.op === 'set');
|
||||
const gaps = sent.filter((one) => one.envelope.event.op === 'gap');
|
||||
expect(deliveries).toHaveLength(128);
|
||||
expect(gaps).toHaveLength(1);
|
||||
expect(deliveries.every((one) => !('value' in one.envelope.event))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
deliveries.every((one) => one.envelope.event.value === 'kept'),
|
||||
).toBe(true);
|
||||
expect(ownerPlanActors).toEqual([
|
||||
expect.objectContaining({
|
||||
user: expect.objectContaining({
|
||||
@@ -2474,9 +2476,10 @@ describe('delivering a kv change', () => {
|
||||
expect(sent).toHaveLength(
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT.limit,
|
||||
);
|
||||
expect(sent.every((one) => !('value' in one.envelope.event))).toBe(
|
||||
true,
|
||||
);
|
||||
// Values ride on the first deliveries only.
|
||||
expect(
|
||||
sent.filter((one) => 'value' in one.envelope.event),
|
||||
).toHaveLength(EVENTS_KV_VALUE_DELIVERIES_PER_EVENT);
|
||||
});
|
||||
|
||||
it('gaps the 513th paid-tier KV listener', async () => {
|
||||
@@ -2512,7 +2515,7 @@ describe('delivering a kv change', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('omits a forwarded value when the KV filter evaluation stops early', async () => {
|
||||
it('keeps a forwarded value on the deliveries made when the KV filter evaluation stops early', async () => {
|
||||
vi.useFakeTimers();
|
||||
await seedKvSubscriptions(128, { includeValue: true });
|
||||
await seedKvSubscriptions(
|
||||
@@ -2542,9 +2545,111 @@ describe('delivering a kv change', () => {
|
||||
|
||||
const deliveries = sent.filter((one) => one.envelope.event.op === 'set');
|
||||
expect(deliveries).toHaveLength(128);
|
||||
expect(deliveries.every((one) => !('value' in one.envelope.event))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
deliveries.every(
|
||||
(one) => one.envelope.event.value === 'forwarded-re-read',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe('shared by several holders', () => {
|
||||
const valueOf = (subIdPart: string) =>
|
||||
sent.filter(
|
||||
(one) =>
|
||||
one.envelope.subId.includes(subIdPart) &&
|
||||
one.envelope.event.op === 'set',
|
||||
);
|
||||
|
||||
it('serves the owner`s rows first and every holder before anyone`s second row', async () => {
|
||||
vi.useFakeTimers();
|
||||
ownerPlan = 'paid-plan';
|
||||
const crowd = userId + 600;
|
||||
const quiet = userId + 700;
|
||||
await seedKvSubscriptions(
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT.limit + 50,
|
||||
{ includeValue: true, holderUserId: crowd, tag: 'crowd' },
|
||||
);
|
||||
await seedKvSubscriptions(1, {
|
||||
includeValue: true,
|
||||
holderUserId: quiet,
|
||||
tag: 'quiet',
|
||||
});
|
||||
await seedKvSubscriptions(1, { includeValue: true, tag: 'owner' });
|
||||
|
||||
await dispatchKv(['cart'], { values: ['shared'] });
|
||||
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
|
||||
|
||||
const delivered = sent.filter(
|
||||
(one) => one.envelope.event.op === 'set',
|
||||
);
|
||||
expect(delivered).toHaveLength(
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT.limit,
|
||||
);
|
||||
for (const tag of ['-owner-', '-quiet-']) {
|
||||
expect(valueOf(tag)).toHaveLength(1);
|
||||
expect(valueOf(tag)[0].envelope.event.value).toBe('shared');
|
||||
}
|
||||
expect(
|
||||
delivered.filter((one) => 'value' in one.envelope.event),
|
||||
).toHaveLength(EVENTS_KV_VALUE_DELIVERIES_PER_EVENT);
|
||||
});
|
||||
|
||||
it('keeps the owner first even past the filter-check limit', async () => {
|
||||
vi.useFakeTimers();
|
||||
const filters =
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT.bySubscription[
|
||||
DEFAULT_FREE_SUBSCRIPTION
|
||||
];
|
||||
await seedKvSubscriptions(filters, {
|
||||
includeValue: true,
|
||||
holderUserId: userId + 600,
|
||||
match: 'elsewhere',
|
||||
tag: 'crowd',
|
||||
});
|
||||
await seedKvSubscriptions(1, { includeValue: true, tag: 'owner' });
|
||||
|
||||
await dispatchKv(['cart'], { values: ['first'] });
|
||||
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
|
||||
|
||||
expect(valueOf('-owner-')).toHaveLength(1);
|
||||
expect(valueOf('-owner-')[0].envelope.event.value).toBe('first');
|
||||
});
|
||||
|
||||
it('starts each event`s turns at a different holder, so none is always past the cap', async () => {
|
||||
vi.useFakeTimers();
|
||||
const holders = 200;
|
||||
for (let i = 0; i < holders; i++)
|
||||
await seedKvSubscriptions(1, {
|
||||
holderUserId: userId + 1000 + i,
|
||||
tag: `holder${i}`,
|
||||
});
|
||||
const cap =
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT.bySubscription[
|
||||
DEFAULT_FREE_SUBSCRIPTION
|
||||
];
|
||||
|
||||
const reached = new Set<string>();
|
||||
const random = vi.spyOn(Math, 'random');
|
||||
try {
|
||||
for (const start of [0, cap / holders]) {
|
||||
random.mockReturnValue(start);
|
||||
sent.length = 0;
|
||||
await dispatchKv(['cart']);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
EVENTS_COALESCE_WINDOW_MS + 1,
|
||||
);
|
||||
const delivered = sent.filter(
|
||||
(one) => one.envelope.event.op === 'set',
|
||||
);
|
||||
expect(delivered).toHaveLength(cap);
|
||||
for (const one of delivered)
|
||||
reached.add(one.envelope.subId);
|
||||
}
|
||||
} finally {
|
||||
random.mockRestore();
|
||||
}
|
||||
expect(reached.size).toBe(holders);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves another app`s namespace alone', async () => {
|
||||
@@ -2998,6 +3103,7 @@ describe('cross-user kv handles', () => {
|
||||
keyPrefix: PREFIX,
|
||||
permission,
|
||||
label: null,
|
||||
notify: false,
|
||||
createdAt: 0,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
@@ -3069,8 +3175,19 @@ describe('cross-user kv handles', () => {
|
||||
expect(wire).not.toContain(`u${userId}`);
|
||||
});
|
||||
|
||||
/** The handle at `read`, the lowest level that carries values. */
|
||||
const mintReadHandle = () =>
|
||||
mintHandle({
|
||||
permission: kvSharePermission(
|
||||
`user-${userId}`,
|
||||
OWN_APP,
|
||||
PREFIX,
|
||||
'read',
|
||||
),
|
||||
});
|
||||
|
||||
it('hands a guest the value when it asked, keyed relative to the handle', async () => {
|
||||
mintHandle();
|
||||
mintReadHandle();
|
||||
vi.useFakeTimers();
|
||||
const asking = (
|
||||
await service.subscribe(actorFor(guestId), socketId, {
|
||||
@@ -3095,6 +3212,23 @@ describe('cross-user kv handles', () => {
|
||||
expect(byId.get(silent.subId)).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('holds the value back from a guest whose share is at watch', async () => {
|
||||
mintHandle();
|
||||
vi.useFakeTimers();
|
||||
const { sub } = await service.subscribe(actorFor(guestId), socketId, {
|
||||
subject: `kv:${handle}:*`,
|
||||
includeValue: true,
|
||||
});
|
||||
|
||||
await dispatchKv([`${PREFIX}title`], { values: ['hello'] });
|
||||
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
|
||||
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0].envelope.subId).toBe(sub.subId);
|
||||
expect(sent[0].envelope.event).toMatchObject({ key: 'title' });
|
||||
expect(sent[0].envelope.event).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('delivers every key under the granted region', async () => {
|
||||
mintHandle();
|
||||
vi.useFakeTimers();
|
||||
@@ -3112,7 +3246,7 @@ describe('cross-user kv handles', () => {
|
||||
});
|
||||
|
||||
it('withholds a private key from the grantee, value and all', async () => {
|
||||
mintHandle();
|
||||
mintReadHandle();
|
||||
vi.useFakeTimers();
|
||||
const { sub } = await service.subscribe(actorFor(guestId), socketId, {
|
||||
subject: `kv:${handle}:*`,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -240,16 +240,17 @@ export const unknownKvShareHandle = (handle: string): HttpError =>
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
|
||||
/** Whether the grant holds, or `null` when the check failed and can't say. */
|
||||
export const kvSharedRegionAuthorized = async (
|
||||
actor: Actor,
|
||||
permission: string,
|
||||
deps: KvSharedRegionDeps,
|
||||
): Promise<boolean> => {
|
||||
): Promise<boolean | null> => {
|
||||
if (!deps.enabled) return false;
|
||||
try {
|
||||
return await deps.checkPermission(actor, permission);
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CONSENT_CONFIRMED_TTL_MS,
|
||||
CONSENT_SETTLE_WINDOW_MS,
|
||||
CONSENT_SETTLES_PER_WINDOW,
|
||||
ConsentSettles,
|
||||
} from './consentSettles.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('runs one check per pair at a time', () => {
|
||||
const settles = new ConsentSettles();
|
||||
expect(settles.begin('1|app')).toBe('start');
|
||||
expect(settles.begin('1|app')).toBe('skip');
|
||||
expect(settles.begin('2|app')).toBe('start');
|
||||
settles.end('1|app');
|
||||
expect(settles.begin('1|app')).toBe('start');
|
||||
});
|
||||
|
||||
it('skips a pair a fresh read just confirmed, until that lapses', () => {
|
||||
const settles = new ConsentSettles();
|
||||
settles.begin('1|app');
|
||||
settles.confirm('1|app');
|
||||
settles.end('1|app');
|
||||
expect(settles.begin('1|app')).toBe('skip');
|
||||
|
||||
vi.setSystemTime(Date.now() + CONSENT_CONFIRMED_TTL_MS + 1);
|
||||
expect(settles.begin('1|app')).toBe('start');
|
||||
});
|
||||
|
||||
it('stops settling once the window budget is spent, and resets after it', () => {
|
||||
const settles = new ConsentSettles();
|
||||
for (let i = 0; i < CONSENT_SETTLES_PER_WINDOW; i++)
|
||||
expect(settles.take()).toBe(true);
|
||||
expect(settles.take()).toBe(false);
|
||||
// Spent means no check either: nothing it could settle.
|
||||
expect(settles.begin('1|app')).toBe('spent');
|
||||
|
||||
vi.setSystemTime(Date.now() + CONSENT_SETTLE_WINDOW_MS);
|
||||
expect(settles.begin('1|app')).toBe('start');
|
||||
expect(settles.take()).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Paces the settles a delivery starts when it finds background consent
|
||||
* withdrawn. A settle ends rows for good, so a permission check that wrongly
|
||||
* answers no must not end every row a delivery reaches: past a small budget per
|
||||
* window the rest only defer. A pair a fresh read just found consented is not
|
||||
* read again for a while, so a stale cached no costs one primary read, not one
|
||||
* per delivery. Per process, and not a published limit.
|
||||
*/
|
||||
|
||||
export const CONSENT_SETTLES_PER_WINDOW = 20;
|
||||
export const CONSENT_SETTLE_WINDOW_MS = 60_000;
|
||||
export const CONSENT_CONFIRMED_TTL_MS = 60_000;
|
||||
export const CONSENT_CONFIRMED_MAX_ENTRIES = 10_000;
|
||||
|
||||
/** `spent`: the window's budget is gone, which is worth an alarm. */
|
||||
export type ConsentCheckStart = 'start' | 'skip' | 'spent';
|
||||
|
||||
export class ConsentSettles {
|
||||
readonly #inFlight = new Set<string>();
|
||||
/** Insertion order is time order: re-confirming moves a key to the tail. */
|
||||
readonly #confirmedAt = new Map<string, number>();
|
||||
#windowStart = 0;
|
||||
#taken = 0;
|
||||
|
||||
/** Whether to check `key` now; `start` must be paired with `end`. */
|
||||
begin(key: string): ConsentCheckStart {
|
||||
if (this.#inFlight.has(key)) return 'skip';
|
||||
const confirmedAt = this.#confirmedAt.get(key);
|
||||
if (confirmedAt !== undefined) {
|
||||
if (Date.now() - confirmedAt <= CONSENT_CONFIRMED_TTL_MS)
|
||||
return 'skip';
|
||||
this.#confirmedAt.delete(key);
|
||||
}
|
||||
if (this.#left() === 0) return 'spent';
|
||||
this.#inFlight.add(key);
|
||||
return 'start';
|
||||
}
|
||||
|
||||
end(key: string): void {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
|
||||
/** A fresh read found the consent standing. */
|
||||
confirm(key: string): void {
|
||||
this.#confirmedAt.delete(key);
|
||||
this.#confirmedAt.set(key, Date.now());
|
||||
this.#prune();
|
||||
}
|
||||
|
||||
/** One settle from this window's budget; false once it is spent. */
|
||||
take(): boolean {
|
||||
if (this.#left() === 0) return false;
|
||||
this.#taken++;
|
||||
return true;
|
||||
}
|
||||
|
||||
#left(): number {
|
||||
if (Date.now() - this.#windowStart >= CONSENT_SETTLE_WINDOW_MS) {
|
||||
this.#windowStart = Date.now();
|
||||
this.#taken = 0;
|
||||
}
|
||||
return CONSENT_SETTLES_PER_WINDOW - this.#taken;
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
for (const [key, confirmedAt] of this.#confirmedAt) {
|
||||
const overCap =
|
||||
this.#confirmedAt.size > CONSENT_CONFIRMED_MAX_ENTRIES;
|
||||
const headExpired =
|
||||
Date.now() - confirmedAt > CONSENT_CONFIRMED_TTL_MS;
|
||||
if (!overCap && !headExpired) break;
|
||||
this.#confirmedAt.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ const subscribeTo = async (subject: string) => {
|
||||
};
|
||||
|
||||
describe('the write path reaches subscribers', () => {
|
||||
it('omits values after the temporary KV fan-out threshold', async () => {
|
||||
it('keeps values on the deliveries a capped KV fan-out makes', async () => {
|
||||
const key = `fanout-value-${Date.now()}`;
|
||||
const socketIds = Array.from({ length: 129 }, (_, i) =>
|
||||
`kv-fanout-${i}`,
|
||||
@@ -117,9 +117,15 @@ describe('the write path reaches subscribers', () => {
|
||||
envelope.event.op === 'set' &&
|
||||
(envelope.event as { key?: string }).key === key,
|
||||
);
|
||||
expect(sent.every((envelope) => !('value' in envelope.event))).toBe(
|
||||
true,
|
||||
);
|
||||
// The value rides on the first 128 deliveries, which is all of
|
||||
// them at this cap.
|
||||
expect(
|
||||
sent.every(
|
||||
(envelope) =>
|
||||
(envelope.event as { value?: unknown }).value ===
|
||||
're-read',
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await Promise.all(
|
||||
socketIds.map((socketId) => events().reapSocket(userId, socketId)),
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Durable subscriptions end to end: the routes that create and revoke them, who
|
||||
* each credential shape may see, and what a write costs once one exists.
|
||||
* Durable subscriptions end to end: the routes that create, update and revoke
|
||||
* them, who each credential shape may see, and what a write costs once one
|
||||
* exists.
|
||||
*
|
||||
* The cost assertions are the point of the whole cache. A subscription made
|
||||
* here is deliverable here without the table, and a region that has never seen
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
} from '../../controllers/events/limits.js';
|
||||
import { DEFAULT_FREE_SUBSCRIPTION } from '../metering/consts.js';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { DURABLE_CONTEXT_MAX_BYTES } from '../../stores/events/DurableSubscriptionStore.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { appSocketRoom } from '../socket/SocketService.js';
|
||||
@@ -97,6 +99,13 @@ const listSubscriptions = (token: string, query = ''): Promise<ApiResponse> =>
|
||||
const unsubscribe = (token: string, subId: string): Promise<ApiResponse> =>
|
||||
call('POST', '/events/unsubscribe', token, { subId });
|
||||
|
||||
const updateContext = (
|
||||
token: string,
|
||||
subId: string,
|
||||
body: object,
|
||||
): Promise<ApiResponse> =>
|
||||
call('POST', '/events/subscriptions/update', token, { subId, ...body });
|
||||
|
||||
const subIdsOf = (response: ApiResponse): string[] =>
|
||||
(response.body.items as Array<{ subId: string }>).map((row) => row.subId);
|
||||
|
||||
@@ -588,6 +597,140 @@ describe('what each credential sees and removes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replacing a durable subscription`s context', () => {
|
||||
const resetBudget = () =>
|
||||
env.server.clients.redis.del(
|
||||
`rate:${EVENTS_SUBSCRIBE_LIMIT.scope}:${userId}`,
|
||||
);
|
||||
|
||||
/** The row this region would deliver against, read from its cache. */
|
||||
const cachedRow = async (subId: string) => {
|
||||
const stored =
|
||||
await env.server.stores.durableSubscription.getBySubId(subId);
|
||||
const cached = await env.server.stores.eventSubscription.getForTokens(
|
||||
userId,
|
||||
[stored!.token],
|
||||
);
|
||||
return cached.find((row) => row.subId === subId);
|
||||
};
|
||||
|
||||
it('keeps the id and the backlog, and delivers the new value from then on', async () => {
|
||||
await resetBudget();
|
||||
await clearRows();
|
||||
const created = await subscribe(appOneToken, {
|
||||
context: { label: 'old' },
|
||||
});
|
||||
const subId = created.body.subId as string;
|
||||
await env.server.stores.pendingDelivery.enqueue(subId, {
|
||||
id: uuidv4(),
|
||||
subject: `fs:${anchor}`,
|
||||
op: 'gap',
|
||||
reason: 'backlog_overflow',
|
||||
ts: Date.now(),
|
||||
});
|
||||
|
||||
const updated = await updateContext(appOneToken, subId, {
|
||||
context: { label: 'new', region: 'eu' },
|
||||
});
|
||||
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body).toMatchObject({
|
||||
subId,
|
||||
contextKeys: ['label', 'region'],
|
||||
});
|
||||
expect(updated.body.contextHash).not.toBe(created.body.contextHash);
|
||||
expect(subIdsOf(await listSubscriptions(appOneToken))).toEqual([
|
||||
subId,
|
||||
]);
|
||||
await expect(
|
||||
env.server.stores.pendingDelivery.depth(subId),
|
||||
).resolves.toBe(1);
|
||||
expect((await cachedRow(subId))?.context).toBe(
|
||||
JSON.stringify({ label: 'new', region: 'eu' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears it with null', async () => {
|
||||
await resetBudget();
|
||||
await clearRows();
|
||||
const subId = (
|
||||
await subscribe(env.users.user.token, { context: { a: 1 } })
|
||||
).body.subId as string;
|
||||
|
||||
const cleared = await updateContext(env.users.user.token, subId, {
|
||||
context: null,
|
||||
});
|
||||
|
||||
expect(cleared.status).toBe(200);
|
||||
expect(cleared.body).toMatchObject({
|
||||
contextKeys: null,
|
||||
contextHash: null,
|
||||
});
|
||||
expect((await cachedRow(subId))?.context).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a body without context, and a context over the cap', async () => {
|
||||
await resetBudget();
|
||||
await clearRows();
|
||||
const subId = (
|
||||
await subscribe(env.users.user.token, { context: { a: 1 } })
|
||||
).body.subId as string;
|
||||
|
||||
const missing = await updateContext(env.users.user.token, subId, {});
|
||||
expect(missing.status).toBe(400);
|
||||
expect(missing.body.code).toBe('bad_request');
|
||||
|
||||
const tooLarge = await updateContext(env.users.user.token, subId, {
|
||||
context: { blob: 'x'.repeat(DURABLE_CONTEXT_MAX_BYTES) },
|
||||
});
|
||||
expect(tooLarge.status).toBe(413);
|
||||
expect(tooLarge.body.code).toBe('events_context_too_large');
|
||||
expect((await cachedRow(subId))?.context).toBe(
|
||||
JSON.stringify({ a: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('answers a row the caller does not hold as absent', async () => {
|
||||
await resetBudget();
|
||||
await clearRows();
|
||||
const mine = (await subscribe(appOneToken)).body.subId as string;
|
||||
|
||||
for (const token of [appTwoToken, appOneAccessToken, readUrlToken]) {
|
||||
const refused = await updateContext(token, mine, {
|
||||
context: { stolen: true },
|
||||
});
|
||||
expect(refused.status).toBe(404);
|
||||
expect(refused.body.code).toBe('subscription_does_not_exist');
|
||||
}
|
||||
expect((await cachedRow(mine))?.context).toBeNull();
|
||||
});
|
||||
|
||||
it('spends the subscribe budget', async () => {
|
||||
await resetBudget();
|
||||
try {
|
||||
await clearRows();
|
||||
const mine = (await subscribe(env.users.user.token)).body
|
||||
.subId as string;
|
||||
for (let i = 1; i < EVENTS_SUBSCRIBE_LIMIT.limit; i++)
|
||||
await updateContext(env.users.user.token, mine, {
|
||||
context: { i },
|
||||
});
|
||||
|
||||
const refused = await subscribe(env.users.user.token);
|
||||
expect(refused.status).toBe(429);
|
||||
const refusedUpdate = await updateContext(
|
||||
env.users.user.token,
|
||||
mine,
|
||||
{ context: { i: -1 } },
|
||||
);
|
||||
expect(refusedUpdate.status).toBe(429);
|
||||
expect(refusedUpdate.body.code).toBe('too_many_requests');
|
||||
} finally {
|
||||
await resetBudget();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a write costs once a durable row exists', () => {
|
||||
it('delivers straight after subscribe without reading the table', async () => {
|
||||
await clearRows();
|
||||
@@ -904,6 +1047,10 @@ describe('with events switched off', () => {
|
||||
}),
|
||||
offCall('GET', '/events/subscriptions'),
|
||||
offCall('POST', '/events/unsubscribe', { subId: 'user#nope' }),
|
||||
offCall('POST', '/events/subscriptions/update', {
|
||||
subId: 'user#nope',
|
||||
context: null,
|
||||
}),
|
||||
]) {
|
||||
const response = await attempt;
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { NotificationFeed } from '../../../puter-js/src/modules/notifications/lib/feed.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
|
||||
@@ -449,6 +450,172 @@ describe('who a fetch may see', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading again from a saved cursor', () => {
|
||||
const SUBJECT = 'notif:app-user';
|
||||
|
||||
/** One of the app's own messages, in a group of its own. */
|
||||
const message = async (title: string, inApp = appUid): Promise<string> =>
|
||||
(
|
||||
await env.server.stores.notification.create({
|
||||
userId,
|
||||
value: { type: 'app.message', title },
|
||||
type: 'app.message',
|
||||
audience: 'app-user',
|
||||
appUid: inApp,
|
||||
group: `group-${uuidv4()}`,
|
||||
})
|
||||
).uid as string;
|
||||
|
||||
/** A replacement pinned to `replacedAt`, as a group replace writes it. */
|
||||
const replaceAt = (uid: string, title: string, replacedAt: number) =>
|
||||
env.server.stores.notification.updateValue(
|
||||
uid,
|
||||
userId,
|
||||
{ type: 'app.message', title },
|
||||
{ onlyUnseen: true, replacedAt },
|
||||
);
|
||||
|
||||
it('returns a notification replaced since, from an earlier page', async () => {
|
||||
await clearMailboxes();
|
||||
const uids: string[] = [];
|
||||
for (let i = 0; i < 3; i++) uids.push(await message(`m${i}`));
|
||||
|
||||
const first = await fetchPage(appToken, { subject: SUBJECT, limit: 2 });
|
||||
const last = await fetchPage(appToken, {
|
||||
subject: SUBJECT,
|
||||
limit: 2,
|
||||
cursor: first.body.cursor,
|
||||
});
|
||||
expect(uidsOf(last)).toEqual([uids[2]]);
|
||||
expect(last.body.cursor).toBeUndefined();
|
||||
|
||||
await env.server.services.notification.notifyUpdate(
|
||||
uids[0],
|
||||
userId,
|
||||
{ title: 'replaced' },
|
||||
{ type: 'app.message', replace: true },
|
||||
);
|
||||
|
||||
const again = await fetchPage(appToken, {
|
||||
subject: SUBJECT,
|
||||
limit: 2,
|
||||
cursor: first.body.cursor,
|
||||
});
|
||||
expect(uidsOf(again)).toEqual([uids[2], uids[0]]);
|
||||
expect(again.body.items![1]).toMatchObject({
|
||||
replaced: true,
|
||||
notification: { title: 'replaced' },
|
||||
});
|
||||
});
|
||||
|
||||
it('pages through replacements without repeating or skipping one', async () => {
|
||||
await clearMailboxes();
|
||||
const uids: string[] = [];
|
||||
for (let i = 0; i < 6; i++) uids.push(await message(`m${i}`));
|
||||
const first = await fetchPage(appToken, { subject: SUBJECT, limit: 5 });
|
||||
expect(first.body.cursor).toBeTruthy();
|
||||
|
||||
// One moment for all four, so only the row id orders them.
|
||||
const at = Date.now();
|
||||
for (const uid of uids.slice(0, 4)) await replaceAt(uid, 'again', at);
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor = first.body.cursor;
|
||||
do {
|
||||
const page = await fetchPage(appToken, {
|
||||
subject: SUBJECT,
|
||||
limit: 2,
|
||||
cursor,
|
||||
});
|
||||
seen.push(...uidsOf(page));
|
||||
cursor = page.body.cursor;
|
||||
} while (cursor);
|
||||
|
||||
expect(seen).toEqual([uids[5], ...uids.slice(0, 4)]);
|
||||
});
|
||||
|
||||
it('leaves out a replacement in another app`s slice', async () => {
|
||||
await clearMailboxes();
|
||||
const other = await message('other app', otherAppUid);
|
||||
const own = [await message('own 0'), await message('own 1')];
|
||||
const first = await fetchPage(appToken, { subject: SUBJECT, limit: 1 });
|
||||
expect(uidsOf(first)).toEqual([own[0]]);
|
||||
|
||||
await replaceAt(other, 'other app again', Date.now());
|
||||
|
||||
const again = await fetchPage(appToken, {
|
||||
subject: SUBJECT,
|
||||
cursor: first.body.cursor,
|
||||
});
|
||||
expect(uidsOf(again)).toEqual([own[1]]);
|
||||
});
|
||||
|
||||
it('hands the SDK feed a replacement made while it was disconnected, once', async () => {
|
||||
await clearMailboxes();
|
||||
// One more than a page, so the feed holds a cursor.
|
||||
const uids: string[] = [];
|
||||
for (let i = 0; i < 201; i++) uids.push(await message(`message ${i}`));
|
||||
|
||||
const appActor = (await env.server.services.auth.authenticate(appToken))
|
||||
.actor!;
|
||||
let reads = 0;
|
||||
let receive: (delivery: { event: unknown }) => void = () => {};
|
||||
const titles: string[] = [];
|
||||
const feed = new NotificationFeed(
|
||||
{
|
||||
authToken: appToken,
|
||||
events: {
|
||||
onLocal: async (
|
||||
_subject: string,
|
||||
handler: typeof receive,
|
||||
) => {
|
||||
receive = handler;
|
||||
return { off: async () => {} };
|
||||
},
|
||||
fetch: async (options: Record<string, unknown>) => {
|
||||
const page =
|
||||
await env.server.services.events.fetchMissed(
|
||||
appActor,
|
||||
options,
|
||||
);
|
||||
reads++;
|
||||
return page;
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
(notification: { title: string | null }) => {
|
||||
titles.push(String(notification.title));
|
||||
},
|
||||
{},
|
||||
);
|
||||
const gapRead = async () => {
|
||||
const before = reads;
|
||||
receive({ event: { op: 'gap', reason: 'reconnect' } });
|
||||
await vi.waitFor(() => expect(reads).toBe(before + 1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
try {
|
||||
await feed.start();
|
||||
expect(titles).toHaveLength(201);
|
||||
|
||||
await env.server.services.notification.notifyUpdate(
|
||||
uids[0],
|
||||
userId,
|
||||
{ title: 'replaced while away' },
|
||||
{ type: 'app.message', replace: true },
|
||||
);
|
||||
await gapRead();
|
||||
await gapRead();
|
||||
} finally {
|
||||
await feed.off();
|
||||
}
|
||||
|
||||
expect(titles).toHaveLength(202);
|
||||
expect(titles.at(-1)).toBe('replaced while away');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-user isolation', () => {
|
||||
it('returns nothing of another account, whatever the subject names', async () => {
|
||||
await clearMailboxes();
|
||||
|
||||
@@ -507,7 +507,7 @@ const makeRegion = (
|
||||
region.events.worker = {
|
||||
invoke: async (invocation: WorkerInvocation) => {
|
||||
region.invoked.push(invocation);
|
||||
return workerOutcome;
|
||||
return { outcome: workerOutcome };
|
||||
},
|
||||
};
|
||||
region.forward.onServerStart();
|
||||
@@ -1361,7 +1361,8 @@ describe('a delivery owed to exactly one consumer', () => {
|
||||
await dispatch(west);
|
||||
await arrived(west);
|
||||
|
||||
expect(west.posts).toEqual([]);
|
||||
// Deliveries only: its own connect announces presence to peers.
|
||||
expect(deliveriesIn(west)).toEqual([]);
|
||||
expect(west.sent[0]).toMatchObject({ ackRequired: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
EVENTS_COALESCE_WINDOW_MS,
|
||||
EVENTS_HANDLER_PUBLISH_BATCH,
|
||||
EVENTS_HANDLER_PUBLISH_LIMIT,
|
||||
EVENTS_HANDLER_SOURCE_MAX_BYTES,
|
||||
EVENTS_SUSPENDED_PENDING_CAP,
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
@@ -341,6 +342,61 @@ describe('who may publish a handler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a handler may refer to', () => {
|
||||
// The file publishes close to the per-minute budget without these.
|
||||
beforeEach(async () => {
|
||||
await env.server.clients.redis.del(
|
||||
`rate:${EVENTS_HANDLER_PUBLISH_LIMIT.scope}:${userId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a handler that names a variable it does not bind', async () => {
|
||||
const refused = await publish(appToken, {
|
||||
name: 'ingestUpload',
|
||||
source: 'async ({ event }) => { await send(endpoint, event.path); }',
|
||||
});
|
||||
|
||||
expect(refused.status).toBe(400);
|
||||
expect(refused.body.code).toBe('events_handler_free_variable');
|
||||
expect(refused.body.message).toContain('`send`');
|
||||
const listed = await call('GET', '/events/handlers/list', appToken);
|
||||
expect(listed.body.handlers).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses the ambient SDK, which a handler never has', async () => {
|
||||
const refused = await publish(appToken, {
|
||||
name: 'ingestUpload',
|
||||
source: 'async ({ event }) => { await puter.fs.stat(event.path); }',
|
||||
});
|
||||
|
||||
expect(refused.status).toBe(400);
|
||||
expect(refused.body.code).toBe('events_handler_free_variable');
|
||||
});
|
||||
|
||||
it('stops a set at the item that does', async () => {
|
||||
const refused = await call(
|
||||
'POST',
|
||||
'/events/handlers/publishAll',
|
||||
appToken,
|
||||
{
|
||||
handlers: [
|
||||
{ name: 'indexDocument', source: NEXT_SOURCE },
|
||||
{ name: 'ingestUpload', source: 'async () => leak()' },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(refused.status).toBe(400);
|
||||
expect(refused.body.code).toBe('events_handler_free_variable');
|
||||
const listed = await call('GET', '/events/handlers/list', appToken);
|
||||
expect(
|
||||
(listed.body.handlers as Array<{ name: string }>).map(
|
||||
(h) => h.name,
|
||||
),
|
||||
).toEqual(['indexDocument']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishing a set', () => {
|
||||
it('takes a build step`s handlers in one call', async () => {
|
||||
const published = await call(
|
||||
@@ -624,8 +680,14 @@ describe('the total-source cap', () => {
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
// Every handler at the per-handler max: the fewest handlers that
|
||||
// can reach the total cap.
|
||||
const bigSource = 'x'.repeat(EVENTS_HANDLER_SOURCE_MAX_BYTES);
|
||||
// can reach the total cap. Padded in a comment so it still passes
|
||||
// the free-variable scan.
|
||||
const prefix = 'async () => {} /*';
|
||||
const padded = (fill: string) =>
|
||||
`${prefix}${fill.repeat(
|
||||
EVENTS_HANDLER_SOURCE_MAX_BYTES - prefix.length - 2,
|
||||
)}*/`;
|
||||
const bigSource = padded('x');
|
||||
const names = Array.from(
|
||||
{
|
||||
length:
|
||||
@@ -666,7 +728,7 @@ describe('the total-source cap', () => {
|
||||
// same size, different content, so it must not double-count.
|
||||
const replace = await publish(appToken, {
|
||||
name: 'big0',
|
||||
source: 'y'.repeat(EVENTS_HANDLER_SOURCE_MAX_BYTES),
|
||||
source: padded('y'),
|
||||
replace: true,
|
||||
});
|
||||
expect(replace.status).toBe(200);
|
||||
|
||||
@@ -28,8 +28,21 @@
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { EVENTS_COALESCE_WINDOW_MS } from '../../controllers/events/limits.js';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
EVENTS_COALESCE_WINDOW_MS,
|
||||
EVENTS_KV_HANDLE_LIMIT,
|
||||
EVENTS_SUBSCRIBE_LIMIT,
|
||||
} from '../../controllers/events/limits.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import {
|
||||
@@ -38,8 +51,12 @@ import {
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { SESSION_SUBSCRIPTION_TTL_SECONDS } from '../../stores/events/EventSubscriptionStore.js';
|
||||
import { kvShareGrantAtEveryLevel } from '../kvShare/kvShares.js';
|
||||
import { EVENTS_BACKGROUND_PERMISSION } from './authorization.js';
|
||||
import type { DeliveryEnvelope } from './EventsService.js';
|
||||
import { kvAnchorToken } from './subjects.js';
|
||||
import { RecordingWorkerInvoker } from './workerSeam.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
const SOCKET_ID = 'kv-revoke-socket';
|
||||
@@ -145,6 +162,7 @@ const clearRows = async () => {
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
// Seeded accounts carry no email, which the plan machinery reads as a
|
||||
// temporary account — and a temporary account holds no durable rows.
|
||||
unlimitedMetering: true,
|
||||
@@ -221,6 +239,144 @@ describe('revoking a handle', () => {
|
||||
expect(delivered).toEqual([]);
|
||||
});
|
||||
|
||||
describe('ending session subscriptions made through it', () => {
|
||||
let send: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
send = vi.spyOn(env.server.services.socket, 'send');
|
||||
});
|
||||
afterEach(() => {
|
||||
send.mockRestore();
|
||||
});
|
||||
|
||||
const endedOn = (socketId: string, subId: string) =>
|
||||
vi.waitFor(
|
||||
() =>
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
{ socket: socketId },
|
||||
'events.ended',
|
||||
expect.objectContaining({
|
||||
subId,
|
||||
code: 'subscription_ended',
|
||||
reason: 'permission_revoked',
|
||||
}),
|
||||
),
|
||||
{ timeout: 5_000, interval: 25 },
|
||||
);
|
||||
|
||||
const heldOn = async (userId: number, socketId: string) =>
|
||||
(
|
||||
await env.server.stores.eventSubscription.listForSocket(
|
||||
userId,
|
||||
socketId,
|
||||
)
|
||||
).map((row) => row.subId);
|
||||
|
||||
it('ends them when the owner revokes it, and tells the connection', async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
const { sub } = await events().subscribe(guest.actor, SOCKET_ID, {
|
||||
subject: `kv:${handle}:*`,
|
||||
});
|
||||
|
||||
await shares().revokeKvHandle(owner.actor, handle);
|
||||
|
||||
await endedOn(SOCKET_ID, sub.subId);
|
||||
expect(await heldOn(guest.id, SOCKET_ID)).toEqual([]);
|
||||
expect(await ownerWatches()).toBe(false);
|
||||
});
|
||||
|
||||
it('still suspends durable subscriptions when ending them fails', async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
const durable = await subscribeDurable(handle);
|
||||
await events().subscribe(guest.actor, SOCKET_ID, {
|
||||
subject: `kv:${handle}:*`,
|
||||
});
|
||||
// Retired with the grant left alone, so only the retire settle
|
||||
// can suspend the row.
|
||||
await env.server.stores.kvShareHandle.retireMany([handle], 'owner');
|
||||
|
||||
const store = env.server.stores.eventSubscription;
|
||||
const listing = vi
|
||||
.spyOn(store, 'listForKvHandles')
|
||||
.mockRejectedValue(new Error('redis unavailable'));
|
||||
try {
|
||||
await events().settleRetiredKvHandles({
|
||||
handles: [handle],
|
||||
userId: guest.id,
|
||||
});
|
||||
await suspendedRow(durable.subId);
|
||||
} finally {
|
||||
listing.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('ends them when the holder gives it up', async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
const { sub } = await events().subscribe(guest.actor, SOCKET_ID, {
|
||||
subject: `kv:${handle}:*`,
|
||||
});
|
||||
|
||||
await shares().removeKvShare(guest.actor, handle);
|
||||
|
||||
await endedOn(SOCKET_ID, sub.subId);
|
||||
expect(await heldOn(guest.id, SOCKET_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ends them on the connection`s next refresh when the handle stopped standing out of sight', async () => {
|
||||
await clearRows();
|
||||
const socketId = 'kv-revoke-refresh';
|
||||
const retired = await mint();
|
||||
const withdrawn = await mint('workspace:withdrawn:');
|
||||
const kept = await mint('workspace:kept:');
|
||||
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
|
||||
try {
|
||||
const subs = [];
|
||||
for (const { handle } of [retired, withdrawn, kept])
|
||||
subs.push(
|
||||
(
|
||||
await events().subscribe(guest.actor, socketId, {
|
||||
subject: `kv:${handle}:*`,
|
||||
})
|
||||
).sub,
|
||||
);
|
||||
|
||||
// Neither goes through a retire this region hears about: a
|
||||
// handle retired from another region, and a grant withdrawn
|
||||
// with its handle left live.
|
||||
await env.server.stores.kvShareHandle.retireMany(
|
||||
[retired.handle],
|
||||
'owner',
|
||||
);
|
||||
const grant = await env.server.stores.kvShareHandle.getByHandle(
|
||||
withdrawn.handle,
|
||||
);
|
||||
await env.server.services.permission.revokeUserUserPermissionSubtree(
|
||||
owner.actor,
|
||||
guest.username,
|
||||
kvShareGrantAtEveryLevel(grant!.permission),
|
||||
{ reason: 'test' },
|
||||
);
|
||||
expect(await heldOn(guest.id, socketId)).toHaveLength(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
(SESSION_SUBSCRIPTION_TTL_SECONDS * 1000) / 3,
|
||||
);
|
||||
|
||||
await endedOn(socketId, subs[0].subId);
|
||||
await endedOn(socketId, subs[1].subId);
|
||||
expect(await heldOn(guest.id, socketId)).toEqual([
|
||||
subs[2].subId,
|
||||
]);
|
||||
} finally {
|
||||
await events().reapSocket(guest.id, socketId);
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('finds its subscriptions on the holder index, not by scanning', async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
@@ -412,6 +568,307 @@ describe('revoking a handle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a queued row whose share can`t be checked right now', () => {
|
||||
const handles = () => env.server.stores.kvShareHandle;
|
||||
const pending = () => env.server.stores.pendingDelivery;
|
||||
|
||||
/**
|
||||
* A durable `single` on a fresh handle, with a backlog waiting and the
|
||||
* lease its first hand-over took lapsed, so a sweep reads it again.
|
||||
*/
|
||||
const queued = async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
const sub = await subscribeDurable(handle);
|
||||
await ownerWrites(`${PREFIX}messages:1`, { body: 'queued' });
|
||||
const lease = `ev:ql:{${sub.subId}}`;
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await pending().depth(sub.subId)).toBeGreaterThan(0);
|
||||
expect(await env.server.clients.redis.zcard(lease)).toBe(1);
|
||||
},
|
||||
{ timeout: 5_000, interval: 25 },
|
||||
);
|
||||
await env.server.clients.redis.del(lease);
|
||||
return { handle, sub, depth: await pending().depth(sub.subId) };
|
||||
};
|
||||
|
||||
/** One sweep pass, reporting whether it went for this row's backlog. */
|
||||
const sweepClaims = async (subId: string) => {
|
||||
const claim = vi.spyOn(pending(), 'claim');
|
||||
try {
|
||||
await events().sweepPending();
|
||||
return claim.mock.calls.some(([id]) => id === subId);
|
||||
} finally {
|
||||
claim.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
it('keeps the row and its backlog when the share lookup fails', async () => {
|
||||
const { sub, depth } = await queued();
|
||||
|
||||
const failing = vi
|
||||
.spyOn(handles(), 'getLive')
|
||||
.mockRejectedValue(new Error('lookup unavailable'));
|
||||
try {
|
||||
expect(await sweepClaims(sub.subId)).toBe(false);
|
||||
} finally {
|
||||
failing.mockRestore();
|
||||
}
|
||||
|
||||
expect((await rowOf(sub.subId))?.suspended_reason).toBeNull();
|
||||
expect(await pending().depth(sub.subId)).toBe(depth);
|
||||
expect(await sweepClaims(sub.subId)).toBe(true);
|
||||
});
|
||||
|
||||
it('holds the backlog while the owner is suspended, and hands it out after', async () => {
|
||||
const { handle, sub, depth } = await queued();
|
||||
const live = await handles().getLive(handle);
|
||||
|
||||
const suspended = vi
|
||||
.spyOn(handles(), 'getLive')
|
||||
.mockResolvedValue({ ...live!, ownerSuspended: true });
|
||||
try {
|
||||
expect(await sweepClaims(sub.subId)).toBe(false);
|
||||
} finally {
|
||||
suspended.mockRestore();
|
||||
}
|
||||
expect((await rowOf(sub.subId))?.suspended_reason).toBeNull();
|
||||
expect(await pending().depth(sub.subId)).toBe(depth);
|
||||
|
||||
expect(await sweepClaims(sub.subId)).toBe(true);
|
||||
expect((await rowOf(sub.subId))?.suspended_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('still settles a share that is gone', async () => {
|
||||
const { sub } = await queued();
|
||||
|
||||
const gone = vi.spyOn(handles(), 'getLive').mockResolvedValue(null);
|
||||
try {
|
||||
expect(await sweepClaims(sub.subId)).toBe(false);
|
||||
} finally {
|
||||
gone.mockRestore();
|
||||
}
|
||||
|
||||
expect((await rowOf(sub.subId))?.suspended_reason).toBe(
|
||||
'permission_revoked',
|
||||
);
|
||||
expect(await pending().depth(sub.subId)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a queued backlog after its share drops to watch', () => {
|
||||
const HANDLER = 'onChange';
|
||||
const pending = () => env.server.stores.pendingDelivery;
|
||||
const socket = () => env.server.services.socket;
|
||||
const soon = (check: () => unknown) =>
|
||||
vi.waitFor(check, { timeout: 5_000, interval: 25 });
|
||||
|
||||
let app: { uid: string; id: number };
|
||||
let guestApp: Actor;
|
||||
let worker: RecordingWorkerInvoker;
|
||||
let previousWorker: ReturnType<typeof events>['worker'];
|
||||
|
||||
beforeAll(async () => {
|
||||
const name = `kv-queued-${uuidv4().slice(0, 8)}`;
|
||||
app = await env.server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'Queued Values',
|
||||
index_url: `https://${name}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: owner.id },
|
||||
);
|
||||
guestApp = makeActor({
|
||||
user: guest.actor.user as never,
|
||||
app: { uid: app.uid, id: app.id },
|
||||
});
|
||||
// The holder launching the app, and letting its handler run unattended.
|
||||
for (const permission of [
|
||||
'flag:app-is-authenticated',
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
])
|
||||
await env.server.services.permission.grantUserAppPermission(
|
||||
guest.actor,
|
||||
app.uid,
|
||||
permission,
|
||||
);
|
||||
await events().publishHandler(owner.actor, {
|
||||
appUid: app.uid,
|
||||
name: HANDLER,
|
||||
source: 'async ({ event }) => { console.log(event.key); }',
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearRows();
|
||||
const limits = [EVENTS_KV_HANDLE_LIMIT, EVENTS_SUBSCRIBE_LIMIT];
|
||||
for (const user of [owner, guest])
|
||||
for (const limit of limits)
|
||||
await env.server.clients.redis.del(
|
||||
`rate:${limit.scope}:${user.id}`,
|
||||
);
|
||||
previousWorker = events().worker;
|
||||
worker = new RecordingWorkerInvoker();
|
||||
events().worker = worker;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
events().worker = previousWorker;
|
||||
});
|
||||
|
||||
/** Sharing a region again is how its level changes. */
|
||||
const shareAt = (
|
||||
prefix: string,
|
||||
access: 'read' | 'watch',
|
||||
appUid?: string,
|
||||
) =>
|
||||
shares().mintKvHandle(
|
||||
owner.actor,
|
||||
{
|
||||
prefix,
|
||||
recipient: { username: guest.username },
|
||||
access,
|
||||
...(appUid ? { appUid } : {}),
|
||||
},
|
||||
{ defaultAccess: 'watch' },
|
||||
);
|
||||
|
||||
const subscribeValues = async (handle: string, actor = guest.actor) =>
|
||||
(
|
||||
await events().subscribeDurable(actor, {
|
||||
subject: `kv:${handle}:*`,
|
||||
delivery: 'single',
|
||||
handlerName: HANDLER,
|
||||
includeValue: true,
|
||||
})
|
||||
).sub;
|
||||
|
||||
const writes = (key: string, value: unknown, appUid?: string) =>
|
||||
runWithContext({ actor: owner.actor }, () =>
|
||||
env.server.drivers.kvStore.set({
|
||||
key,
|
||||
value,
|
||||
...(appUid ? { optConfig: { appUuid: appUid } } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
/** One entry queued and leased to nobody, so the next drain claims it. */
|
||||
const backlogged = async (subId: string) => {
|
||||
await soon(async () => expect(await pending().depth(subId)).toBe(1));
|
||||
await env.server.clients.redis.del(`ev:ql:{${subId}}`);
|
||||
};
|
||||
|
||||
const deliveredTo = (subId: string) =>
|
||||
delivered.find((one) => one.subId === subId);
|
||||
|
||||
const ranFor = (subId: string) =>
|
||||
worker.recorded
|
||||
.filter((one) => one.subId === subId)
|
||||
.map((one) => one.event);
|
||||
|
||||
/** Run `step` with the holder connected in this region. */
|
||||
const connected = async (step: () => Promise<unknown>) => {
|
||||
const has = vi.spyOn(socket(), 'has').mockReturnValue(true);
|
||||
try {
|
||||
await step();
|
||||
} finally {
|
||||
has.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
it('hands a connected client the backlog without the value', async () => {
|
||||
const prefix = 'queued:socket:';
|
||||
const { handle } = await shareAt(prefix, 'read');
|
||||
const sub = await subscribeValues(handle);
|
||||
await writes(`${prefix}secret`, 'private-value');
|
||||
await backlogged(sub.subId);
|
||||
|
||||
await shareAt(prefix, 'watch');
|
||||
delivered.length = 0;
|
||||
await connected(() => events().sweepPending());
|
||||
|
||||
const event = deliveredTo(sub.subId)?.event;
|
||||
expect(event).toMatchObject({ key: 'secret', op: 'set' });
|
||||
expect(event).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('leaves the value off an older entry a new write hands out', async () => {
|
||||
const prefix = 'queued:next-write:';
|
||||
const { handle } = await shareAt(prefix, 'read');
|
||||
const sub = await subscribeValues(handle);
|
||||
await writes(`${prefix}first`, 'private-value');
|
||||
await backlogged(sub.subId);
|
||||
|
||||
await shareAt(prefix, 'watch');
|
||||
delivered.length = 0;
|
||||
await connected(async () => {
|
||||
await writes(`${prefix}second`, 'later');
|
||||
await soon(() => expect(deliveredTo(sub.subId)).toBeDefined());
|
||||
});
|
||||
|
||||
const event = deliveredTo(sub.subId)?.event;
|
||||
expect(event).toMatchObject({ key: 'first' });
|
||||
expect(event).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('hands a handler run the backlog without the value', async () => {
|
||||
const prefix = 'queued:worker:';
|
||||
const { handle } = await shareAt(prefix, 'read', app.uid);
|
||||
const sub = await subscribeValues(handle, guestApp);
|
||||
await writes(`${prefix}secret`, 'private-value', app.uid);
|
||||
await soon(() => expect(ranFor(sub.subId)).toHaveLength(1));
|
||||
expect(ranFor(sub.subId)[0]).toMatchObject({ value: 'private-value' });
|
||||
|
||||
await shareAt(prefix, 'watch', app.uid);
|
||||
await env.server.clients.redis.del(`ev:ql:{${sub.subId}}`);
|
||||
await events().sweepPending();
|
||||
|
||||
const [, again] = ranFor(sub.subId);
|
||||
expect(again).toMatchObject({ key: 'secret' });
|
||||
expect(again).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('hands a delivery its client declined to the handler without the value', async () => {
|
||||
const prefix = 'queued:declined:';
|
||||
const { handle } = await shareAt(prefix, 'read', app.uid);
|
||||
const sub = await subscribeValues(handle, guestApp);
|
||||
delivered.length = 0;
|
||||
await connected(async () => {
|
||||
await writes(`${prefix}secret`, 'private-value', app.uid);
|
||||
await soon(() => expect(deliveredTo(sub.subId)).toBeDefined());
|
||||
});
|
||||
const envelope = deliveredTo(sub.subId)!;
|
||||
expect(envelope.event).toMatchObject({ value: 'private-value' });
|
||||
|
||||
await shareAt(prefix, 'watch', app.uid);
|
||||
await events().declineDelivery(guestApp, {
|
||||
subId: sub.subId,
|
||||
id: envelope.ackId,
|
||||
});
|
||||
|
||||
const [run] = ranFor(sub.subId);
|
||||
expect(run).toMatchObject({ key: 'secret' });
|
||||
expect(run).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('leaves the owner`s own backlog its values', async () => {
|
||||
const prefix = 'queued:owner:';
|
||||
const { handle } = await shareAt(prefix, 'watch');
|
||||
const sub = await subscribeValues(handle, owner.actor);
|
||||
await writes(`${prefix}mine`, 'kept');
|
||||
await backlogged(sub.subId);
|
||||
|
||||
delivered.length = 0;
|
||||
await connected(() => events().sweepPending());
|
||||
|
||||
expect(deliveredTo(sub.subId)?.event).toMatchObject({
|
||||
key: 'mine',
|
||||
value: 'kept',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the owner`s handle listing', () => {
|
||||
it('shows what was shared, retired handles included', async () => {
|
||||
const { handle } = await mint();
|
||||
|
||||
@@ -49,12 +49,15 @@ import { DEFAULT_FREE_SUBSCRIPTION } from '../metering/consts.js';
|
||||
import type { DeliveryEnvelope } from './EventsService.js';
|
||||
import { kvSharePermission } from '../kvShare/kvShares.js';
|
||||
import { seedKvShareHandles } from '../kvShare/kvShareTestUtil.js';
|
||||
import { MATCH_PATTERN_MAX_LENGTH } from './matcher.js';
|
||||
import { isKvHandleId, kvAnchorToken } from './subjects.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
const SOCKET_ID = 'kv-handle-socket';
|
||||
const TABLE = 'event_subscriptions';
|
||||
const PREFIX = 'workspace:abc:';
|
||||
/** Long enough that anything composed onto it passes the indexed-prefix cap. */
|
||||
const LONG_PREFIX = `workspace:${'a'.repeat(150)}:`;
|
||||
|
||||
interface TestUser {
|
||||
actor: Actor;
|
||||
@@ -542,6 +545,58 @@ describe('subscribing through a handle', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('holds a pattern to its limit as the holder wrote it, whatever the prefix', async () => {
|
||||
await clearRows();
|
||||
const handles = [
|
||||
(await mint()).handle,
|
||||
(await mint({ prefix: LONG_PREFIX })).handle,
|
||||
];
|
||||
const longest = `${'x'.repeat(MATCH_PATTERN_MAX_LENGTH - 1)}*`;
|
||||
const over = `${'x'.repeat(MATCH_PATTERN_MAX_LENGTH)}*`;
|
||||
|
||||
for (const handle of handles) {
|
||||
await expect(
|
||||
events().subscribe(guest.actor, SOCKET_ID, {
|
||||
subject: `kv:${handle}:${longest}`,
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
// The same refusal for both, naming only what the holder wrote.
|
||||
await expect(
|
||||
events().subscribe(guest.actor, SOCKET_ID, {
|
||||
subject: `kv:${handle}:${over}`,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
legacyCode: 'invalid_subject_pattern',
|
||||
message: `Match pattern exceeds ${MATCH_PATTERN_MAX_LENGTH} characters: ${over}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('describes its filter the same way whatever the prefix', async () => {
|
||||
const handles = [
|
||||
(await mint()).handle,
|
||||
(await mint({ prefix: LONG_PREFIX })).handle,
|
||||
];
|
||||
for (const pattern of ['messages:*', 'messages*', 'a:b:c:d:e:f']) {
|
||||
await clearRows();
|
||||
const views = [];
|
||||
for (const handle of handles) {
|
||||
const { sub } = await events().subscribe(
|
||||
guest.actor,
|
||||
SOCKET_ID,
|
||||
{ subject: `kv:${handle}:${pattern}` },
|
||||
);
|
||||
const [listed] = await events().listSubscriptions(
|
||||
guest.actor,
|
||||
SOCKET_ID,
|
||||
);
|
||||
views.push(sub.match, listed.match);
|
||||
await events().reapSocket(guest.id, SOCKET_ID);
|
||||
}
|
||||
expect(new Set(views).size).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a user the handle was not granted to', async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
@@ -587,6 +642,24 @@ describe('a write in the shared region', () => {
|
||||
expect(JSON.stringify(delivered[0])).not.toContain(owner.uuid);
|
||||
});
|
||||
|
||||
it('reaches a pattern that runs past the limit once composed onto the prefix', async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint({ prefix: LONG_PREFIX });
|
||||
const key = 'k'.repeat(200);
|
||||
const { sub } = await events().subscribe(guest.actor, SOCKET_ID, {
|
||||
subject: `kv:${handle}:${key}*`,
|
||||
});
|
||||
delivered.length = 0;
|
||||
|
||||
await ownerWrites(`${LONG_PREFIX}${key}:1`, 1);
|
||||
await settled();
|
||||
|
||||
expect(delivered[0]).toMatchObject({
|
||||
subId: sub.subId,
|
||||
event: { key: `${key}:1` },
|
||||
});
|
||||
});
|
||||
|
||||
it('reaches a durable subscription, key after key', async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
@@ -646,9 +719,10 @@ describe('a write in the shared region', () => {
|
||||
),
|
||||
{ timeout: EVENTS_COALESCE_WINDOW_MS * 12, interval: 25 },
|
||||
);
|
||||
expect(
|
||||
delivered.find((one) => one.subId === sub.subId)?.event,
|
||||
).toMatchObject({ key: 'title', value: 'hello' });
|
||||
// A `watch` share carries keys only, whatever the subscription asked.
|
||||
const event = delivered.find((one) => one.subId === sub.subId)?.event;
|
||||
expect(event).toMatchObject({ key: 'title' });
|
||||
expect(event).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('stays inside the region the handle was granted on', async () => {
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface CompileMatchOptions {
|
||||
separator?: string | null;
|
||||
/** Let a wildcard-free pattern also match everything beneath it. */
|
||||
literalCoversSubtree?: boolean;
|
||||
/** Longest pattern accepted; defaults to {@link MATCH_PATTERN_MAX_LENGTH}. */
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
const WILDCARD = /[*?]/;
|
||||
@@ -91,14 +93,12 @@ export function compileMatch(
|
||||
options: CompileMatchOptions = {},
|
||||
): CompiledMatch {
|
||||
const separator = options.separator === undefined ? '/' : options.separator;
|
||||
const maxLength = options.maxLength ?? MATCH_PATTERN_MAX_LENGTH;
|
||||
|
||||
if (typeof pattern !== 'string' || pattern.length === 0)
|
||||
throw invalidPattern('is empty');
|
||||
if (pattern.length > MATCH_PATTERN_MAX_LENGTH)
|
||||
throw invalidPattern(
|
||||
`exceeds ${MATCH_PATTERN_MAX_LENGTH} characters`,
|
||||
pattern,
|
||||
);
|
||||
if (pattern.length > maxLength)
|
||||
throw invalidPattern(`exceeds ${maxLength} characters`, pattern);
|
||||
|
||||
const raw = separator === null ? [pattern] : pattern.split(separator);
|
||||
if (raw.length > MATCH_PATTERN_MAX_SEGMENTS)
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { projectNotifRow, resolveNotifFetch } from './notifFetch.js';
|
||||
import {
|
||||
mergeNotifCatchUp,
|
||||
notifFetchCursor,
|
||||
notifFetchPosition,
|
||||
projectNotifRow,
|
||||
resolveNotifFetch,
|
||||
} from './notifFetch.js';
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111';
|
||||
const APP = 'app-22222222-2222-4222-8222-222222222222';
|
||||
@@ -140,6 +146,24 @@ describe('projectNotifRow', () => {
|
||||
expect(asEpochSeconds.ts).toBe(Date.UTC(2026, 7, 30, 1, 2, 3));
|
||||
});
|
||||
|
||||
it('versions a replaced row by its replacement time', () => {
|
||||
// Postgres hands a bigint back as a string.
|
||||
for (const replacedAt of [1_790_000_000_123, '1790000000123']) {
|
||||
const projected = projectNotifRow(
|
||||
{ ...row, replaced_at: replacedAt },
|
||||
USER,
|
||||
0,
|
||||
);
|
||||
expect(projected).toMatchObject({
|
||||
replaced: true,
|
||||
ts: 1_790_000_000_123,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
projectNotifRow({ ...row, replaced_at: null }, USER, 0),
|
||||
).not.toHaveProperty('replaced');
|
||||
});
|
||||
|
||||
it('names the app when the row is about one', () => {
|
||||
const projected = projectNotifRow(
|
||||
{ ...row, audience: 'app-user', app_uid: APP },
|
||||
@@ -150,3 +174,88 @@ describe('projectNotifRow', () => {
|
||||
expect(projected.appUid).toBe(APP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifFetchPosition', () => {
|
||||
it('starts the replacement mark from scratch on a cursor without one', () => {
|
||||
expect(notifFetchPosition({ id: 7 })).toEqual({
|
||||
id: 7,
|
||||
replaced: { replacedAt: 0, id: 0 },
|
||||
});
|
||||
expect(notifFetchPosition(undefined).id).toBeNull();
|
||||
});
|
||||
|
||||
it('reads back what it wrote', () => {
|
||||
const position = { id: 9, replaced: { replacedAt: 1_500, id: 4 } };
|
||||
expect(notifFetchPosition(notifFetchCursor(position))).toEqual(
|
||||
position,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeNotifCatchUp', () => {
|
||||
/** A row created at second `created`, replaced at `replaced` ms if set. */
|
||||
const row = (
|
||||
id: number,
|
||||
created: number,
|
||||
replaced: number | null = null,
|
||||
) => ({
|
||||
id,
|
||||
uid: `n-${id}`,
|
||||
created_at: created,
|
||||
replaced_at: replaced,
|
||||
});
|
||||
const idsOf = (rows: Array<Record<string, unknown>>) =>
|
||||
rows.map((one) => one.id);
|
||||
const from = { id: 10, replaced: { replacedAt: 1_000, id: 0 } };
|
||||
|
||||
it('merges both reads oldest first, and ends when both ran out', () => {
|
||||
const { rows, next } = mergeNotifCatchUp(
|
||||
[row(11, 2), row(12, 4)],
|
||||
[row(3, 1, 3_000)],
|
||||
5,
|
||||
from,
|
||||
);
|
||||
expect(idsOf(rows)).toEqual([11, 3, 12]);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it('resumes each read after the last row it took', () => {
|
||||
const { rows, next } = mergeNotifCatchUp(
|
||||
[row(11, 2), row(12, 4), row(13, 6)],
|
||||
[row(3, 1, 3_000), row(5, 1, 5_000), row(4, 1, 7_000)],
|
||||
2,
|
||||
from,
|
||||
);
|
||||
expect(idsOf(rows)).toEqual([11, 3]);
|
||||
expect(next).toEqual({
|
||||
id: 11,
|
||||
replaced: { replacedAt: 3_000, id: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the mark while replacements behind it are still unread', () => {
|
||||
const { next } = mergeNotifCatchUp(
|
||||
[row(11, 2, 9_000), row(12, 4)],
|
||||
[row(3, 9, 9_500), row(4, 9, 9_600)],
|
||||
1,
|
||||
from,
|
||||
);
|
||||
expect(next).toEqual({ id: 11, replaced: from.replaced });
|
||||
});
|
||||
|
||||
it('moves the mark past every row it read once no replacement is left behind', () => {
|
||||
// Row 11 was replaced before this read, so it went out as it stands
|
||||
// and the next read must not return it again.
|
||||
const { rows, next } = mergeNotifCatchUp(
|
||||
[row(11, 2, 8_000), row(12, 4), row(13, 6)],
|
||||
[],
|
||||
2,
|
||||
{ id: null, replaced: { replacedAt: 0, id: 0 } },
|
||||
);
|
||||
expect(idsOf(rows)).toEqual([11, 12]);
|
||||
expect(next).toEqual({
|
||||
id: 12,
|
||||
replaced: { replacedAt: 8_000, id: 11 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -91,18 +91,130 @@ const epochMs = (value: unknown): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
type NotifRow = Record<string, unknown>;
|
||||
|
||||
/** Drivers hand a bigint back as a number or a string. */
|
||||
const replacedAtOf = (row: NotifRow): number | null =>
|
||||
row.replaced_at === null || row.replaced_at === undefined
|
||||
? null
|
||||
: Number(row.replaced_at);
|
||||
|
||||
/** A row's `ts`: when it was replaced, or else when it was created. */
|
||||
const rowTs = (row: NotifRow): number =>
|
||||
replacedAtOf(row) ?? epochMs(row.created_at);
|
||||
|
||||
/** A replacement's place in replacement order. */
|
||||
interface ReplacedKey {
|
||||
replacedAt: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a catch-up resumes: after `id`, the last row read in insertion order
|
||||
* (null before the first page), and after `replaced`, the last replacement read
|
||||
* among the rows up to `id`.
|
||||
*/
|
||||
export interface NotifFetchPosition {
|
||||
id: number | null;
|
||||
replaced: ReplacedKey;
|
||||
}
|
||||
|
||||
/** The position an opened cursor names. A missing part starts from scratch. */
|
||||
export const notifFetchPosition = (
|
||||
cursor: Record<string, unknown> | undefined,
|
||||
): NotifFetchPosition => {
|
||||
const id = Number(cursor?.id);
|
||||
const replacedAt = Number(cursor?.replacedAt);
|
||||
const replacedId = Number(cursor?.replacedId);
|
||||
return {
|
||||
id: Number.isSafeInteger(id) && id >= 0 ? id : null,
|
||||
replaced:
|
||||
Number.isSafeInteger(replacedAt) && Number.isSafeInteger(replacedId)
|
||||
? { replacedAt, id: replacedId }
|
||||
: { replacedAt: 0, id: 0 },
|
||||
};
|
||||
};
|
||||
|
||||
/** The cursor payload for `position`. */
|
||||
export const notifFetchCursor = (
|
||||
position: NotifFetchPosition,
|
||||
): Record<string, unknown> => ({
|
||||
id: position.id,
|
||||
replacedAt: position.replaced.replacedAt,
|
||||
replacedId: position.replaced.id,
|
||||
});
|
||||
|
||||
const laterKey = (a: ReplacedKey, b: ReplacedKey): ReplacedKey =>
|
||||
a.replacedAt > b.replacedAt ||
|
||||
(a.replacedAt === b.replacedAt && a.id > b.id)
|
||||
? a
|
||||
: b;
|
||||
|
||||
const replacedKeyOf = (row: NotifRow): ReplacedKey => ({
|
||||
replacedAt: replacedAtOf(row) ?? 0,
|
||||
id: Number(row.id),
|
||||
});
|
||||
|
||||
/**
|
||||
* One catch-up page from its two reads, each `limit + 1` deep: `inserted`, the
|
||||
* rows after `from.id` in id order, and `replaced`, the rows up to it replaced
|
||||
* after `from.replaced`, in replacement order. Merged oldest first by `ts`.
|
||||
* `next` is where the following page starts, or null on the last page.
|
||||
*/
|
||||
export const mergeNotifCatchUp = (
|
||||
inserted: NotifRow[],
|
||||
replaced: NotifRow[],
|
||||
limit: number,
|
||||
from: NotifFetchPosition,
|
||||
): { rows: NotifRow[]; next: NotifFetchPosition | null } => {
|
||||
const rows: NotifRow[] = [];
|
||||
let i = 0;
|
||||
let r = 0;
|
||||
while (
|
||||
rows.length < limit &&
|
||||
(i < inserted.length || r < replaced.length)
|
||||
) {
|
||||
const takeReplaced =
|
||||
r < replaced.length &&
|
||||
(i === inserted.length || rowTs(replaced[r]) < rowTs(inserted[i]));
|
||||
rows.push(takeReplaced ? replaced[r++] : inserted[i++]);
|
||||
}
|
||||
if (i === inserted.length && r === replaced.length)
|
||||
return { rows, next: null };
|
||||
|
||||
// With every replacement behind `from.id` read, each row up to the new id
|
||||
// was read as it stands, so the mark moves past all of them.
|
||||
const mark =
|
||||
r === replaced.length
|
||||
? rows
|
||||
.filter((row) => replacedAtOf(row) !== null)
|
||||
.map(replacedKeyOf)
|
||||
.reduce(laterKey, from.replaced)
|
||||
: r > 0
|
||||
? replacedKeyOf(replaced[r - 1])
|
||||
: from.replaced;
|
||||
return {
|
||||
rows,
|
||||
next: {
|
||||
id: i > 0 ? Number(inserted[i - 1].id) : from.id,
|
||||
replaced: mark,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* One stored notification in the shape a live delivery has. `id` is the row's
|
||||
* uid on both paths, so a client that fetched a row and then received it can
|
||||
* tell they are the same notification.
|
||||
*/
|
||||
export const projectNotifRow = (
|
||||
row: Record<string, unknown>,
|
||||
row: NotifRow,
|
||||
userUuid: string,
|
||||
seq: number,
|
||||
): ProjectedNotifEvent => {
|
||||
const appUid = (row.app_uid as string | null) ?? null;
|
||||
const audience = (row.audience as NotificationAudience) ?? 'account';
|
||||
const replacedAt = replacedAtOf(row);
|
||||
return {
|
||||
id: String(row.uid),
|
||||
subject: `notif:${appUid ?? userUuid}:${audience}`,
|
||||
@@ -112,9 +224,10 @@ export const projectNotifRow = (
|
||||
audience,
|
||||
appUid,
|
||||
notification: (row.value ?? {}) as Record<string, unknown>,
|
||||
...(replacedAt !== null ? { replaced: true as const } : {}),
|
||||
// A fetched row is always the caller's own mailbox.
|
||||
self: true,
|
||||
ts: epochMs(row.created_at),
|
||||
ts: rowTs(row),
|
||||
seq,
|
||||
};
|
||||
};
|
||||
@@ -431,6 +431,67 @@ describe('notifications with the fold-in on', () => {
|
||||
await state.server.services.events.reapSocket(user.id, 'socket-app');
|
||||
});
|
||||
|
||||
it("delivers an app message's replacement under its uid, with the new content", async () => {
|
||||
const user = await makeUser(state.server);
|
||||
const appUid = await makeApp(state.server, user.id);
|
||||
const appActor = {
|
||||
user: { id: user.id, uuid: user.uuid, username: user.username },
|
||||
app: { uid: appUid },
|
||||
effectiveApp: { uid: appUid },
|
||||
};
|
||||
await state.server.services.events.subscribe(
|
||||
appActor as never,
|
||||
'socket-app',
|
||||
{ subject: 'notif:app-user' },
|
||||
);
|
||||
|
||||
const notifications = state.server.services.notification;
|
||||
const uid = await notifications.notify(
|
||||
[user.id],
|
||||
{ title: 'one message' },
|
||||
{ type: 'app.message', appUid, group: 'chat-1' },
|
||||
);
|
||||
await waitFor(() => state.delivered.length === 1);
|
||||
await notifications.notifyUpdate(
|
||||
uid!,
|
||||
user.id,
|
||||
{ title: 'two messages' },
|
||||
{ type: 'app.message', replace: true },
|
||||
);
|
||||
await waitFor(() => state.delivered.length === 2);
|
||||
await settle();
|
||||
|
||||
const [first, second] = state.delivered.map((d) => d.event);
|
||||
expect(first).toMatchObject({
|
||||
id: uid,
|
||||
notification: { title: 'one message', type: 'app.message' },
|
||||
});
|
||||
expect(first).not.toHaveProperty('replaced');
|
||||
expect(second).toMatchObject({
|
||||
id: uid,
|
||||
notification: { title: 'two messages', type: 'app.message' },
|
||||
replaced: true,
|
||||
});
|
||||
// An app's message never reaches the desktop wire.
|
||||
expect(notifSends(state.sent)).toEqual([]);
|
||||
|
||||
// The fetched copy carries the same version as the live one.
|
||||
const page = await state.server.services.events.fetchMissed(
|
||||
appActor as never,
|
||||
{ subject: 'notif:app-user', limit: 50 },
|
||||
);
|
||||
expect(page.items).toEqual([
|
||||
expect.objectContaining({
|
||||
id: uid,
|
||||
replaced: true,
|
||||
ts: (second as { ts: number }).ts,
|
||||
notification: { title: 'two messages', type: 'app.message' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await state.server.services.events.reapSocket(user.id, 'socket-app');
|
||||
});
|
||||
|
||||
it('delivers a developer row naming an app to its owner\'s own generic subscription', async () => {
|
||||
const user = await makeUser(state.server);
|
||||
const appUid = await makeApp(state.server, user.id);
|
||||
|
||||
@@ -110,6 +110,12 @@ export interface ProjectedNotifEvent extends ProjectedEventBase {
|
||||
/** App the notification is about; `null` for platform notifications. */
|
||||
appUid: string | null;
|
||||
notification: Record<string, unknown>;
|
||||
/**
|
||||
* Present when the notification replaced an earlier one under the same uid.
|
||||
* `ts` is then the replacement time, the same on live and fetched copies,
|
||||
* so it versions the uid.
|
||||
*/
|
||||
replaced?: true;
|
||||
}
|
||||
|
||||
export type ProjectedEvent =
|
||||
@@ -200,6 +206,8 @@ export interface NotifEventContext extends EventContextBase {
|
||||
audience: NotificationAudience;
|
||||
appUid: string | null;
|
||||
notification: Record<string, unknown>;
|
||||
/** An earlier notification replaced in place; `ts` is when. */
|
||||
replaced?: true;
|
||||
}
|
||||
|
||||
export type FsDeliveryContext = FsEventContext & DeliveryFields;
|
||||
@@ -345,6 +353,7 @@ const notifProject = (delivery: NotifDeliveryContext): ProjectedNotifEvent => ({
|
||||
audience: delivery.audience,
|
||||
appUid: delivery.appUid,
|
||||
notification: delivery.notification,
|
||||
...(delivery.replaced ? { replaced: true as const } : {}),
|
||||
self: delivery.self,
|
||||
ts: delivery.ts,
|
||||
seq: delivery.seq,
|
||||
|
||||
@@ -527,11 +527,14 @@ describe('what a revoked grant settles', () => {
|
||||
).sub;
|
||||
|
||||
await write(uniquePath(inner));
|
||||
const lease = `ev:ql:{${sub.subId}}`;
|
||||
await vi.waitFor(
|
||||
async () =>
|
||||
async () => {
|
||||
expect(
|
||||
await env.server.stores.pendingDelivery.depth(sub.subId),
|
||||
).toBeGreaterThan(0),
|
||||
).toBeGreaterThan(0);
|
||||
expect(await env.server.clients.redis.zcard(lease)).toBe(1);
|
||||
},
|
||||
{ timeout: 5_000, interval: 25 },
|
||||
);
|
||||
|
||||
@@ -539,6 +542,8 @@ describe('what a revoked grant settles', () => {
|
||||
await unshare(shared, 'list');
|
||||
expect((await rowOf(sub.subId)).suspended_reason).toBeNull();
|
||||
|
||||
// The first hand-over's lease lapses, and the sweep reads the row.
|
||||
await env.server.clients.redis.del(lease);
|
||||
delivered.length = 0;
|
||||
await events().sweepPending();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
EVENTS_COALESCE_WINDOW_MS,
|
||||
EVENTS_REGION_PENDING_CEILING,
|
||||
EVENTS_RETRY_MAX_MS,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_SOCKET_LEASE_MS,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
@@ -62,6 +63,8 @@ let sent: DeliveryEnvelope[];
|
||||
let delivered: DeliveryEnvelope[];
|
||||
let invoked: WorkerInvocation[];
|
||||
let rows: Map<string, DurableSubscription>;
|
||||
/** Every subscription row the service read, in order. */
|
||||
let rowReads: string[];
|
||||
let entries: Map<string, FSEntry>;
|
||||
let alarms: ReturnType<typeof vi.fn>;
|
||||
let notified: ReturnType<typeof vi.fn>;
|
||||
@@ -82,6 +85,8 @@ interface MeteredLine {
|
||||
let socketConnected = true;
|
||||
/** What the handler seam reports back, which is what settles a lease or not. */
|
||||
let workerOutcome: WorkerInvocationOutcome = 'deferred';
|
||||
/** The least wait the handler seam's answer names, if any. */
|
||||
let workerRetryAfterMs: number | undefined;
|
||||
/** Stand-in for presence: which remote region, if any, answers one attempt. */
|
||||
let candidateRegionImpl: (
|
||||
holderUserId: number,
|
||||
@@ -154,11 +159,23 @@ const register = async (
|
||||
return row;
|
||||
};
|
||||
|
||||
const dispatch = (node = entry()): Promise<void> =>
|
||||
service.dispatchFs('fs.write.file', node, {
|
||||
/**
|
||||
* Let the hand-over a `single` starts off the dispatch path run to where it
|
||||
* parks: a socket send, a handler run, or a lease.
|
||||
*/
|
||||
const handedOver = async (): Promise<void> => {
|
||||
for (let i = 0; i < 20; i++)
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
};
|
||||
|
||||
const dispatch = async (node = entry()): Promise<boolean> => {
|
||||
const matched = await service.dispatchFs('fs.write.file', node, {
|
||||
actingUserId: userId,
|
||||
ancestors: async () => ancestors(),
|
||||
});
|
||||
await handedOver();
|
||||
return matched;
|
||||
};
|
||||
|
||||
/** Wait out the coalescing window a `broadcast` delivery sits in. */
|
||||
const flushed = (count = 1): Promise<void> =>
|
||||
@@ -180,12 +197,14 @@ beforeEach(async () => {
|
||||
userId = 7000 + seq;
|
||||
socketConnected = true;
|
||||
workerOutcome = 'deferred';
|
||||
workerRetryAfterMs = undefined;
|
||||
candidateRegionImpl = async () => null;
|
||||
candidateRegionCalls = [];
|
||||
sent = [];
|
||||
delivered = [];
|
||||
invoked = [];
|
||||
rows = new Map();
|
||||
rowReads = [];
|
||||
entries = new Map();
|
||||
alarms = vi.fn();
|
||||
notified = vi.fn();
|
||||
@@ -220,7 +239,10 @@ beforeEach(async () => {
|
||||
pendingDelivery: pending,
|
||||
durableSubscription: {
|
||||
warmRegion: async () => false,
|
||||
getBySubId: async (subId: string) => rows.get(subId) ?? null,
|
||||
getBySubId: async (subId: string) => {
|
||||
rowReads.push(subId);
|
||||
return rows.get(subId) ?? null;
|
||||
},
|
||||
remove: async (row: DurableSubscription) => {
|
||||
rows.delete(row.subId);
|
||||
return { userId: row.holderUserId, generation: 1 };
|
||||
@@ -312,7 +334,7 @@ beforeEach(async () => {
|
||||
service.worker = {
|
||||
invoke: async (invocation: WorkerInvocation) => {
|
||||
invoked.push(invocation);
|
||||
return workerOutcome;
|
||||
return { outcome: workerOutcome, retryAfterMs: workerRetryAfterMs };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -494,6 +516,75 @@ describe('a delivery owed to exactly one consumer', () => {
|
||||
await expect(redis.get(`ev:qf:{${row.subId}}`)).resolves.toBe('2');
|
||||
});
|
||||
|
||||
it('retries a run Puter could not complete without counting it or billing for it', async () => {
|
||||
socketConnected = false;
|
||||
workerOutcome = 'unavailable';
|
||||
const row = await register();
|
||||
|
||||
await dispatch();
|
||||
for (let attempt = 1; attempt < 8; attempt++) {
|
||||
jump(deliveryBackoffMs(attempt) + 1);
|
||||
await service.sweepPending();
|
||||
}
|
||||
|
||||
expect(invoked).toHaveLength(8);
|
||||
expect(rows.get(row.subId)?.suspendedAt).toBeNull();
|
||||
await expect(redis.get(`ev:qf:{${row.subId}}`)).resolves.toBeNull();
|
||||
expect(metered).toEqual([]);
|
||||
expect(delivered).toEqual([]);
|
||||
await expect(pending.depth(row.subId)).resolves.toBe(1);
|
||||
|
||||
// Back up: one bill for the one event.
|
||||
workerOutcome = 'settled';
|
||||
jump(deliveryBackoffMs(8) + 1);
|
||||
await service.sweepPending();
|
||||
expect(metered).toHaveLength(1);
|
||||
await expect(pending.depth(row.subId)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('leaves a run of handler failures where it was across a run Puter could not complete', async () => {
|
||||
socketConnected = false;
|
||||
workerOutcome = 'retriable';
|
||||
const row = await register();
|
||||
await dispatch();
|
||||
await expect(redis.get(`ev:qf:{${row.subId}}`)).resolves.toBe('1');
|
||||
|
||||
workerOutcome = 'unavailable';
|
||||
jump(deliveryBackoffMs(1) + 1);
|
||||
await service.sweepPending();
|
||||
|
||||
expect(invoked).toHaveLength(2);
|
||||
await expect(redis.get(`ev:qf:{${row.subId}}`)).resolves.toBe('1');
|
||||
});
|
||||
|
||||
it('waits as long as the answer asked, up to the backoff ceiling', async () => {
|
||||
socketConnected = false;
|
||||
workerOutcome = 'unavailable';
|
||||
workerRetryAfterMs = 60_000;
|
||||
await register();
|
||||
|
||||
await dispatch();
|
||||
expect(invoked).toHaveLength(1);
|
||||
|
||||
// Past the plain backoff, inside the wait the answer named.
|
||||
jump(deliveryBackoffMs(1) + 1);
|
||||
await service.sweepPending();
|
||||
expect(invoked).toHaveLength(1);
|
||||
|
||||
jump(60_000);
|
||||
await service.sweepPending();
|
||||
expect(invoked).toHaveLength(2);
|
||||
|
||||
// A wait past the ceiling is held to it.
|
||||
workerRetryAfterMs = 60 * 60 * 1000;
|
||||
jump(EVENTS_RETRY_MAX_MS + 1);
|
||||
await service.sweepPending();
|
||||
expect(invoked).toHaveLength(3);
|
||||
jump(EVENTS_RETRY_MAX_MS + 1);
|
||||
await service.sweepPending();
|
||||
expect(invoked).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('resumes a socket-only account row once its client reconnects, rather than wedging forever', async () => {
|
||||
const row = await register({ appUid: null });
|
||||
await dispatch();
|
||||
@@ -694,6 +785,42 @@ describe('a delivery owed to exactly one consumer', () => {
|
||||
await expect(keysOf(row.subId)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('queues every row`s event without waiting on another row`s handler run', async () => {
|
||||
socketConnected = false;
|
||||
const first = await register({ subId: 'first' });
|
||||
const second = await register({ subId: 'second' });
|
||||
let finish!: () => void;
|
||||
const running = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
service.worker = {
|
||||
invoke: async (invocation: WorkerInvocation) => {
|
||||
invoked.push(invocation);
|
||||
await running;
|
||||
return { outcome: 'settled' };
|
||||
},
|
||||
};
|
||||
|
||||
await service.dispatchFs('fs.write.file', entry(), {
|
||||
actingUserId: userId,
|
||||
ancestors: async () => ancestors(),
|
||||
});
|
||||
|
||||
await expect(pending.depth(first.subId)).resolves.toBe(1);
|
||||
await expect(pending.depth(second.subId)).resolves.toBe(1);
|
||||
// Both runs start rather than the second waiting on the first.
|
||||
await handedOver();
|
||||
expect(invoked.map((call) => call.subId).sort()).toEqual(
|
||||
[first.subId, second.subId].sort(),
|
||||
);
|
||||
|
||||
finish();
|
||||
await vi.waitFor(async () => {
|
||||
await expect(pending.depth(first.subId)).resolves.toBe(0);
|
||||
await expect(pending.depth(second.subId)).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('is never delivered as a broadcast copy as well', async () => {
|
||||
await register();
|
||||
|
||||
@@ -811,7 +938,7 @@ describe('a delivery everyone connected gets', () => {
|
||||
service.worker = {
|
||||
invoke: async (invocation: WorkerInvocation) => {
|
||||
invoked.push(invocation);
|
||||
return workerOutcome;
|
||||
return { outcome: workerOutcome };
|
||||
},
|
||||
available: () => false,
|
||||
};
|
||||
@@ -1009,6 +1136,62 @@ describe('the sweep order a busy subscription holds', () => {
|
||||
const afterSweep = await redis.zscore('ev:qx', row.subId);
|
||||
expect(Number(afterSweep)).toBeGreaterThan(Number(afterPublish));
|
||||
});
|
||||
|
||||
it('moves a leased subscription back without reading its row', async () => {
|
||||
socketConnected = false;
|
||||
const row = await register();
|
||||
await dispatch();
|
||||
expect(invoked).toHaveLength(1);
|
||||
const heldAt = await redis.zscore('ev:qx', row.subId);
|
||||
|
||||
jump(1_000);
|
||||
rowReads = [];
|
||||
await service.sweepPending();
|
||||
expect(rowReads).toEqual([]);
|
||||
expect(invoked).toHaveLength(1);
|
||||
expect(Number(await redis.zscore('ev:qx', row.subId))).toBeGreaterThan(
|
||||
Number(heldAt),
|
||||
);
|
||||
|
||||
// Once the lease lapses, the row is read and the delivery retried.
|
||||
jump(61_000);
|
||||
await service.sweepPending();
|
||||
expect(rowReads).toEqual([row.subId]);
|
||||
expect(invoked).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not hold the rest of a pass behind one slow handler run', async () => {
|
||||
socketConnected = false;
|
||||
const first = await register({ subId: 'first' });
|
||||
const second = await register({ subId: 'second' });
|
||||
await dispatch();
|
||||
expect(invoked).toHaveLength(2);
|
||||
|
||||
let finish!: () => void;
|
||||
const running = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const retried: string[] = [];
|
||||
service.worker = {
|
||||
invoke: async (invocation: WorkerInvocation) => {
|
||||
retried.push(invocation.subId);
|
||||
// Whichever runs first never answers until the end.
|
||||
if (retried.length === 1) await running;
|
||||
return { outcome: 'settled' };
|
||||
},
|
||||
};
|
||||
|
||||
jump(61_000);
|
||||
const sweeping = service.sweepPending();
|
||||
await vi.waitFor(() =>
|
||||
expect([...retried].sort()).toEqual(
|
||||
[first.subId, second.subId].sort(),
|
||||
),
|
||||
);
|
||||
|
||||
finish();
|
||||
await expect(sweeping).resolves.toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a delivery costs its holder', () => {
|
||||
|
||||
@@ -21,7 +21,9 @@ import type { KvOp } from '../../clients/event/types.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { isTildePath } from '../fs/resolveNode.js';
|
||||
import type { NotificationAudience } from '../notification/notificationTypes.js';
|
||||
import { PERMISSION_MAX_LEN } from '../permission/consts.js';
|
||||
import { PermissionUtil } from '../permission/permissionUtil.js';
|
||||
import { MATCH_PATTERN_MAX_LENGTH } from './matcher.js';
|
||||
|
||||
/**
|
||||
* Subscription subjects, `:`-delimited with the same component escaping as
|
||||
@@ -131,6 +133,14 @@ export const KV_KEY_SEPARATOR = ':';
|
||||
*/
|
||||
export const KV_MATCH_SEPARATOR: string | null = null;
|
||||
|
||||
/**
|
||||
* Longest KV filter compiled. A handle's pattern is held to
|
||||
* {@link MATCH_PATTERN_MAX_LENGTH} as its holder wrote it, then stored composed
|
||||
* onto the shared prefix, which always fits inside its grant string.
|
||||
*/
|
||||
export const KV_MATCH_MAX_LENGTH =
|
||||
MATCH_PATTERN_MAX_LENGTH + PERMISSION_MAX_LEN;
|
||||
|
||||
const FS_TOKEN_PREFIX = 'f#';
|
||||
const KV_TOKEN_PREFIX = 'k#';
|
||||
const NOTIF_TOKEN_PREFIX = 'n#';
|
||||
@@ -142,6 +152,8 @@ const NOTIF_TOKEN_PREFIX = 'n#';
|
||||
*/
|
||||
export const KV_HANDLE_PREFIX = 'kvh-';
|
||||
|
||||
const KV_HANDLE_SUBJECT_PREFIX = `kv:${KV_HANDLE_PREFIX}`;
|
||||
|
||||
export const isKvHandleId = (value: string): boolean =>
|
||||
value.startsWith(KV_HANDLE_PREFIX) &&
|
||||
value.length > KV_HANDLE_PREFIX.length;
|
||||
@@ -215,11 +227,27 @@ export const isNotifToken = (token: string): boolean =>
|
||||
* can sit in its app slot.
|
||||
*/
|
||||
export const kvHandleFromSubject = (subject: string): string | null => {
|
||||
// Asked of every KV delivery. A stored handle subject is rebuilt from the
|
||||
// parsed handle, so anything else is turned away before the split.
|
||||
if (!subject.startsWith(KV_HANDLE_SUBJECT_PREFIX)) return null;
|
||||
const parts = PermissionUtil.split(subject);
|
||||
if (parts[0] !== 'kv' || parts.length < 3) return null;
|
||||
return isKvHandleId(parts[1]) ? parts[1] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The filter a handle row is described with to its holder: what the pattern
|
||||
* they wrote would filter by if the handle were the namespace root. The stored
|
||||
* filter is composed onto the shared prefix, so whether it has one at all
|
||||
* depends on how long the prefix is.
|
||||
*/
|
||||
export const kvHandleMatchFromSubject = (subject: string): string | null => {
|
||||
const parts = PermissionUtil.split(subject);
|
||||
if (parts[0] !== 'kv' || parts.length < 3 || !isKvHandleId(parts[1]))
|
||||
return null;
|
||||
return kvAnchorFor(parts.slice(2).join(KV_KEY_SEPARATOR)).rawMatch;
|
||||
};
|
||||
|
||||
/**
|
||||
* The delimiter-aligned prefixes a key can be watched under, shallowest first
|
||||
* and capped at {@link KV_TOKEN_SEGMENT_CAP}. The empty prefix is the whole
|
||||
|
||||
@@ -36,7 +36,6 @@ describe('the backlog a suspension holds', () => {
|
||||
const policy = backlogPolicyFor(reason);
|
||||
expect(policy.cap).toBe(EVENTS_SUSPENDED_PENDING_CAP);
|
||||
expect(policy.ttlMs).toBeGreaterThan(0);
|
||||
expect(policy.resumable).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,7 +60,6 @@ describe('the backlog a suspension holds', () => {
|
||||
expect(backlogPolicyFor('permission_revoked')).toMatchObject({
|
||||
cap: 0,
|
||||
ttlMs: 0,
|
||||
resumable: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import type { DurableSubscription } from '../../stores/events/types.js';
|
||||
import {
|
||||
SUSPENDED_REASONS,
|
||||
TERMINAL_SUSPENDED_REASONS,
|
||||
type SuspendedReason,
|
||||
} from '../../stores/events/DurableSubscriptionStore.js';
|
||||
|
||||
@@ -50,14 +51,11 @@ export interface BacklogPolicy {
|
||||
cap: number;
|
||||
/** How long the kept ones survive, or `0` to drop them now. */
|
||||
ttlMs: number;
|
||||
/** Whether the reason can be lifted at all. */
|
||||
resumable: boolean;
|
||||
}
|
||||
|
||||
const HELD: BacklogPolicy = {
|
||||
cap: EVENTS_SUSPENDED_PENDING_CAP,
|
||||
ttlMs: EVENTS_SUSPENDED_BACKLOG_TTL_MS,
|
||||
resumable: true,
|
||||
};
|
||||
|
||||
export const BACKLOG_POLICY: Record<SuspendedReason, BacklogPolicy> = {
|
||||
@@ -67,9 +65,8 @@ export const BACKLOG_POLICY: Record<SuspendedReason, BacklogPolicy> = {
|
||||
no_credit: {
|
||||
cap: EVENTS_SUSPENDED_PENDING_CAP,
|
||||
ttlMs: EVENTS_NO_CREDIT_BACKLOG_TTL_MS,
|
||||
resumable: true,
|
||||
},
|
||||
permission_revoked: { cap: 0, ttlMs: 0, resumable: false },
|
||||
permission_revoked: { cap: 0, ttlMs: 0 },
|
||||
};
|
||||
|
||||
export const backlogPolicyFor = (reason: SuspendedReason): BacklogPolicy =>
|
||||
@@ -77,7 +74,7 @@ export const backlogPolicyFor = (reason: SuspendedReason): BacklogPolicy =>
|
||||
|
||||
/** Whether a reason ever lifts. `permission_revoked` never does. */
|
||||
export const isResumable = (reason: SuspendedReason): boolean =>
|
||||
BACKLOG_POLICY[reason].resumable;
|
||||
!TERMINAL_SUSPENDED_REASONS.includes(reason);
|
||||
|
||||
/**
|
||||
* The reasons that lift, and therefore the ones a revocation has to re-stamp: a
|
||||
|
||||
@@ -52,6 +52,7 @@ import { runWithContext } from '../../core/context.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { EVENTS_BACKGROUND_PERMISSION } from './authorization.js';
|
||||
import { ConsentSettles } from './consentSettles.js';
|
||||
import {
|
||||
EVENTS_HANDLER_TOKEN_TTL_SECONDS,
|
||||
EVENTS_WORKER_SESSION_NAME,
|
||||
@@ -92,11 +93,18 @@ let calls: StubCall[];
|
||||
let answer: number | 'hang' = 200;
|
||||
/** Whether the stub's answer carries the handled header, as a real one would. */
|
||||
let answerHandled = true;
|
||||
/** Further headers on the stub's answer, such as the runtime's error code. */
|
||||
let answerHeaders: Record<string, string> = {};
|
||||
/**
|
||||
* Whether the stub reports the script missing until it sees the deployed
|
||||
* header.
|
||||
*/
|
||||
let dispatchMissing = false;
|
||||
/**
|
||||
* Whether the stub answers as a script still bound to a rotated key until it
|
||||
* sees the deployed header.
|
||||
*/
|
||||
let staleKey = false;
|
||||
|
||||
const events = () => env.server.services.events;
|
||||
const pending = () => env.server.stores.pendingDelivery;
|
||||
@@ -134,13 +142,20 @@ const startStub = async (): Promise<string> => {
|
||||
}).end();
|
||||
return;
|
||||
}
|
||||
if (staleKey && deployed !== '1') {
|
||||
res.writeHead(500, {
|
||||
'x-puter-events-handled': '1',
|
||||
'x-puter-events-error': 'bad-key',
|
||||
}).end();
|
||||
return;
|
||||
}
|
||||
// Stands in for the dispatcher forwarding a genuine answer from
|
||||
// the script, so it carries the marker a real one would — unless
|
||||
// a test is specifically simulating something that never reached one.
|
||||
res.writeHead(
|
||||
answer,
|
||||
answerHandled ? { 'x-puter-events-handled': '1' } : {},
|
||||
).end();
|
||||
res.writeHead(answer, {
|
||||
...(answerHandled ? { 'x-puter-events-handled': '1' } : {}),
|
||||
...answerHeaders,
|
||||
}).end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
@@ -313,6 +328,15 @@ const heldForMs = async (subId: string): Promise<number> => {
|
||||
return Number(score) - Date.now();
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait until the delivery is held past its claim's own lease, which only a
|
||||
* failed attempt's backoff does. Needs the clock frozen with `jump`.
|
||||
*/
|
||||
const heldForBackoff = (subId: string): Promise<void> =>
|
||||
waitUntil(async () =>
|
||||
expect(await heldForMs(subId)).toBeGreaterThan(2 * INVOKE_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* Wait until this subscription has `count` failed attempts on record. The
|
||||
* counter moves only after the attempt's hold or gap marker is written, so it
|
||||
@@ -395,7 +419,9 @@ beforeEach(async () => {
|
||||
calls = [];
|
||||
answer = 200;
|
||||
answerHandled = true;
|
||||
answerHeaders = {};
|
||||
dispatchMissing = false;
|
||||
staleKey = false;
|
||||
await env.server.clients.db.write('DELETE FROM `event_subscriptions`', []);
|
||||
events().invalidateUser(userId);
|
||||
await env.server.stores.eventSubscription.markRegionCold(userId);
|
||||
@@ -527,7 +553,7 @@ describe('the call an owed delivery makes', () => {
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('mints nothing once the background consent is gone', async () => {
|
||||
it('mints nothing once the background consent is gone, and finishes the settle', async () => {
|
||||
const subId = await subscribe();
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
env.users.user.token,
|
||||
@@ -557,15 +583,18 @@ describe('the call an owed delivery makes', () => {
|
||||
await durable().warmRegion(userId);
|
||||
|
||||
await touch('revoked.txt');
|
||||
await waitUntil(async () =>
|
||||
expect(await pending().depth(subId)).toBe(1),
|
||||
);
|
||||
await events().sweepPending();
|
||||
|
||||
// Deferred rather than run: nothing was invoked, and the delivery
|
||||
// is still owed rather than dropped.
|
||||
// Nothing was invoked, and the delivery ended the row the way the
|
||||
// revocation settle would have, backlog included.
|
||||
await waitUntil(async () =>
|
||||
expect(
|
||||
(await durable().getBySubId(subId))?.suspendedReason,
|
||||
).toBe('permission_revoked'),
|
||||
);
|
||||
await waitUntil(async () =>
|
||||
expect(await pending().depth(subId)).toBe(0),
|
||||
);
|
||||
expect(calls).toEqual([]);
|
||||
expect(await pending().depth(subId)).toBe(1);
|
||||
} finally {
|
||||
await env.server.services.permission.grantUserAppPermission(
|
||||
actor!,
|
||||
@@ -575,6 +604,157 @@ describe('the call an owed delivery makes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the row when the consent check fails rather than answers', async () => {
|
||||
const subId = await subscribe();
|
||||
const permission = env.server.services.permission;
|
||||
const check = permission.check.bind(permission);
|
||||
const failing = vi
|
||||
.spyOn(permission, 'check')
|
||||
.mockImplementation(async (actor, asked) => {
|
||||
if (asked === EVENTS_BACKGROUND_PERMISSION)
|
||||
throw new Error('permission lookup unavailable');
|
||||
return check(actor, asked);
|
||||
});
|
||||
try {
|
||||
await touch('unchecked.txt');
|
||||
// The delivery reached the check that failed.
|
||||
await waitUntil(() =>
|
||||
expect(
|
||||
failing.mock.calls.some(
|
||||
([, asked]) => asked === EVENTS_BACKGROUND_PERMISSION,
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
// Long enough for a settle to have run, had one started.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
} finally {
|
||||
failing.mockRestore();
|
||||
}
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect((await durable().getBySubId(subId))?.suspendedAt).toBeNull();
|
||||
expect(await pending().depth(subId)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the row when only a stale cached answer says the consent is gone', async () => {
|
||||
const subId = await subscribe();
|
||||
const redis = env.server.clients.redis;
|
||||
const app = await env.server.stores.app.getByUid(appUid);
|
||||
// What a node that cached this app's grants before the consent was
|
||||
// given still holds: every grant but that one.
|
||||
const grants = `perms:u2a:${userId}:${app!.id}`;
|
||||
const beforeConsent = (
|
||||
await env.server.stores.permission.listUserAppPermsFromPrimary(
|
||||
userId,
|
||||
app!.id,
|
||||
)
|
||||
).filter((row) => row.permission !== EVENTS_BACKGROUND_PERMISSION);
|
||||
const forgetScans = async () => {
|
||||
for (const key of await redis.keys('permission-scan:*'))
|
||||
await redis.del(key);
|
||||
};
|
||||
const permission = env.server.services.permission;
|
||||
const check = vi.spyOn(permission, 'check');
|
||||
const primaryRead = vi.spyOn(
|
||||
env.server.stores.permission,
|
||||
'listUserAppPermsFromPrimary',
|
||||
);
|
||||
try {
|
||||
await redis.del(`${grants}:deleted`);
|
||||
await redis.set(grants, JSON.stringify(beforeConsent), 'EX', 300);
|
||||
await forgetScans();
|
||||
|
||||
await touch('stale-consent.txt');
|
||||
// The delivery asked for the consent and was told no.
|
||||
await waitUntil(async () => {
|
||||
const asked = check.mock.calls.findIndex(
|
||||
([, perm]) => perm === EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
expect(asked).toBeGreaterThanOrEqual(0);
|
||||
expect(await check.mock.results[asked].value).toBe(false);
|
||||
});
|
||||
// Long enough for a settle to have run, had one started.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect((await durable().getBySubId(subId))?.suspendedAt).toBeNull();
|
||||
expect(await pending().depth(subId)).toBe(1);
|
||||
// Kept because the no was asked again past the caches.
|
||||
expect(primaryRead).toHaveBeenCalled();
|
||||
} finally {
|
||||
check.mockRestore();
|
||||
primaryRead.mockRestore();
|
||||
await redis.del(grants);
|
||||
await forgetScans();
|
||||
}
|
||||
});
|
||||
|
||||
it('defers instead of ending the row once the settle budget is spent', async () => {
|
||||
const subId = await subscribe();
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
env.users.user.token,
|
||||
);
|
||||
const spent = vi
|
||||
.spyOn(ConsentSettles.prototype, 'begin')
|
||||
.mockReturnValue('spent');
|
||||
const alarm = vi.spyOn(env.server.clients.alarm, 'create');
|
||||
const primaryRead = vi.spyOn(
|
||||
env.server.stores.permission,
|
||||
'listUserAppPermsFromPrimary',
|
||||
);
|
||||
|
||||
try {
|
||||
await env.server.services.permission.revokeUserAppPermission(
|
||||
actor!,
|
||||
appUid,
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
// As in the test above: undo the listener's settle so the
|
||||
// delivery path is what meets the withdrawn consent.
|
||||
await waitUntil(async () =>
|
||||
expect(
|
||||
(await durable().getBySubId(subId))?.suspendedAt,
|
||||
).not.toBeNull(),
|
||||
);
|
||||
await env.server.clients.db.write(
|
||||
'UPDATE `event_subscriptions` SET `suspended_at` = NULL, ' +
|
||||
'`suspended_reason` = NULL WHERE `sub_id` = ?',
|
||||
[subId],
|
||||
);
|
||||
events().invalidateUser(userId);
|
||||
await env.server.stores.eventSubscription.markRegionCold(userId);
|
||||
await durable().warmRegion(userId);
|
||||
primaryRead.mockClear();
|
||||
|
||||
await touch('over-budget.txt');
|
||||
|
||||
await waitUntil(() =>
|
||||
expect(
|
||||
alarm.mock.calls.some(
|
||||
([id]) => id === 'events_consent_settle_budget',
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
// Long enough for a settle to have run, had one started.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect((await durable().getBySubId(subId))?.suspendedAt).toBeNull();
|
||||
expect(await pending().depth(subId)).toBe(1);
|
||||
// Spent: not even the fresh read.
|
||||
expect(primaryRead).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
spent.mockRestore();
|
||||
alarm.mockRestore();
|
||||
primaryRead.mockRestore();
|
||||
await env.server.services.permission.grantUserAppPermission(
|
||||
actor!,
|
||||
appUid,
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('settles the delivery when the handler takes it', async () => {
|
||||
const subId = await subscribe();
|
||||
|
||||
@@ -611,19 +791,67 @@ describe('what each answer does to the delivery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a 4xx with no handled marker for retry rather than dropping it', async () => {
|
||||
it('holds a 4xx with no handled marker for retry, without counting it', async () => {
|
||||
// Nothing said this reached the script — an edge 404, a WAF — so it
|
||||
// must not read as the handler's own refusal.
|
||||
// is neither the handler's refusal nor its failure.
|
||||
answer = 404;
|
||||
answerHandled = false;
|
||||
const subId = await subscribe();
|
||||
jump(0);
|
||||
|
||||
await touch('unmarked.txt');
|
||||
await invoked(1);
|
||||
|
||||
await failuresRecorded(subId, 1);
|
||||
expect(await heldForMs(subId)).toBeGreaterThan(0);
|
||||
await heldForBackoff(subId);
|
||||
expect(await pending().depth(subId)).toBe(1);
|
||||
expect(
|
||||
await env.server.clients.redis.get(`ev:qf:{${subId}}`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not count a run a Puter API failure ended, and waits as long as it asked', async () => {
|
||||
answer = 500;
|
||||
answerHeaders = {
|
||||
'x-puter-events-error': 'platform-error',
|
||||
'retry-after': '30',
|
||||
};
|
||||
const subId = await subscribe();
|
||||
jump(0);
|
||||
|
||||
await touch('platform.txt');
|
||||
await invoked(1);
|
||||
|
||||
await waitUntil(async () =>
|
||||
expect(await heldForMs(subId)).toBe(30_000),
|
||||
);
|
||||
expect(
|
||||
await env.server.clients.redis.get(`ev:qf:{${subId}}`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('never suspends a subscription for Puter`s own failures', async () => {
|
||||
answer = 502;
|
||||
answerHandled = false;
|
||||
const subId = await subscribe();
|
||||
jump(0);
|
||||
|
||||
await touch('outage.txt');
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= EVENTS_CONSECUTIVE_FAILURES + 1;
|
||||
attempt++
|
||||
) {
|
||||
await invoked(attempt);
|
||||
await heldForBackoff(subId);
|
||||
jump(deliveryBackoffMs(attempt) + 50);
|
||||
await events().sweepPending();
|
||||
}
|
||||
|
||||
await invoked(EVENTS_CONSECUTIVE_FAILURES + 2);
|
||||
expect((await durable().getBySubId(subId))?.suspendedAt).toBeNull();
|
||||
expect(
|
||||
await env.server.clients.redis.get(`ev:qf:{${subId}}`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('holds one it could not answer, for longer each time', async () => {
|
||||
@@ -987,6 +1215,32 @@ describe('deploying on a dispatcher miss', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('redeploying a script whose key rotated', () => {
|
||||
it('redeploys it, settles on the retry, and counts nothing against the handler', async () => {
|
||||
staleKey = true;
|
||||
const createSpy = vi
|
||||
.spyOn(env.server.drivers.workers, 'create')
|
||||
.mockResolvedValue({ success: true, errors: [] });
|
||||
|
||||
try {
|
||||
const subId = await subscribe();
|
||||
await touch('rotated-key.txt');
|
||||
await invoked(2);
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
expect(calls[1].headers.deployed).toBe('1');
|
||||
await waitUntil(async () =>
|
||||
expect(await pending().depth(subId)).toBe(0),
|
||||
);
|
||||
expect(
|
||||
await env.server.clients.redis.get(`ev:qf:{${subId}}`),
|
||||
).toBeNull();
|
||||
} finally {
|
||||
createSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlers whose writes run handlers', () => {
|
||||
const callsFor = (forAppUid: string): StubCall[] =>
|
||||
calls.filter((call) => call.headers.app === forAppUid);
|
||||
@@ -1139,8 +1393,7 @@ describe('handlers whose writes run handlers', () => {
|
||||
);
|
||||
const maxDepth = limitFor(EVENTS_HANDLER_DEPTH, plan.id);
|
||||
const entry = (await env.server.stores.fsEntry.getEntryByPath(path))!;
|
||||
// Awaited end to end, queue and drain included, unlike a write's own
|
||||
// fire-and-forget dispatch.
|
||||
// Awaited until the event is queued; the drain runs on its own.
|
||||
const dispatchAt = (handlerDepth: number) =>
|
||||
events().dispatchFs('fs.write.file', entry, {
|
||||
handlerDepth,
|
||||
@@ -1154,8 +1407,10 @@ describe('handlers whose writes run handlers', () => {
|
||||
await dispatchAt(maxDepth);
|
||||
// Dropped, not held for a retry that could never run, and not counted
|
||||
// as the handler failing.
|
||||
await waitUntil(async () =>
|
||||
expect(await pending().depth(subId)).toBe(0),
|
||||
);
|
||||
expect(callsFor(worker.appUid)).toHaveLength(1);
|
||||
expect(await pending().depth(subId)).toBe(0);
|
||||
await expect(
|
||||
env.server.clients.redis.get(`ev:qf:{${subId}}`),
|
||||
).resolves.toBeNull();
|
||||
|
||||
@@ -133,11 +133,13 @@ const INGEST_SOURCE = `async ({ event, ctx, user, fetch, ack }) => {
|
||||
kv,
|
||||
outsideStat,
|
||||
token: user.authToken,
|
||||
// Read off globalThis, where the router runtime and the deploy's
|
||||
// bindings put them: a bare name is refused at publish.
|
||||
ambient: {
|
||||
me: typeof me,
|
||||
router: typeof router,
|
||||
puterAuth: typeof puter_auth,
|
||||
invokeKey: typeof events_invoke_key,
|
||||
me: typeof globalThis.me,
|
||||
router: typeof globalThis.router,
|
||||
puterAuth: typeof globalThis.puter_auth,
|
||||
invokeKey: typeof globalThis.events_invoke_key,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -45,14 +45,21 @@ export interface WorkerInvocation {
|
||||
|
||||
/**
|
||||
* `settled`: delivered. `terminal`: handler refused, not retried. `retriable`:
|
||||
* nobody answered, comes back. `deferred`: nothing attempted. Both failures
|
||||
* count toward the run that suspends a subscription.
|
||||
* the handler failed, comes back. `unavailable`: Puter could not run it, comes
|
||||
* back. `deferred`: nothing attempted. Only `terminal` and `retriable` count
|
||||
* toward the run that suspends a subscription.
|
||||
*/
|
||||
export type WorkerInvocationOutcome =
|
||||
'settled' | 'terminal' | 'retriable' | 'deferred';
|
||||
'settled' | 'terminal' | 'retriable' | 'unavailable' | 'deferred';
|
||||
|
||||
export interface WorkerInvocationResult {
|
||||
outcome: WorkerInvocationOutcome;
|
||||
/** Least wait before the next attempt, when the answer named one. */
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
export interface WorkerInvokerSeam {
|
||||
invoke(invocation: WorkerInvocation): Promise<WorkerInvocationOutcome>;
|
||||
invoke(invocation: WorkerInvocation): Promise<WorkerInvocationResult>;
|
||||
/** Whether this seam can take an invocation at all. Absent means yes. */
|
||||
available?(): boolean;
|
||||
}
|
||||
@@ -114,18 +121,19 @@ export class EventsWorkerInvoker implements WorkerInvokerSeam {
|
||||
|
||||
async invoke(
|
||||
invocation: WorkerInvocation,
|
||||
): Promise<WorkerInvocationOutcome> {
|
||||
): Promise<WorkerInvocationResult> {
|
||||
const { appUid, handlerName } = invocation;
|
||||
if (!appUid || !handlerName) return 'deferred';
|
||||
if (!appUid || !handlerName) return { outcome: 'deferred' };
|
||||
if (!this.#available()) return { outcome: 'unavailable' };
|
||||
|
||||
const token = await this.#mintToken(invocation);
|
||||
if (token === null) return 'deferred';
|
||||
if (token === null) return { outcome: 'deferred' };
|
||||
|
||||
// Nowhere to send it yet. Retriable rather than terminal: the address
|
||||
// is the platform's to provide, and the app has done nothing wrong —
|
||||
// the consecutive-failure rule is what stops this retrying forever.
|
||||
// Nowhere to send it: the app has no handlers or no owner to run them
|
||||
// as. Retrying cannot fix that, so the consecutive-failure rule is what
|
||||
// stops it.
|
||||
const address = await this.#address(appUid);
|
||||
if (address === null) return 'retriable';
|
||||
if (address === null) return { outcome: 'retriable' };
|
||||
|
||||
const result = await this.#client.invoke({
|
||||
script: address.script,
|
||||
@@ -136,9 +144,18 @@ export class EventsWorkerInvoker implements WorkerInvokerSeam {
|
||||
event: invocation.event,
|
||||
ctx: parseContext(invocation.context),
|
||||
});
|
||||
if (result.outcome === 'retriable' && result.error)
|
||||
if (
|
||||
(result.outcome === 'retriable' ||
|
||||
result.outcome === 'unavailable') &&
|
||||
result.error
|
||||
)
|
||||
this.#warnRetriable(address.script, appUid, result.error);
|
||||
return result.outcome;
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
...(result.retryAfterMs
|
||||
? { retryAfterMs: result.retryAfterMs }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
#warnRetriable(script: string, appUid: string, error: string): void {
|
||||
@@ -178,9 +195,9 @@ const RECORDED_INVOCATIONS = 100;
|
||||
export class RecordingWorkerInvoker implements WorkerInvokerSeam {
|
||||
readonly recorded: WorkerInvocation[] = [];
|
||||
|
||||
invoke(invocation: WorkerInvocation): Promise<WorkerInvocationOutcome> {
|
||||
invoke(invocation: WorkerInvocation): Promise<WorkerInvocationResult> {
|
||||
this.recorded.push(invocation);
|
||||
if (this.recorded.length > RECORDED_INVOCATIONS) this.recorded.shift();
|
||||
return Promise.resolve('deferred');
|
||||
return Promise.resolve({ outcome: 'deferred' });
|
||||
}
|
||||
}
|
||||
@@ -83,29 +83,60 @@ interface LoadedWorker {
|
||||
sandbox: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const API_ORIGIN = 'https://api.example';
|
||||
|
||||
/**
|
||||
* A puter.js stand-in: `call(url)` sends through the fetch the runtime handed
|
||||
* the client and throws a body-shaped error on a non-2xx, the way puter.js
|
||||
* does, with no status on it.
|
||||
*/
|
||||
interface StubUser {
|
||||
authToken: string;
|
||||
call(url: string): Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy a handler set the way the driver does — runtime first, generated
|
||||
* source after — into a context that stands in for the isolate.
|
||||
* source after — into a context that stands in for the isolate. `network`
|
||||
* answers every fetch made inside it.
|
||||
*/
|
||||
const loadWorker = (
|
||||
handlers: EventHandler[],
|
||||
options: { invokeKey?: string } = {},
|
||||
options: {
|
||||
invokeKey?: string;
|
||||
network?: (url: string) => Promise<Response>;
|
||||
} = {},
|
||||
): LoadedWorker => {
|
||||
const generated = generateEventsWorkerSource(handlers);
|
||||
const userTokens: string[] = [];
|
||||
let dispatch: FetchHandler | null = null;
|
||||
const network =
|
||||
options.network ?? (() => Promise.resolve(new Response('')));
|
||||
|
||||
const sandbox: Record<string, unknown> = {
|
||||
Response,
|
||||
Request,
|
||||
URL,
|
||||
fetch: () => Promise.resolve(new Response('')),
|
||||
fetch: (input: string) => network(String(input)),
|
||||
console,
|
||||
events_invoke_key: options.invokeKey ?? INVOKE_KEY,
|
||||
puter_endpoint: 'https://api.example',
|
||||
init_puter_portable: (token: string) => {
|
||||
puter_endpoint: API_ORIGIN,
|
||||
init_puter_portable: (
|
||||
token: string,
|
||||
_apiOrigin: string,
|
||||
_type: string,
|
||||
clientOptions: { fetch: (url: string) => Promise<Response> },
|
||||
): StubUser => {
|
||||
userTokens.push(token);
|
||||
return { authToken: token };
|
||||
return {
|
||||
authToken: token,
|
||||
call: async (url) => {
|
||||
const response = await clientOptions.fetch(url);
|
||||
if (!response.ok)
|
||||
throw { message: 'request failed', code: 'internal_error' };
|
||||
return response;
|
||||
},
|
||||
};
|
||||
},
|
||||
self: {
|
||||
addEventListener: (_type: string, listener: unknown) => {
|
||||
@@ -171,6 +202,35 @@ const delivery = (over: Record<string, unknown> = {}) => ({
|
||||
|
||||
const SINK = 'async ({ event, ctx, user, fetch, ack }) => { await ack(); }';
|
||||
|
||||
const HELD_URL = 'https://held.test/wait';
|
||||
|
||||
/**
|
||||
* A network whose `HELD_URL` answers only once released, so one run can be kept
|
||||
* in flight while another runs. `reached` resolves when it is first hit.
|
||||
*/
|
||||
const holdUntilReleased = () => {
|
||||
let release = () => {};
|
||||
let arrive = () => {};
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const reached = new Promise<void>((resolve) => {
|
||||
arrive = resolve;
|
||||
});
|
||||
return {
|
||||
reached,
|
||||
release: () => release(),
|
||||
or:
|
||||
(answer: (url: string) => Response) =>
|
||||
async (url: string): Promise<Response> => {
|
||||
if (url !== HELD_URL) return answer(url);
|
||||
arrive();
|
||||
await released;
|
||||
return new Response('');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('the generated worker inside its runtime', () => {
|
||||
it('answers the invoke route and nothing else', async () => {
|
||||
const worker = loadWorker([handler('a', SINK)]);
|
||||
@@ -291,6 +351,34 @@ describe('the generated worker inside its runtime', () => {
|
||||
expect(Object.isFrozen(seen.mock.calls[0][0])).toBe(true);
|
||||
});
|
||||
|
||||
it('reuses a token`s client, building another only for a run that overlaps one', async () => {
|
||||
const held = holdUntilReleased();
|
||||
const worker = loadWorker(
|
||||
[
|
||||
handler('a', SINK),
|
||||
handler(
|
||||
'waits',
|
||||
`async ({ fetch }) => { await fetch('${HELD_URL}'); }`,
|
||||
),
|
||||
],
|
||||
{ network: held.or(() => new Response('')) },
|
||||
);
|
||||
|
||||
await invoke(worker, { body: delivery() });
|
||||
await invoke(worker, { body: delivery() });
|
||||
expect(worker.userTokens).toEqual(['delivery-token']);
|
||||
|
||||
const waiting = invoke(worker, {
|
||||
body: delivery({ handler: 'waits' }),
|
||||
});
|
||||
await held.reached;
|
||||
await invoke(worker, { body: delivery() });
|
||||
held.release();
|
||||
await waiting;
|
||||
await invoke(worker, { body: delivery() });
|
||||
expect(worker.userTokens).toEqual(['delivery-token', 'delivery-token']);
|
||||
});
|
||||
|
||||
it('takes resolution as the ack, and a plain throw as a refusal', async () => {
|
||||
const worker = loadWorker([
|
||||
handler('ok', 'async () => {}'),
|
||||
@@ -407,6 +495,185 @@ describe('the generated worker inside its runtime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a handler failed by a Puter API call', () => {
|
||||
const answering =
|
||||
(status: number, headers: Record<string, string> = {}) =>
|
||||
(url: string) =>
|
||||
Promise.resolve(
|
||||
new Response('', {
|
||||
status: url.startsWith(API_ORIGIN) ? status : 200,
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
const errorOf = (res: Response) => res.headers.get('x-puter-events-error');
|
||||
|
||||
it('answers platform-error when it throws after a 5xx from Puter', async () => {
|
||||
const worker = loadWorker(
|
||||
[handler('a', `async ({ user }) => { await user.call('${API_ORIGIN}/kv'); }`)],
|
||||
{ network: answering(503) },
|
||||
);
|
||||
const res = await invoke(worker, { body: delivery() });
|
||||
expect(res.status).toBe(500);
|
||||
expect(errorOf(res)).toBe('platform-error');
|
||||
expect(res.headers.get('retry-after')).toBeNull();
|
||||
});
|
||||
|
||||
it('passes on the wait a 429 from Puter named', async () => {
|
||||
const worker = loadWorker(
|
||||
[handler('a', `async ({ user }) => { await user.call('${API_ORIGIN}/kv'); }`)],
|
||||
{ network: answering(429, { 'retry-after': '7' }) },
|
||||
);
|
||||
const res = await invoke(worker, { body: delivery() });
|
||||
expect(errorOf(res)).toBe('platform-error');
|
||||
expect(res.headers.get('retry-after')).toBe('7');
|
||||
});
|
||||
|
||||
it('answers platform-error when Puter could not be reached', async () => {
|
||||
const worker = loadWorker(
|
||||
[handler('a', `async ({ user }) => { await user.call('${API_ORIGIN}/kv'); }`)],
|
||||
{
|
||||
network: () => Promise.reject(new TypeError('fetch failed')),
|
||||
},
|
||||
);
|
||||
const res = await invoke(worker, { body: delivery() });
|
||||
expect(errorOf(res)).toBe('platform-error');
|
||||
});
|
||||
|
||||
it('leaves a failure the handler handled, or marked terminal, to the handler', async () => {
|
||||
const worker = loadWorker(
|
||||
[
|
||||
handler(
|
||||
'caught',
|
||||
`async ({ user }) => { try { await user.call('${API_ORIGIN}/kv'); } catch {} }`,
|
||||
),
|
||||
handler(
|
||||
'terminal',
|
||||
`async ({ user }) => {
|
||||
try { await user.call('${API_ORIGIN}/kv'); }
|
||||
catch (err) { throw Object.assign(new Error('drop'), { terminal: true }); }
|
||||
}`,
|
||||
),
|
||||
],
|
||||
{ network: answering(500) },
|
||||
);
|
||||
expect(
|
||||
(await invoke(worker, { body: delivery({ handler: 'caught' }) }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const terminal = await invoke(worker, {
|
||||
body: delivery({ handler: 'terminal' }),
|
||||
});
|
||||
expect(terminal.status).toBe(400);
|
||||
expect(errorOf(terminal)).toBe('handler-terminal');
|
||||
});
|
||||
|
||||
it('counts only Puter`s own API, and only failures during this run', async () => {
|
||||
const worker = loadWorker(
|
||||
[
|
||||
handler(
|
||||
'elsewhere',
|
||||
`async ({ user }) => { await user.call('https://third.party/x'); throw new Error('own'); }`,
|
||||
),
|
||||
handler(
|
||||
'swallow',
|
||||
`async ({ user }) => { try { await user.call('${API_ORIGIN}/kv'); } catch {} }`,
|
||||
),
|
||||
handler('own', `async () => { throw new Error('own'); }`),
|
||||
],
|
||||
{
|
||||
network: (url) =>
|
||||
Promise.resolve(
|
||||
new Response('', {
|
||||
status: url.startsWith(API_ORIGIN) ? 503 : 502,
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
const elsewhere = await invoke(worker, {
|
||||
body: delivery({ handler: 'elsewhere' }),
|
||||
});
|
||||
expect(errorOf(elsewhere)).toBe('handler-terminal');
|
||||
|
||||
// Same token, so the same client: the earlier run's failure is not
|
||||
// this one's.
|
||||
await invoke(worker, { body: delivery({ handler: 'swallow' }) });
|
||||
const own = await invoke(worker, { body: delivery({ handler: 'own' }) });
|
||||
expect(own.status).toBe(400);
|
||||
expect(errorOf(own)).toBe('handler-terminal');
|
||||
});
|
||||
|
||||
it('keeps a concurrent run`s failure out of another run on the same token', async () => {
|
||||
const held = holdUntilReleased();
|
||||
const worker = loadWorker(
|
||||
[
|
||||
handler(
|
||||
'buggy',
|
||||
`async ({ fetch }) => { await fetch('${HELD_URL}'); throw new Error('handler bug'); }`,
|
||||
),
|
||||
handler(
|
||||
'api',
|
||||
`async ({ user }) => { await user.call('${API_ORIGIN}/kv'); }`,
|
||||
),
|
||||
],
|
||||
{ network: held.or(() => new Response('', { status: 503 })) },
|
||||
);
|
||||
|
||||
const buggy = invoke(worker, { body: delivery({ handler: 'buggy' }) });
|
||||
await held.reached;
|
||||
const api = await invoke(worker, {
|
||||
body: delivery({ handler: 'api' }),
|
||||
});
|
||||
expect(errorOf(api)).toBe('platform-error');
|
||||
|
||||
held.release();
|
||||
const own = await buggy;
|
||||
expect(own.status).toBe(400);
|
||||
expect(errorOf(own)).toBe('handler-terminal');
|
||||
});
|
||||
|
||||
it('answers each concurrent run with the wait its own call was given', async () => {
|
||||
const held = holdUntilReleased();
|
||||
const worker = loadWorker(
|
||||
[
|
||||
handler(
|
||||
'slow',
|
||||
`async ({ user, fetch }) => {
|
||||
try { await user.call('${API_ORIGIN}/slow'); }
|
||||
catch (err) { await fetch('${HELD_URL}'); throw err; }
|
||||
}`,
|
||||
),
|
||||
handler(
|
||||
'fast',
|
||||
`async ({ user }) => { await user.call('${API_ORIGIN}/fast'); }`,
|
||||
),
|
||||
],
|
||||
{
|
||||
network: held.or(
|
||||
(url) =>
|
||||
new Response('', {
|
||||
status: 429,
|
||||
headers: {
|
||||
'retry-after': url.endsWith('/slow')
|
||||
? '7'
|
||||
: '2',
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const slow = invoke(worker, { body: delivery({ handler: 'slow' }) });
|
||||
await held.reached;
|
||||
const fast = await invoke(worker, {
|
||||
body: delivery({ handler: 'fast' }),
|
||||
});
|
||||
expect(fast.headers.get('retry-after')).toBe('2');
|
||||
|
||||
held.release();
|
||||
expect((await slow).headers.get('retry-after')).toBe('7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the handled marker', () => {
|
||||
it('carries the handled header on every answer, success or failure', async () => {
|
||||
const worker = loadWorker([
|
||||
|
||||
@@ -4900,7 +4900,11 @@ export class FSService extends PuterService {
|
||||
options.ancestors ??
|
||||
((entry: FSEntry) => fsEntry.getAncestorChain(entry.path));
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
const actingUserId = actor?.user?.id;
|
||||
// Puter filing into the account is nobody's own write, but still
|
||||
// carries the chain depth of whatever caused it.
|
||||
const actingUserId = Context.get('platformWrite')
|
||||
? undefined
|
||||
: actor?.user?.id;
|
||||
const handlerDepth = handlerDepthOf(actor);
|
||||
const viaWorker = viaWorkerOf(actor);
|
||||
const movedFromChain = movedFrom && {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { MeteringService } from './metering/MeteringService';
|
||||
import { NotificationService } from './notification/NotificationService';
|
||||
import { PermissionService } from './permission/PermissionService';
|
||||
import { ProfileService } from './profile/ProfileService';
|
||||
import { OpenAIRealtimeRelayService } from './realtime/OpenAIRealtimeRelayService';
|
||||
import { DefaultUserService } from './selfhosted/DefaultUserService';
|
||||
import { ShareNotificationService } from './share/ShareNotificationService';
|
||||
import { ShareService } from './share/ShareService';
|
||||
@@ -89,6 +90,7 @@ declare module './types' {
|
||||
health: ServerHealthService;
|
||||
userAccount: UserAccountService;
|
||||
signup: SignupService;
|
||||
openaiRealtime: OpenAIRealtimeRelayService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,4 +162,6 @@ export const puterServices = {
|
||||
// check can reference the peer.
|
||||
health: ServerHealthService,
|
||||
localworkerservice: LocalWorkerService,
|
||||
// Authenticates through `auth` and meters through `metering`.
|
||||
openaiRealtime: OpenAIRealtimeRelayService,
|
||||
} satisfies IPuterServiceRegistry;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -322,7 +322,7 @@ describe.each(ENGINES)('key-value shares on %s', (engine) => {
|
||||
|
||||
describe('revoking a grant the database spells differently', () => {
|
||||
it.skipIf(engine !== 'mysql')(
|
||||
'clears the flat entry under the spelling it removed',
|
||||
'leaves a grant spelled in another case standing',
|
||||
async () => {
|
||||
const tag = uuidv4().slice(0, 8);
|
||||
const stored = kvSharePermission(
|
||||
@@ -352,14 +352,59 @@ describe.each(ENGINES)('key-value shares on %s', (engine) => {
|
||||
[asked],
|
||||
);
|
||||
|
||||
expect(removed).toEqual([stored]);
|
||||
expect(
|
||||
await flat().getFlatUserPerms(guest.id, [stored]),
|
||||
).toEqual([]);
|
||||
expect(removed).toEqual([]);
|
||||
expect(await permissions().check(guest.actor, stored)).toBe(
|
||||
false,
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(engine !== 'mysql')(
|
||||
'deletes with no operator MySQL warns is deprecated, whatever the connection charset',
|
||||
async () => {
|
||||
const prefix = region('warn');
|
||||
await share(prefix, 'read');
|
||||
const write = vi.spyOn(db(), 'write');
|
||||
try {
|
||||
await permissions().revokeUserUserPermissionSubtree(
|
||||
owner.actor,
|
||||
guest.username,
|
||||
kvShareGrantAtEveryLevel(
|
||||
kvSharePermission(owner.uuid, GLOBAL, prefix),
|
||||
),
|
||||
);
|
||||
await permissions().revokeUserUserPermission(
|
||||
owner.actor,
|
||||
guest.username,
|
||||
kvSharePermission(owner.uuid, GLOBAL, region('warn')),
|
||||
);
|
||||
} finally {
|
||||
write.mockRestore();
|
||||
}
|
||||
const deletes = write.mock.calls.filter(([query]) =>
|
||||
query.startsWith('DELETE FROM `user_to_user_permissions`'),
|
||||
);
|
||||
expect(deletes).toHaveLength(2);
|
||||
|
||||
const connection = await mysql.createConnection({
|
||||
...MYSQL,
|
||||
database,
|
||||
charset: 'LATIN1_SWEDISH_CI',
|
||||
});
|
||||
try {
|
||||
for (const [query, params] of deletes) {
|
||||
await connection.query(
|
||||
String(query),
|
||||
params as never[],
|
||||
);
|
||||
const [warnings] =
|
||||
await connection.query('SHOW WARNINGS');
|
||||
expect(warnings).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import {
|
||||
EVENTS_COALESCE_WINDOW_MS,
|
||||
EVENTS_KV_HANDLE_LIMIT,
|
||||
EVENTS_SUBSCRIBE_LIMIT,
|
||||
} from '../../controllers/events/limits.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
@@ -410,6 +412,106 @@ describe('changing a share`s level', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('values through a share handle', () => {
|
||||
beforeEach(async () => {
|
||||
for (const user of [owner, guest])
|
||||
await env.server.clients.redis.del(
|
||||
`rate:${EVENTS_SUBSCRIBE_LIMIT.scope}:${user.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const user of [owner, guest])
|
||||
await events().reapSocket(user.id, SOCKET_ID);
|
||||
});
|
||||
|
||||
/** A session subscription on the handle that asks for values. */
|
||||
const watchValues = async (handle: string, user = guest) =>
|
||||
(
|
||||
await events().subscribe(user.actor, SOCKET_ID, {
|
||||
subject: `kv:${handle}:*`,
|
||||
includeValue: true,
|
||||
})
|
||||
).sub;
|
||||
|
||||
/** The event one write delivers to `subId`. */
|
||||
const deliveredAfter = async (subId: string, write: () => unknown) => {
|
||||
delivered.length = 0;
|
||||
await write();
|
||||
let found: DeliveryEnvelope | undefined;
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
found = delivered.find((one) => one.subId === subId);
|
||||
expect(found).toBeDefined();
|
||||
},
|
||||
{ timeout: EVENTS_COALESCE_WINDOW_MS * 12, interval: 25 },
|
||||
);
|
||||
return found!.event as Record<string, unknown>;
|
||||
};
|
||||
|
||||
it('leaves the value out at watch, even when the subscription asked for it', async () => {
|
||||
const prefix = region('values-watch');
|
||||
const { handle } = await share(prefix, 'watch');
|
||||
const sub = await watchValues(handle);
|
||||
expect(sub.includeValue).toBe(true);
|
||||
|
||||
const event = await deliveredAfter(sub.subId, () =>
|
||||
ownerWrites(`${prefix}note`, 'hidden'),
|
||||
);
|
||||
expect(event).toMatchObject({ key: 'note', op: 'set' });
|
||||
expect(event).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it.each(['read', 'write'] as const)(
|
||||
'carries the value at %s',
|
||||
async (access) => {
|
||||
const prefix = region(`values-${access}`);
|
||||
const { handle } = await share(prefix, access);
|
||||
const sub = await watchValues(handle);
|
||||
|
||||
await expect(
|
||||
deliveredAfter(sub.subId, () =>
|
||||
ownerWrites(`${prefix}note`, 'shown'),
|
||||
),
|
||||
).resolves.toMatchObject({ key: 'note', value: 'shown' });
|
||||
},
|
||||
);
|
||||
|
||||
it('follows a level change made after subscribing', async () => {
|
||||
const prefix = region('values-change');
|
||||
const { handle } = await share(prefix, 'read');
|
||||
const sub = await watchValues(handle);
|
||||
const write = (value: string) => () =>
|
||||
ownerWrites(`${prefix}note`, value);
|
||||
|
||||
await expect(
|
||||
deliveredAfter(sub.subId, write('one')),
|
||||
).resolves.toMatchObject({ value: 'one' });
|
||||
|
||||
await share(prefix, 'watch');
|
||||
const lowered = await deliveredAfter(sub.subId, write('two'));
|
||||
expect(lowered).toMatchObject({ key: 'note' });
|
||||
expect(lowered).not.toHaveProperty('value');
|
||||
|
||||
await share(prefix, 'read');
|
||||
await expect(
|
||||
deliveredAfter(sub.subId, write('three')),
|
||||
).resolves.toMatchObject({ value: 'three' });
|
||||
});
|
||||
|
||||
it('leaves the owner`s own subscription on the handle its values', async () => {
|
||||
const prefix = region('values-owner');
|
||||
const { handle } = await share(prefix, 'watch');
|
||||
const sub = await watchValues(handle, owner);
|
||||
|
||||
await expect(
|
||||
deliveredAfter(sub.subId, () =>
|
||||
ownerWrites(`${prefix}note`, 'mine'),
|
||||
),
|
||||
).resolves.toMatchObject({ key: 'note', value: 'mine' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
it('are set, changed, kept when omitted and cleared with null', async () => {
|
||||
const prefix = region('label');
|
||||
@@ -661,6 +763,7 @@ describe('what has been shared with me', () => {
|
||||
access: 'write',
|
||||
appUid: GLOBAL,
|
||||
label: 'Plans',
|
||||
notify: false,
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(page.items.some((row) => row.handle === gone.handle)).toBe(
|
||||
@@ -1123,7 +1226,16 @@ describe('an app deletion that fails partway', () => {
|
||||
}
|
||||
expect((await handles().getByHandle(handle))?.revokedAt).toBeNull();
|
||||
|
||||
await deleted();
|
||||
// The next check after its backoff retries it.
|
||||
const now = Date.now.bind(Date);
|
||||
const clock = vi
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => now() + 2 * 60_000);
|
||||
try {
|
||||
await shares().resumeKvShareJobs();
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
expect(await grantRows(guest, prefix, app.uid)).toEqual([]);
|
||||
expect(await handles().getByHandle(handle)).toMatchObject({
|
||||
revokedBy: 'system',
|
||||
|
||||
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user