mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
172
Commits
26.10
...
NS/PUT-1903
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca509f821 | ||
|
|
4401b4c4dc | ||
|
|
62270ff9aa | ||
|
|
b52fc01712 | ||
|
|
f37cb22d8a | ||
|
|
948772165d | ||
|
|
b8bb6292d6 | ||
|
|
542ca9ee1c | ||
|
|
8e92bf6803 | ||
|
|
b61448c8b7 | ||
|
|
f9cde23937 | ||
|
|
94e84a2819 | ||
|
|
f0b55e7605 | ||
|
|
d8f9aff3dd | ||
|
|
b88aa55b24 | ||
|
|
ff13edc19a | ||
|
|
f07a786b41 | ||
|
|
21131ad3d6 | ||
|
|
38c986927c | ||
|
|
590c87c00a | ||
|
|
ed22e2b285 | ||
|
|
9030b45459 | ||
|
|
f3134b2530 | ||
|
|
45f6e9d8ee | ||
|
|
d162549c1d | ||
|
|
576e893a5b | ||
|
|
ecef154630 | ||
|
|
c815dc82a1 | ||
|
|
b5eefaa9cc | ||
|
|
af9ebb0ef1 | ||
|
|
2131b980ce | ||
|
|
96909b0800 | ||
|
|
906d6b7886 | ||
|
|
6a938bd8e8 | ||
|
|
847a4fc031 | ||
|
|
3296526e8e | ||
|
|
c553a380ae | ||
|
|
cf9df8719a | ||
|
|
7ad963bacf | ||
|
|
0066d49f74 | ||
|
|
b67acdbf6f | ||
|
|
64db150a90 | ||
|
|
f88cadb4b9 | ||
|
|
aef0a32d72 | ||
|
|
800e300a8c | ||
|
|
491729e060 | ||
|
|
d4589caefe | ||
|
|
4fca4809b9 | ||
|
|
cc8b9858a2 | ||
|
|
6060de8fbd | ||
|
|
0e0cedb02b | ||
|
|
43094a84a0 | ||
|
|
5fb025c109 | ||
|
|
50bcdc9c6c | ||
|
|
ff35fbd66e | ||
|
|
1ff5d93244 | ||
|
|
86cc3490f7 | ||
|
|
7de8360a6e | ||
|
|
be78281d9d | ||
|
|
8a6929847d | ||
|
|
360f6b1079 | ||
|
|
8ecfeec8c3 | ||
|
|
ae234ffcc2 | ||
|
|
2ae2e093e7 | ||
|
|
dcfb1263de | ||
|
|
50e2af34bf | ||
|
|
7adc03cb75 | ||
|
|
807368f7b8 | ||
|
|
4f3a524ace | ||
|
|
95b6f86fcc | ||
|
|
bb829885ef | ||
|
|
c55437245b | ||
|
|
d1d55a6bc3 | ||
|
|
4ea93e6a73 | ||
|
|
80736de0f1 | ||
|
|
085ab1774f | ||
|
|
505fd13d97 | ||
|
|
47a1be7f70 | ||
|
|
38c7191009 | ||
|
|
b6f31e3b02 | ||
|
|
3c852cf9cf | ||
|
|
0afe8ccfdd | ||
|
|
266390d23d | ||
|
|
b6150d63f3 | ||
|
|
4e8277ea4b | ||
|
|
b6dfc6f903 | ||
|
|
3d7deaa403 | ||
|
|
199d72cbdf | ||
|
|
f234bd477c | ||
|
|
2638e9ce93 | ||
|
|
accf33a50e | ||
|
|
ab175b0fb4 | ||
|
|
3fcbd895a7 | ||
|
|
4c9e4d8e79 | ||
|
|
cf574e4b34 | ||
|
|
2e275a8f6b | ||
|
|
b5bd917119 | ||
|
|
3999dd062f | ||
|
|
3e234d7329 | ||
|
|
f674e51b4d | ||
|
|
d395a3aa94 | ||
|
|
fde323842e | ||
|
|
4a54beac6b | ||
|
|
0555ea6479 | ||
|
|
27eebc7300 | ||
|
|
4b5f509066 | ||
|
|
ece8ad5f1d | ||
|
|
cf817b0f0a | ||
|
|
6efb96d560 | ||
|
|
5596e70f25 | ||
|
|
e477bca3eb | ||
|
|
11e48c68a9 | ||
|
|
464f758a97 | ||
|
|
fadee6fac5 | ||
|
|
6bce256589 | ||
|
|
d2cdde5147 | ||
|
|
50ee0818ea | ||
|
|
ad1be70d06 | ||
|
|
6277f106c4 | ||
|
|
738a8058ef | ||
|
|
0e2f9663b2 | ||
|
|
b8fcbffe3f | ||
|
|
755b58fd49 | ||
|
|
bd5aa901ab | ||
|
|
b84fc6c158 | ||
|
|
edb0c2a712 | ||
|
|
55e29666d0 | ||
|
|
66774ff90c | ||
|
|
0fbb6def5a | ||
|
|
d9ee207ed0 | ||
|
|
0495ea0f9d | ||
|
|
73698be964 | ||
|
|
25f132b28d | ||
|
|
36a22b05a2 | ||
|
|
9d8e37dc6d | ||
|
|
6826071156 | ||
|
|
d896250ae2 | ||
|
|
665314535e | ||
|
|
f3b05ca822 | ||
|
|
96f0c51d1e | ||
|
|
a1eca46a80 | ||
|
|
484c498859 | ||
|
|
71399c5939 | ||
|
|
b1da48b738 | ||
|
|
9a9972b026 | ||
|
|
4629b544e6 | ||
|
|
cbb8d708b4 | ||
|
|
95809c7dd5 | ||
|
|
ac5fa20388 | ||
|
|
3389d3f8a1 | ||
|
|
39099e2056 | ||
|
|
acd6ea3c75 | ||
|
|
d6c606fb21 | ||
|
|
8c7372adc6 | ||
|
|
3711d1e837 | ||
|
|
957784bdb8 | ||
|
|
dc029b43a8 | ||
|
|
985c76c9c3 | ||
|
|
f21cd51edf | ||
|
|
c2fc45a8d1 | ||
|
|
614c4c9c2f | ||
|
|
ac740984a3 | ||
|
|
63eabf8c7c | ||
|
|
445588f857 | ||
|
|
53ca2b61e5 | ||
|
|
a242e31343 | ||
|
|
ba081e2332 | ||
|
|
a46eb15d5e | ||
|
|
970092f0a0 | ||
|
|
6dc61b2d67 | ||
|
|
7976dc9a29 | ||
|
|
a76800d693 |
No files matched your search
@@ -1,17 +1,21 @@
|
||||
name: Backend Tests
|
||||
|
||||
# Always triggers so it can be a required PR check; the `changes` job below
|
||||
# Always triggers on PRs so it can be a required check; the `changes` job below
|
||||
# decides whether backend/extension paths actually changed (test-only changes
|
||||
# match the same globs, since tests are co-located with the code), and every
|
||||
# other job is skipped — not absent — when they didn't, which still satisfies
|
||||
# a required check. puter.js SDK changes are covered by puterjs-tests.yaml.
|
||||
# match the same globs, since tests are co-located with the code), and `test`
|
||||
# still reports success when they didn't. Pushes to main produce the coverage
|
||||
# PRs compare against. puter.js SDK changes are covered by puterjs-tests.yaml.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
# Reads main's coverage artifact for the PR comparison.
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
@@ -42,12 +46,9 @@ jobs:
|
||||
# The consuming repo runs an equivalent gate, but only once someone bumps the
|
||||
# submodule pointer — too late to keep the error off this repo's default
|
||||
# branch. Hence a gate here, on this repo's own pull requests.
|
||||
#
|
||||
# Its own job rather than a step in `test`: that one runs a base/PR matrix for
|
||||
# coverage comparison, and the check only needs the PR ref, once.
|
||||
typecheck:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -65,85 +66,146 @@ jobs:
|
||||
- name: Type check (new errors only)
|
||||
run: npm run typecheck
|
||||
|
||||
# `needs.changes` gates the steps below, not this job, on purpose: a
|
||||
# job-level `if` on a matrix job skips the whole job as one unexpanded
|
||||
# check ("test (${{ matrix.artifact }})", literally, since there's no
|
||||
# matrix context to fill it in) instead of the two real per-artifact
|
||||
# checks a required rule pins to ("test (base)" / "test (pr)") — so the
|
||||
# required check never gets reported and the PR blocks forever. Letting
|
||||
# the matrix always expand keeps the check names stable either way.
|
||||
test:
|
||||
# The suite is CPU-bound, so it's split across runners. Each shard writes a
|
||||
# blob report (results plus coverage) that the `coverage` job merges.
|
||||
test-shard:
|
||||
needs: changes
|
||||
# Overrides the default matrix naming, which would otherwise bake the
|
||||
# base/head branch name (matrix.ref) into the check name — making it a
|
||||
# different string on every PR and impossible to pin as a required check.
|
||||
name: test (${{ matrix.artifact }})
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
name: test shard ${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- ref: ${{ github.base_ref }}
|
||||
artifact: base
|
||||
- ref: ${{ github.head_ref }}
|
||||
artifact: pr
|
||||
shard: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
|
||||
- name: Set up Node.js
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests with coverage
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
run: npm run test:backend -- --coverage
|
||||
run: >-
|
||||
npm run test:backend --
|
||||
--coverage
|
||||
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
--reporter=default
|
||||
--reporter=blob
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: backend-blob-${{ matrix.shard }}
|
||||
path: .vitest-reports/
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
|
||||
# Required check, so its PR name is pinned. A skipped job would satisfy it,
|
||||
# so this always runs and fails on any shard that didn't pass.
|
||||
test:
|
||||
needs: [changes, test-shard]
|
||||
if: ${{ !cancelled() }}
|
||||
name: test (${{ github.event_name == 'push' && 'main' || 'pr' }})
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check shard results
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
BACKEND_CHANGED: ${{ needs.changes.outputs.backend }}
|
||||
SHARDS_RESULT: ${{ needs.test-shard.result }}
|
||||
run: |
|
||||
if [ "$CHANGES_RESULT" != "success" ]; then
|
||||
echo "changes job: $CHANGES_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$BACKEND_CHANGED" != "true" ]; then
|
||||
echo "No backend changes."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$SHARDS_RESULT" != "success" ]; then
|
||||
echo "test shards: $SHARDS_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
coverage:
|
||||
needs: [changes, test-shard]
|
||||
if: ${{ !cancelled() && needs.test-shard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: backend-blob-*
|
||||
path: .vitest-reports
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge shard coverage
|
||||
run: npx vitest run --config src/backend/vitest.config.ts --merge-reports --coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: needs.changes.outputs.backend == 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: backend-coverage-${{ matrix.artifact }}
|
||||
name: backend-coverage-${{ github.event_name == 'push' && 'main' || 'pr' }}
|
||||
path: src/backend/coverage/
|
||||
retention-days: 14
|
||||
retention-days: ${{ github.event_name == 'push' && 30 || 14 }}
|
||||
|
||||
report-coverage:
|
||||
needs: [changes, test]
|
||||
if: always() && needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-coverage-pr
|
||||
path: src/backend/coverage
|
||||
|
||||
- name: Download base coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-coverage-base
|
||||
path: coverage-base
|
||||
# Latest main coverage from this repo's own runs; a fork can't publish
|
||||
# under this repository id.
|
||||
- name: Download main coverage
|
||||
id: base
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
id=$(gh api "repos/$REPO/actions/artifacts?name=backend-coverage-main&per_page=20" --jq '
|
||||
[.artifacts[]
|
||||
| select(.expired | not)
|
||||
| select(.workflow_run.head_branch == "main")
|
||||
| select(.workflow_run.head_repository_id == .workflow_run.repository_id)
|
||||
][0].id // empty')
|
||||
if [ -z "$id" ]; then
|
||||
echo "No main coverage artifact; reporting without a comparison."
|
||||
exit 0
|
||||
fi
|
||||
gh api "repos/$REPO/actions/artifacts/$id/zip" > coverage-base.zip
|
||||
unzip -q coverage-base.zip -d coverage-base
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Report coverage on PR
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
json-summary-path: src/backend/coverage/coverage-summary.json
|
||||
json-final-path: src/backend/coverage/coverage-final.json
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
json-summary-compare-path: ${{ steps.base.outputs.found == 'true' && 'coverage-base/coverage-summary.json' || '' }}
|
||||
@@ -66,6 +66,7 @@ AGENTS.md
|
||||
|
||||
|
||||
coverage/
|
||||
.vitest-reports/
|
||||
*.log
|
||||
undefined
|
||||
servers.json
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding agents working in this repository. Read this before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think the guidance is wrong, raise an issue or flag a maintainer — don't just do what you think is right. This is the source of truth for how we write code, tests, and docs in this repo.
|
||||
Guidance for AI coding agents working in this repository, and the source of truth for how we write code, tests, and docs here. Read it before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think a rule is wrong, raise an issue or flag a maintainer instead of doing what you think is right.
|
||||
|
||||
## Documentation Index
|
||||
|
||||
Use these as the source of truth before exploring further:
|
||||
|
||||
- [README.md](README.md) — project overview and quickstart.
|
||||
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), and extensions.
|
||||
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), actors, and extensions.
|
||||
- [doc/contributing-apis.md](doc/contributing-apis.md) — adding and maintaining public APIs end to end (backend surface → puter.js → types → docs → tests). Follow it for any API work.
|
||||
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
|
||||
- [doc/alarms.md](doc/alarms.md) — raising alarms and picking a severity (what pages, what only gets recorded), plus the config that routes them.
|
||||
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — testing, security, AI-assisted code, PR conventions, Boy Scout Rule.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor entry point and PR conventions.
|
||||
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
|
||||
- [BUG-BOUNTY.md](BUG-BOUNTY.md) — bounty program scope.
|
||||
- [TRADEMARK.md](TRADEMARK.md) — trademark usage.
|
||||
@@ -26,16 +26,16 @@ These apply everywhere — backend, puter.js, and GUI.
|
||||
### Language & files
|
||||
|
||||
- Write ES modules, not CommonJS — we transpile and build as needed.
|
||||
- TypeScript preferred for new files in the backend and extensions; convert existing JS there opportunistically when you're already touching a file. The GUI and puter.js are plain JavaScript — don't introduce TypeScript files in them. In puter.js, type with JSDoc instead (see the puter.js section for what must be typed).
|
||||
- **Reuse before adding.** Search for an existing type, helper, or implementation and extend it; only add a new one when nothing suitable exists.
|
||||
- Make new types findable: descriptive `PascalCase` names, exported from the obvious entry point. A type used from many places belongs in the owning module's `types.ts` (e.g. [src/backend/controllers/types.ts](src/backend/controllers/types.ts)) rather than bloating a logic file; a type with a single consumer can stay next to it.
|
||||
- Naming: `camelCase` for variables/functions, `PascalCase` for classes and files containing a class (`AuthService.ts`). **Prefer `camelCase` for all new code** — new local variables, parameters, functions, and internal object properties. `snake_case` is reserved for identifiers that are part of an external contract: wire/JSON keys sent to or received from the backend, stable API error codes, public option names that already ship as `snake_case`, and established `puter_*` namespaced fields. Don't rename those (it's a breaking change), but don't introduce new `snake_case` alongside them either — a camelCase local can carry a value into a `snake_case` wire key (`{ operation_id: operationId }`).
|
||||
- TypeScript preferred for new files in the backend and extensions; convert existing JS there opportunistically when you're already touching a file. The GUI and puter.js are plain JavaScript — don't introduce TypeScript files in them.
|
||||
- In plain-JS files, typing with JSDoc (`@type`/`@param`/`@returns`, `@typedef` for shared shapes, using the TypeScript type system) is encouraged. API types must not be `unknown` or untyped `...args`: spell out the real parameter and return shapes, unless the value passes through transparently to an upstream layer that owns its type. puter.js has stricter rules ([below](#types)).
|
||||
- Make new types findable: descriptive `PascalCase` names, exported from the obvious entry point. A type used from many places goes in the owning module's `types.ts` (e.g. [src/backend/controllers/types.ts](src/backend/controllers/types.ts)); a type with a single consumer can stay next to it.
|
||||
- Naming: `camelCase` for variables and functions, `PascalCase` for classes and files containing a class (`AuthService.ts`). **Prefer `camelCase` for all new code**, including parameters and internal object properties. `snake_case` is reserved for external contracts that already use it: wire/JSON keys, stable API error codes, public option names that already ship as `snake_case`, and established `puter_*` namespaced fields. Don't rename those (it's a breaking change), and don't add new `snake_case` beside them; a camelCase local can carry a value into a wire key (`{ operation_id: operationId }`).
|
||||
|
||||
### Comments
|
||||
|
||||
Keep comments light; prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. Use `//` for single lines and `/** ... */` JSDoc when it genuinely needs more — if a comment runs long, it's probably too long. Don't restate the code and don't reference the current task, PR, or version — those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names; describe the why in domain terms, not project-management terms. Use plain ASCII `-` in comment section dividers (`// -- Section --`), never box-drawing characters.
|
||||
Prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. **A comment is a sentence or two**: one or two lines of `//`, a few of `/** ... */` JSDoc. State the constraint and stop. Don't restate the code, narrate history ("used to swallow the failure, which meant…"), argue the design, or explain parameters the signature already shows. Reasoning that needs paragraphs belongs in a design doc.
|
||||
|
||||
**Be concise — a comment is a sentence or two, not a paragraph block.** One or two lines for `//`, a few for JSDoc. Don't narrate the history ("used to swallow the failure, which meant…"), don't argue the design at length, and don't explain each parameter when the signature already does. State the constraint and stop. If the reasoning genuinely needs paragraphs, it belongs in a design doc, not above the function.
|
||||
Don't reference the current task, PR, or version; those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names: describe the why in domain terms. Section dividers use plain ASCII (`// -- Section --`), never box-drawing characters.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
@@ -49,77 +49,57 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
|
||||
|
||||
Commit messages and PR descriptions never credit anyone or mention a bug report. For security fixes, state what the change does mechanically ("scoped tokens do xyz now"), not the vulnerability or how it could be abused.
|
||||
|
||||
### Reuse and abstraction
|
||||
|
||||
- **Search before you build.** Before writing a type, helper, cache/TTL map, retry, lock, pagination, validator, serializer, error mapper, host/email normalizer, or provider adapter, grep `src/backend/util/`, the owning layer, and `extensions/` for one that exists, and extend it instead of adding a parallel copy. If you find two copies, consolidate them or flag it. Before opening the PR, check the diff for new helpers that duplicate existing ones.
|
||||
- **One path per operation.** Single-item variants call the batch version. Legacy and v2 endpoints share one service method. A second entry point (OIDC, WebDAV, admin, cron) calls the same service as the first instead of re-implementing it.
|
||||
- **No abstraction without a second caller.** No base class, interface, factory, registry, decorator, or option bag with one implementation. No wrappers that only forward, no interface methods nobody calls, no methods only tests call.
|
||||
- **Variants are data.** Providers or models that differ only in constants (base URL, prices, key names) are entries in a table read by one implementation, not one file each.
|
||||
|
||||
### Hot paths
|
||||
|
||||
- No per-row queries or awaits in loops: use the store's batch method (add one if missing) or [src/backend/util/concurrency.ts](src/backend/util/concurrency.ts).
|
||||
- Bound everything: queries get `LIMIT`/pagination, in-memory maps get eviction, outbound calls get a timeout and a size cap.
|
||||
- Build clients, catalogs, and config-derived lookups once, not per request.
|
||||
- Listeners on fleet-wide events (`fs.write.file`, route lifecycle) return before any I/O for requests they don't concern.
|
||||
|
||||
### Working rules of thumb
|
||||
|
||||
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
|
||||
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you think the existing pattern is wrong, raise it — don't quietly diverge.
|
||||
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo or dead import in files you're already touching; don't ride a refactor along with a bug fix.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
|
||||
- **Boy Scout Rule, proportional to the change.** In files you're already touching, fix the typo, the dead import, the missing test, the bit you had to read twice. Don't ride a refactor along with a bug fix.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't have written, debugged, or defended in review is not.
|
||||
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/util/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
|
||||
|
||||
---
|
||||
|
||||
## Local development configuration
|
||||
## Local development
|
||||
|
||||
To test a local Docker build, create `docker-compose.override.yml` in the repository
|
||||
root (or merge into the existing file):
|
||||
To test a local Docker build, use the gitignored `docker-compose.override.yml` described in [Self-Hosting → Building from source](doc/self-hosting.md#building-from-source-instead-of-pulling). Keep local build settings there, not in `docker-compose.yml`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
puter:
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
```
|
||||
|
||||
Compose merges it automatically. Run `docker compose up -d --build` to build and
|
||||
start the stack, and repeat after source changes. Keep local build settings in
|
||||
this gitignored override instead of editing `docker-compose.yml`, so they stay
|
||||
out of pull requests and do not conflict when pulling updates. See
|
||||
[Self-Hosting Puter](doc/self-hosting.md#building-from-source-instead-of-pulling)
|
||||
for the required runtime configuration.
|
||||
|
||||
For local API testing, agents may decide whether to set `"unlimitedMetering": true`
|
||||
when budget checks block unrelated tests; no additional approval is needed.
|
||||
Merge it into the ignored `config.json` for `npm start` or
|
||||
`puter/config/config.json` for Docker, then restart Puter. It applies to all
|
||||
accounts on that local instance, including guests, and usage is still recorded.
|
||||
Keep normal settings when testing budget or subscription enforcement, and leave
|
||||
shared defaults unchanged.
|
||||
When budget checks block unrelated local API tests, you may set `"unlimitedMetering": true` without further approval. Merge it into the gitignored `config.json` (`npm start`) or `puter/config/config.json` (Docker) and restart Puter. It covers every account on that instance, guests included, and usage is still recorded. Keep normal settings when testing budget or subscription enforcement, and leave shared defaults unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
|
||||
Controllers → drivers (optional) → services → stores → clients, all configured from `IConfig`. Each layer depends only on the layers beneath it and receives them through its constructor; `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires them. [doc/architecture.md](doc/architecture.md) is the full reference.
|
||||
|
||||
| Layer (top → bottom) | Lives in | Responsibility |
|
||||
| -------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers: parse/validate input, per-route gates via `RouteOptions` (auth, subdomain, rate limit, body parsers), call services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional RPC-style handlers on `/drivers/*`; thin shells that validate inputs and call services/stores. |
|
||||
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized. |
|
||||
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence; wraps clients in the domain shapes services consume. |
|
||||
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, email, event bus). Protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
|
||||
A public API can be a controller or a driver. Prefer a controller when you need fine-grained control over routes and gates; [doc/contributing-apis.md](doc/contributing-apis.md#controller-or-driver) has the decision guide.
|
||||
|
||||
A public API can be exposed through either a controller or a driver — both are supported; prefer a controller when you need fine-grained control over routes and gates. [doc/contributing-apis.md](doc/contributing-apis.md) has the decision guide with links to the decorators and middleware for each.
|
||||
Layer rules:
|
||||
|
||||
Cross-layer rules:
|
||||
|
||||
- **Don't reach across layers.** Controllers don't poke clients; services don't register routes. If you want to, the abstraction is wrong — fix the abstraction.
|
||||
- **Don't reach across layers.** Controllers don't poke clients; services don't register routes; lower layers never import from controllers. If you want to, the abstraction is wrong; fix the abstraction.
|
||||
- **Gates run at the edge.** Controllers and drivers validate input and apply auth gates; services assume the caller is already authenticated and authorized.
|
||||
- **Don't call sideways within a layer for code reuse.** Two services needing the same logic means a util/helper, not a service-to-service dependency.
|
||||
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only for genuinely request-scoped values that would otherwise thread through many layers — today mostly `actor` and `req`.
|
||||
- **A table's store owns every query against it.** Services, controllers, and extensions don't run raw SQL/redis against a table that has a store; add the store method so cache invalidation stays with the write.
|
||||
- **Drivers and controllers stay thin.** Domain rules a second entry point needs (signup, app CRUD, worker deploy, AI routing/billing) live in a service, not in the first driver or controller that needed them.
|
||||
- **Prefer explicit arguments over [`Context`](src/backend/core/context.ts) (ALS).** Use it only for request-scoped values that would otherwise thread through many layers; today that's mostly `actor` and `req`.
|
||||
|
||||
### Extensions
|
||||
|
||||
[extensions/](extensions/) parallels the layered stack and is for **non-crucial parts of the system** — Puter still works with the extension removed. If core needs to call it, it belongs in core, not an extension (see [whoami](extensions/whoami.ts) as the cautionary example). The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
|
||||
|
||||
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
|
||||
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
|
||||
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` — lazy proxy to instantiated core objects. `extension.config` exposes live config.
|
||||
|
||||
Follow the same layered structure inside an extension — unless it only needs a few route handlers, in which case the lightweight helpers are enough on their own.
|
||||
[extensions/](extensions/) is for **non-crucial parts of the system**: Puter must still work with the extension removed. If core needs to call it, it belongs in core ([whoami](extensions/whoami.ts) is the cautionary example). Inside an extension, follow the same layered structure, unless it only needs a few route handlers, in which case the `extension.get/post/...` helpers are enough on their own. The `extension` API is in [doc/architecture.md](doc/architecture.md#extension-api).
|
||||
|
||||
### Backend tests
|
||||
|
||||
@@ -132,44 +112,82 @@ Follow the same layered structure inside an extension — unless it only needs a
|
||||
|
||||
## puter.js (the SDK)
|
||||
|
||||
[src/puter-js/](src/puter-js/) is the public SDK. It ships live from `https://js.puter.com/v2/` with no version pinning — every existing app picks up changes immediately. Treat every observable behavior (signatures, response fields, error codes) as something a production app depends on.
|
||||
[src/puter-js/](src/puter-js/) is the public SDK. It ships live from `https://js.puter.com/v2/` with no version pinning, so every change reaches every existing app at once; see [Rule zero](doc/contributing-apis.md#rule-zero-dont-break-callers).
|
||||
|
||||
Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `kv/`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), generated (gitignored) type declarations in `src/puter-js/types/`, API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/).
|
||||
|
||||
Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. **The JSDoc is the source of truth for the SDK's types.** `src/puter-js/types/` is `tsc --emitDeclarationOnly` output: the SDK build (`npm run build` in `src/puter-js`) generates it, the npm tarball ships it so TypeScript consumers get declarations, and git ignores it — it is never committed and never edited by hand. `npm run check:puterjs:types` generates it in CI and type-checks the result without `skipLibCheck`. The one hand-written declaration file is [src/puter-js/index.d.ts](src/puter-js/index.d.ts), which decides what is public and re-exports the generated names.
|
||||
Every SDK change carries all five of these, or the PR is incomplete. [doc/contributing-apis.md](doc/contributing-apis.md) has the steps for each.
|
||||
|
||||
Declare a shape where it belongs, and reference it with an `import(...)` type from elsewhere. A shape more than one file needs lives in the module's `types.js`; a shape shared across modules lives in [src/puter-js/src/lib/types.js](src/puter-js/src/lib/types.js); a shape with one consumer can stay next to it:
|
||||
1. **Backward compatibility**, unless a maintainer explicitly signs off on a break ([rules](doc/contributing-apis.md#maintaining-an-existing-api)).
|
||||
2. **Tests** in the cross-platform API suite, or the Playwright harness for desktop-rendered `puter.ui.*` ([steps](doc/contributing-apis.md#6-tests)).
|
||||
3. **Docs** in [src/docs/src/](src/docs/src/) that match the implementation exactly ([steps](doc/contributing-apis.md#5-docs), [writing rules](#developer-docs)).
|
||||
4. **Types**, per the rules below.
|
||||
5. **Error handling**: `{ message, code }` with stable `snake_case` codes, as in [modules/kv/](src/puter-js/src/modules/kv/) ([steps](doc/contributing-apis.md#3-puterjs)).
|
||||
|
||||
### Types
|
||||
|
||||
puter.js source is plain JavaScript typed via JSDoc, and **the JSDoc is the source of truth for the SDK's types**. `src/puter-js/types/` is `tsc --emitDeclarationOnly` output: the SDK build (`npm run build` in `src/puter-js`) generates it, the npm tarball ships it, and git ignores it. Never commit or hand-edit it. `npm run check:puterjs:types` generates it and type-checks the result without `skipLibCheck`; run it after any type change. The one hand-written declaration file is [src/puter-js/index.d.ts](src/puter-js/index.d.ts): it decides what is public and re-exports the generated names, so name a new type there if consumers should be able to import it.
|
||||
|
||||
- Public (exposed) methods carry JSDoc types for parameters and return value, with one `@overload` block per accepted call form; those overloads _are_ the published signature. Typing private helpers is at your discretion; annotate where it helps the next reader.
|
||||
- Tag members `@internal`, not `@private`, to strip them from the generated declarations.
|
||||
- Declarations must match runtime behavior exactly. A wrong type is worse than a missing one.
|
||||
|
||||
Declare a shape where it belongs and reference it elsewhere with an `import(...)` type. A shape more than one file needs lives in the module's `types.js`; one shared across modules lives in [src/puter-js/src/lib/types.js](src/puter-js/src/lib/types.js); one with a single consumer can stay next to it:
|
||||
|
||||
```js
|
||||
/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */
|
||||
```
|
||||
|
||||
Use `@typedef {Object}` + `@property` for any shape whose fields need documenting — it is the only JSDoc form that carries a doc comment per field into the generated declaration. Keep the inline object-literal form for small internal shapes with nothing to say about each field, and prefer `unknown` over `*`:
|
||||
Use `@typedef {Object}` + `@property` for any shape whose fields need documenting; it's the only JSDoc form that carries a per-field doc comment into the generated declarations. Use the inline form for small internal shapes with nothing to say per field, and prefer `unknown` over `*`:
|
||||
|
||||
```js
|
||||
/** @typedef {{ key: string; value: unknown }} KVEntry */
|
||||
```
|
||||
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads _are_ the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
|
||||
---
|
||||
|
||||
Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example:
|
||||
## Developer docs
|
||||
|
||||
```js
|
||||
/** @typedef {{ key: string; value: unknown }} KVEntry */
|
||||
[src/docs/src/](src/docs/src/) is the published puter.js documentation: one reference page per method (`<Area>/<method>.md`, [steps](doc/contributing-apis.md#5-docs)) and recipes in [src/docs/src/recipes/](src/docs/src/recipes/).
|
||||
|
||||
/** @type {KVEntry[]} */
|
||||
let entries = [];
|
||||
```
|
||||
### Page structure
|
||||
|
||||
Every SDK change carries all five of the following — a puter.js PR missing one is incomplete:
|
||||
The reference has two kinds of page:
|
||||
|
||||
1. **Backward compatibility.** Mandatory unless a maintainer explicitly signs off on a break. Existing call signatures keep working (including both positional and options-object forms where a method supports them); new parameters are optional with defaults that preserve old behavior; never rename or repurpose existing params, response fields, or error codes. New parameter names are `camelCase` (existing `snake_case` stays for compatibility). Say in the PR how existing callers are unaffected.
|
||||
2. **Tests.** Add or extend a suite in [tests/api/suites/](src/puter-js/tests/api/suites/) (`<area>.suite.ts`; register new suites in `suites/index.ts` — no globbing). One suite runs unchanged on node, browser, and workerd via `npm run test:puterjs`; never write a per-platform test. The runners execute the **built** bundle — run `npm run build:workerLib` after SDK changes or the suite silently tests stale code. For `puter.ui.*` methods rendered by the desktop, use the Playwright e2e harness instead — see [src/puter-js/TESTING.md](src/puter-js/TESTING.md).
|
||||
3. **Docs.** New or changed APIs update [src/docs/src/](src/docs/src/): the method page (`<Area>/<method>.md`, with frontmatter and a runnable example) and the area overview when the surface changes. Docs are the contract users code against — signatures, defaults, and return shapes must match the implementation exactly.
|
||||
4. **Types.** Type the change in JSDoc on the implementation, then run `npm run check:puterjs:types` to confirm it still produces declarations that type-check. Name any new type in [src/puter-js/index.d.ts](src/puter-js/index.d.ts) if consumers should be able to import it. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one.
|
||||
5. **Error handling.** Reject/throw `{ message, code }` objects with stable `snake_case` codes, matching the existing modules (see `KV.js`). Validate cheap preconditions client-side before making the network call; pass backend errors through unchanged rather than swallowing or re-wrapping them. Error codes are API surface — changing one is a breaking change.
|
||||
- **API overview pages** (`<Area>.md`, such as [KV.md](src/docs/src/KV.md)) give a quick top-level picture of what the API is for and what it can do, with short examples and a list of its functions that links every function page in the area. Leave out the details. The first sentence of the intro says what the API is about. This is a judgment call too: when the API depends on a Puter concept the reader may not know, such as Teams, the first sentence can name it and the sentences after it explain it.
|
||||
- **Function pages** (`<Area>/<method>.md`) hold the details: parameters, defaults, return values, errors, limits, and edge cases.
|
||||
|
||||
[doc/contributing-apis.md](doc/contributing-apis.md) walks the full lifecycle of adding an API across backend + SDK.
|
||||
An overview page answers "what is this API and is it what I need?", and a function page answers "how exactly does this call behave?". A change to one function usually only touches that function's page, so a new `kv.list()` option goes in `KV/list.md` without a matching update to `KV.md`. Don't copy a function's options, limits, or edge cases onto the overview page for completeness. Where a detail goes is a judgment call, not a hard rule: update the overview when a change affects how someone would describe the API as a whole.
|
||||
|
||||
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
|
||||
|
||||
### Writing docs
|
||||
|
||||
These apply to reference pages and recipes alike.
|
||||
|
||||
- **Document the developer-facing side only.** The reader is building on top of puter.js, so cover what affects their code: signatures, defaults, return shapes, errors, limits, and behavior they can observe. Leave out internal workings (which service handles the call, storage layout, caching, retries) unless they change what the developer has to do. [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) is the exception: it publishes every limit, including timeouts and caps that come from how the backend works.
|
||||
- **Describe the current state.** Docs aren't a changelog. Write how the API behaves now, without "now", "no longer", "existing code is unaffected", or other wording that compares it to an older version.
|
||||
- **Verify before you document.** Run the calls you describe against a running Puter instead of inferring behavior from the source.
|
||||
- **Write plainly.** Use the literal words a developer would say out loud, with contractions and everyday words. No idioms or figurative verbs ("walk away from something shared with you" → "remove your own access", "nothing to page through" → "without pagination"). No formal register ("present only while more pages remain" → "includes a `cursor` if there are more pages to load"). Prefer dev terms such as atomic, concurrent, and insertion order.
|
||||
- **State the rule, then its consequence, in short sentences.** No metaphor in place of a rule. Name a concrete action ("you can create any files and folders inside it") over an abstract permission ("yours to organize freely"). Cut a sentence that only repeats the next one.
|
||||
- **Describe behavior directly.** No quoted inner-voice phrases. Write "`isNew` is `true` when the person did not have access before", not "this tells 'I just gave this to someone' from 'they already had it'".
|
||||
- **Keep references easy to follow.** Name what a list or code block belongs to in the sentence directly above it. Name a thing once and keep that name. Introduce an actor as "a user" before giving them a role: "A user can create a team. The user who creates the team becomes its owner."
|
||||
- **Sentence mechanics.** Never start a sentence with inline code: write "To store data, use the [`set()`](/KV/set/) method." Link every inline mention of an API to its page, every time. No em dashes, and no colons or semicolons mid-sentence; a colon that introduces a code block is fine.
|
||||
- **Show the correct way.** No "don't" and no wrong-way-first examples, in recipes and playground examples alike. Mention a wrong approach only when developers already hit it in practice, and state how it fails as a fact: "`add()` reads a plain object as a path map, so the call rejects with …".
|
||||
|
||||
### Recipes
|
||||
|
||||
Recipes are tested, copy-pasteable patterns for a task, one layer above the per-method reference, often combining several APIs. Agents find them through the `## Recipes` section of `llms.txt`.
|
||||
|
||||
- **Files.** One `.md` per recipe in [src/docs/src/recipes/](src/docs/src/recipes/), with `title`, `description`, `tags`, and optional `order` front matter; they're picked up automatically. Tags must exist in [src/docs/src/recipe-tags.js](src/docs/src/recipe-tags.js) or the build fails. Quote a `description` that contains a colon followed by a space. Every top-level `.md` in that directory is built as a recipe, so keep other markdown out of it.
|
||||
- **Title.** Short, Title Case, starts with a verb, and leaves out "to Your App" ("Add Team Support", "Deploy Workers Programmatically"). The slug is the title in kebab case, with no API-name prefix. In `llms.txt` an agent sees only the title and description before deciding to open a recipe, so word them to match the task the reader is trying to do.
|
||||
- **Description.** Written for someone who has never heard of Puter. Start with "Learn how to", put the action first, and say what the reader's app gains: "Learn how to add file sharing to your app with Puter.js, so users can share files in their Puter account with other people." No API names, no mechanism, no list of example cases, and no "not just …" contrast.
|
||||
- **Intro.** Explain the situation and why you'd do this, tied to something the reader already knows ("`puter.kv` is your database in Puter.js"). No "Use this when" bullet list.
|
||||
- **Structure.** Get to the first working call as early as possible, then add validation, rate limits, and sign-in after it. Explain a Puter concept the first time it appears, such as what `me.puter` is or that `puter.workers.exec()` opens the Puter sign-in window. Split recipes by what the reader needs, not by technique, and give a family of recipes a shared tag.
|
||||
- **Link to the reference for full option lists.** Mention only the options the recipe uses, then link to the function page for the rest ("All the sort options are listed on [`puter.fs.readdir()`](/FS/readdir/)."). A copied list of options, rules, or scopes goes stale when the reference changes.
|
||||
- **Code.** Platform-neutral: just the code, with top-level `await`. No `<script>` or `<html>` wrapper, no IIFE.
|
||||
- **Tradeoffs.** Justify a choice with one scenario the reader can picture right away, in about two short paragraphs, or fold each point into the section where it matters. The description states the benefit only.
|
||||
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit goes in the section it applies to; other facts belong on the linked reference page.
|
||||
|
||||
---
|
||||
|
||||
@@ -177,7 +195,7 @@ Every SDK change carries all five of the following — a puter.js PR missing one
|
||||
|
||||
[src/gui/](src/gui/) is the Puter desktop: deliberately plain JavaScript + jQuery with HTML-string templates. Don't introduce a UI framework or a new rendering pattern.
|
||||
|
||||
The guiding rule here is **conformity over novelty** — match the existing design and code structure even where you'd personally choose differently. A visually or structurally divergent addition is a defect even when it works.
|
||||
**Conformity over novelty**: match the existing design and code structure even where you'd choose differently. A visually or structurally divergent addition is a defect even when it works.
|
||||
|
||||
- **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.
|
||||
|
||||
+11
-36
@@ -1,46 +1,21 @@
|
||||
# Contributing to Puter Backend
|
||||
# Contributing to Puter
|
||||
|
||||
Thanks for contributing. These rules aren't strictly enforced — but following them makes every PR easier. If anything's unclear, ping a core maintainer or open the PR and ask.
|
||||
Thanks for contributing. These rules aren't strictly enforced, but following them makes every PR easier. If anything's unclear, ping a core maintainer or open the PR and ask.
|
||||
|
||||
New to the backend? Start with [doc/architecture.md](doc/architecture.md).
|
||||
- New to the backend? Start with [doc/architecture.md](doc/architecture.md).
|
||||
- Code, test, and docs conventions for the whole repo live in [AGENTS.md](AGENTS.md). It's written for AI agents; the conventions apply to human contributors too.
|
||||
- Adding or changing a public API (an endpoint, driver method, or puter.js method)? Follow [doc/contributing-apis.md](doc/contributing-apis.md): backward compatibility, [developer docs](src/docs/), types, and tests all move in the same PR.
|
||||
- Found a vulnerability? Report it privately per [SECURITY.md](SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Test it. Run it.
|
||||
## Before you open a PR
|
||||
|
||||
Run the affected code path end-to-end before opening a PR. "It builds" is not "it works."
|
||||
Check your diff against these sections of AGENTS.md:
|
||||
|
||||
Add tests for new behavior, endpoints, or bug fixes. If something's genuinely hard to test, say so in the PR.
|
||||
|
||||
## 2. Follow existing patterns
|
||||
|
||||
Match the shape of similar code already in the repo. [doc/architecture.md](doc/architecture.md) is the source of truth for layers, wiring, and naming. If you think a pattern is wrong, raise it — don't quietly diverge.
|
||||
|
||||
In plain-JS files, typing is encouraged via JSDoc `@type` annotations using the TypeScript type system, with `@typedef` for shared shapes. Don't type API surfaces as `unknown` or untyped `...args` unless the values are passed through transparently to an upstream layer that owns their type.
|
||||
|
||||
## 3. Don't expose system or user information
|
||||
|
||||
Scan your diff for stray logs, debug routes, internal paths, secrets, tokens, or user data in errors/responses. When in doubt, return less. Flag any auth, permission, or data-export changes in the PR description.
|
||||
|
||||
For private security reports, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
## 4. AI-assisted code is fine — understood code is required
|
||||
|
||||
Don't commit code you couldn't have written, debugged, or defended yourself. Read the diff, run it, and be ready to explain it in review.
|
||||
|
||||
## 5. Adding or changing APIs
|
||||
|
||||
If you add or change a public API (an endpoint, driver method, or puter-js method), follow [doc/contributing-apis.md](doc/contributing-apis.md) — backward compatibility, [developer docs](src/docs/), types, and tests all move in the same PR.
|
||||
|
||||
The same rule applies to limits: rate limits, concurrency caps, quotas, and allowances are published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md), and a PR that changes one of those numbers updates that page in the same PR. A limit nobody published is a limit developers only discover as a service failure.
|
||||
|
||||
## 6. Boy Scout Rule — leave it 1% better
|
||||
|
||||

|
||||
|
||||
Fix the typo, the dead import, the missing test, the bit you had to read twice. Keep cleanup proportional to the change — no refactors riding along on bug fixes.
|
||||
|
||||
---
|
||||
- [Working rules of thumb](AGENTS.md#working-rules-of-thumb): run it end to end, test new behavior and bug fixes, understand every line you commit (AI assistance is fine), leave touched files a bit better, and publish limit changes.
|
||||
- [Reuse and abstraction](AGENTS.md#reuse-and-abstraction) and [hot paths](AGENTS.md#hot-paths).
|
||||
- [Security & privacy](AGENTS.md#security--privacy): expose nothing the caller doesn't need.
|
||||
|
||||
## Opening a PR
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"disable_user_signup": false,
|
||||
"teams_enabled": false,
|
||||
"max_teams_per_user": 1,
|
||||
"max_seats_per_team_free": 4,
|
||||
"max_seats_per_team_free": 3,
|
||||
"max_seats_per_team_paid": 40,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
|
||||
@@ -323,12 +323,13 @@
|
||||
//
|
||||
// Seats one team may provision. Each seat is a real Puter account taking a
|
||||
// name from the global username pool, so this is what bounds a team's blast
|
||||
// radius. The owner's plan decides which of the two applies; defaults are 4
|
||||
// and 40. Lowering either below a team's current seat count blocks new
|
||||
// provisioning and disables nobody. Deployments upgrading from the old flat
|
||||
// default of 50: without a billing extension every owner reads as free, so
|
||||
// set `max_seats_per_team` to keep the old behavior.
|
||||
"max_seats_per_team_free": 4,
|
||||
// radius. The owner's plan decides which of the two applies; defaults are 3
|
||||
// and 40. A plan registered with `teamSeatCap` uses that instead. Lowering
|
||||
// a cap below a team's current seat count blocks new provisioning and
|
||||
// disables nobody. Deployments upgrading from the old flat default of 50:
|
||||
// without a billing extension every owner reads as free, so set
|
||||
// `max_seats_per_team` to keep the old behavior.
|
||||
"max_seats_per_team_free": 3,
|
||||
"max_seats_per_team_paid": 40,
|
||||
//
|
||||
// One flat cap for every team, whatever the owner pays. Setting it
|
||||
|
||||
+9
-3
@@ -36,9 +36,10 @@ pass one explicitly unless you really mean "page someone".
|
||||
### Choosing one
|
||||
|
||||
- Did the server fail to do its job in a way nobody expected? → `critical`
|
||||
- Is a background job, rate, or dependency degraded? → `warning`
|
||||
- Is this a user doing something notable (tripping an abuse heuristic,
|
||||
overspending)? → `info`
|
||||
- Is a background job, rate, or dependency degraded, or has an account spent
|
||||
far past its credits? → `warning`
|
||||
- Is this a user doing something notable (tripping an abuse heuristic)?
|
||||
→ `info`
|
||||
- Did one of *our own* limits reject a caller — a rate limit, a concurrency
|
||||
cap, a quota? → don't alarm at all. The limit doing its job is not an
|
||||
event; the 429 is the whole signal, and alarming on it only produces noise
|
||||
@@ -133,6 +134,11 @@ occurrence is reported up to a small burst, then one per interval. A fault
|
||||
recurring hundreds of times a second still counts every occurrence, but it
|
||||
does not write a log line or build an alert payload for each one.
|
||||
|
||||
The count lasts only as long as the client remembers the alarm. One that
|
||||
hasn't recurred for 24 hours is forgotten, and the client holds at most 1000
|
||||
at once, dropping the least recently raised first. Either way the next
|
||||
occurrence is a new alarm, with its count and burst starting over.
|
||||
|
||||
Below that, the chat transport won't repost the same alarm id within
|
||||
`repeatThrottleMs` (default 15 minutes). The first occurrence always posts,
|
||||
and the next one that gets through reports how many piled up in between —
|
||||
|
||||
+9
-20
@@ -1,6 +1,6 @@
|
||||
# Backend Architecture
|
||||
|
||||
Loosely inspired by the Controller–Service–Repository pattern with dependency injection. The backend is organized as a stack of layers where each layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](../src/backend/server.ts)) instantiates each layer in order and hands the instances down to the next.
|
||||
Loosely inspired by the Controller–Service–Repository pattern with dependency injection. The backend is a stack of layers; each depends only on the layers beneath it and receives them through its constructor from `PuterServer` ([src/backend/server.ts](../src/backend/server.ts)), which instantiates the layers in order. The rules for working within the layers are in [AGENTS.md](../AGENTS.md#backend).
|
||||
|
||||
## Layers
|
||||
|
||||
@@ -31,8 +31,6 @@ block-beta
|
||||
style CFG fill:#334155,stroke:#1e293b,color:#fff
|
||||
```
|
||||
|
||||
Each layer only depends on the layers beneath it, and every dependency is injected through the constructor by `PuterServer`. Extensions sit alongside this stack and can register into any layer — see [Extensions](#extensions) below.
|
||||
|
||||
| Layer | Lives in | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| **Controllers** | [src/backend/controllers/](../src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers — see `RouteOptions`), call into services, format responses. |
|
||||
@@ -42,7 +40,7 @@ Each layer only depends on the layers beneath it, and every dependency is inject
|
||||
| **Clients** | [src/backend/clients/](../src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamodb, email, event bus, …). Knows protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | The flat, typed config object every layer receives at construction. |
|
||||
|
||||
Each layer receives the layers beneath it through its constructor, so dependencies are explicit and traceable from `PuterServer`. A controller does not reach into a client directly; if it needs one, the right move is usually a service.
|
||||
Extensions sit alongside this stack and can register into any layer; see [Extensions](#extensions).
|
||||
|
||||
## Entry point: `PuterServer`
|
||||
|
||||
@@ -55,13 +53,11 @@ Each layer receives the layers beneath it through its constructor, so dependenci
|
||||
|
||||
## Context (ALS)
|
||||
|
||||
We use [`Context`](../src/backend/core/context.ts) — backed by `AsyncLocalStorage` — to carry per-request state without threading it through every function signature. It is used **sparingly**, mostly for `actor` and `req`. The request-context middleware opens a scope per request after the auth probe runs; anything inside a request handler can call `Context.get('actor')` / `Context.get('req')` instead of plumbing it as an argument.
|
||||
|
||||
Prefer explicit arguments. Reach for `Context` only when the value is truly request-scoped and would otherwise need to thread through many layers.
|
||||
[`Context`](../src/backend/core/context.ts), backed by `AsyncLocalStorage`, carries per-request state without threading it through every function signature. The request-context middleware opens a scope per request after the auth probe runs, so anything inside a request handler can call `Context.get('actor')` / `Context.get('req')`. It's used sparingly, mostly for `actor` and `req`; see the [Context rule](../AGENTS.md#backend).
|
||||
|
||||
## Actors
|
||||
|
||||
An [`Actor`](../src/backend/core/actor.ts) is who a request is acting as. It has two app-shaped fields and they are not interchangeable: `app` is the app the actor carries *directly* (an app-under-user token), while `effectiveApp` is the app it ultimately acts as — its own, or the one that issued its access token. An access-token actor has no `app` of its own, so a gate that reads `app` answers "no app" for it and falls open.
|
||||
An [`Actor`](../src/backend/core/actor.ts) is who a request is acting as. Its two app fields are not interchangeable: `app` is the app the actor carries *directly* (an app-under-user token), while `effectiveApp` is the app it ultimately acts as — its own, or the one that issued its access token. An access-token actor has no `app` of its own, so a gate that reads `app` answers "no app" for it and falls open.
|
||||
|
||||
`makeActor` resolves `effectiveApp` once at construction, and `assertResolvedActor` at the request edge rejects any actor that skipped it — so reading `effectiveApp` needs no fallback. Reach for `app` only where the direct app genuinely is the question, and say why in a comment.
|
||||
|
||||
@@ -71,11 +67,11 @@ An [`Actor`](../src/backend/core/actor.ts) is who a request is acting as. It has
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions live alongside core ([packages/puter/extensions/](../extensions/)) and parallel the layered stack. They are meant for **non-crucial parts of the system** — things Puter still works without if removed.
|
||||
Extensions live in [extensions/](../extensions/), parallel the layered stack, and hold the parts of the system Puter still works without. [AGENTS.md](../AGENTS.md#extensions) has the rule for what belongs in one.
|
||||
|
||||
- **Good extensions**: [thumbnails](../extensions/thumbnails.ts), [serverInfo](../extensions/serverInfo.ts), [devWatcher](../extensions/devWatcher.ts) — opt-in features cleanly bolted on.
|
||||
- **Should probably be core**: [metering](../extensions/metering.ts), [appTelemetry](../extensions/appTelemetry.ts) — clients now expect these to be present, so the "extension" framing is misleading.
|
||||
- **Shouldn't have been an extension**: [whoami](../extensions/whoami.ts) — it's load-bearing for every authenticated client. Keep this one in mind as a cautionary example when deciding whether something belongs in an extension.
|
||||
- **Shouldn't have been an extension**: [whoami](../extensions/whoami.ts) — it's load-bearing for every authenticated client.
|
||||
|
||||
### Extension API
|
||||
|
||||
@@ -98,11 +94,11 @@ import { extension } from '@heyputer/backend/src/extensions';
|
||||
const services = extension.import('service');
|
||||
|
||||
extension.get('/healthcheck/deep', { subdomain: 'api', adminOnly: true }, async (_req, res) => {
|
||||
res.json({ ok: await services.health.runDeepCheck() });
|
||||
res.json(await services.health.getStatus());
|
||||
});
|
||||
|
||||
extension.on('user.signup', (_key, data) => {
|
||||
console.log('new user', data.user.username);
|
||||
extension.on('puter.signup.success', (_key, data) => {
|
||||
console.log('new user', data.username);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -117,10 +113,3 @@ extension.on('app.recommended', (_key, data) => {
|
||||
data.appNames = ['editor', 'camera'];
|
||||
});
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript preferred** in new code where feasible. Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **`camelCase`** for variable/function names; **`PascalCase`** for classes and for files that contain a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
- **Deduplicate**. If two services need the same logic, lift it into a util/helper rather than calling sideways across the same layer — services should not depend on other services for code reuse.
|
||||
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you find yourself wanting to, that's usually a signal the abstraction is wrong.
|
||||
+14
-29
@@ -12,21 +12,7 @@ Every change is backward compatible unless a maintainer has explicitly agreed to
|
||||
|
||||
## Core or extension?
|
||||
|
||||
The first decision is where the API lives.
|
||||
|
||||
If the API is **not crucial to core functionality — nothing in core will call it — prefer an extension** over wiring it into core. Extensions live in [extensions/](../extensions/), parallel the core layered stack, and reach core through `extension.import(...)`:
|
||||
|
||||
```js
|
||||
const services = extension.import('service');
|
||||
const stores = extension.import('store');
|
||||
|
||||
extension.registerDriver('myFeature', MyFeatureDriver); // first-class driver
|
||||
extension.post('/my-feature/frobnicate', opts, handler); // or plain routes
|
||||
```
|
||||
|
||||
Follow the same layered structure inside the extension (driver/controller → service → store) unless it genuinely only needs a couple of route handlers — then the lightweight `extension.get/post/...` helpers are enough on their own.
|
||||
|
||||
The test is the direction of dependency: Puter must still work with the extension removed. The moment core needs to call your API, it belongs in core — see [whoami](../extensions/whoami.ts) for the cautionary example of a load-bearing "extension".
|
||||
The first decision is where the API lives. If **nothing in core will call it, prefer an extension** over wiring it into core. [AGENTS.md](../AGENTS.md#extensions) has the rule and its test; [architecture.md](architecture.md#extension-api) documents the `extension` API.
|
||||
|
||||
## Adding a new API
|
||||
|
||||
@@ -35,13 +21,13 @@ Work through all seven steps; the PR is complete when every one is.
|
||||
### 1. Design the surface first
|
||||
|
||||
- Sketch the signature, options, return shape, and error cases before writing code. Find the two or three most similar existing APIs and match their conventions.
|
||||
- New parameter and field names are `camelCase`. (Existing `snake_case` names stay where they already exist.)
|
||||
- New parameter and field names follow the [naming rule](../AGENTS.md#language--files) (`camelCase`).
|
||||
- Anything returning a list follows the [pagination convention](pagination.md): `limit`/`cursor` in, `{ items, cursor, total }` envelope out.
|
||||
- Prefer an options object over a growing list of positional parameters, but keep the common case callable with a single argument where siblings do.
|
||||
|
||||
### 2. Backend
|
||||
|
||||
Follow the layered stack ([architecture.md](architecture.md)): a controller or driver at the edge, business logic in a service, persistence in a store. The edge parses and validates input and applies gates; services assume the caller is already authorized. Return exactly what the caller needs and no more — every response field you ship is permanent — and use stable `snake_case` error codes.
|
||||
Follow the layered stack ([architecture.md](architecture.md), [layer rules](../AGENTS.md#backend)): a controller or driver at the edge, business logic in a service, persistence in a store. Return exactly what the caller needs and no more — every response field you ship is permanent — and use stable `snake_case` error codes.
|
||||
|
||||
#### Controller or driver?
|
||||
|
||||
@@ -54,10 +40,10 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
|
||||
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
|
||||
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan where `card` is listed (a paying account has a card on file already); otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with the first verifiable factor's code plus `factors`, the verifiable ones in the route's order, so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, for an SMS-provider outage.
|
||||
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
- **Paying accounts only:** `requireSubscription`, as a route option on a controller endpoint or in the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan an extension registers counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` or leaving it out means no requirement. An empty array is a boot error, since it would read as subscribers-only while admitting everyone. The check is a map lookup on the metering service's cached per-actor subscription. It asks which plan the account is on; `requireCredits` asks whether it has budget left. Deployments with no paid plans (self-hosted installs) turn it off with `meteringEnforcement.subscriptions: false`.
|
||||
- **Trusted-enough accounts only:** `requireReputation`, as a route option or in the per-method `@Driver({ requireReputation })` block. Opt-in; implies `requireAuth`. The value names a tier, and `reputationGate.tiers` in config sets the score that tier takes, so a deployment can retune it without editing the surface. A tier the running config doesn't define is inert and everyone passes, so an install that doesn't score its accounts never turns traffic away on a score it never computed. `reputationGate.enabled: false` stops every declared gate at once. Denials are a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet.
|
||||
- **A specific verified factor:** `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. Opt-in, and the factor must actually have been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan when `card` is listed (a paying account already has a card on file). Otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on); with none verifiable the gate is inert rather than locking the route on a self-hosted install. A denial carries the first verifiable factor's code plus `factors` (the verifiable ones, in the route's order), so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, e.g. during an SMS-provider outage.
|
||||
- **Metered spend:** an endpoint that spends metered resources on the caller's behalf (moving file content, object-store requests, anything else the account is billed for) declares `requireCredits: true`, which turns an account with no budget left away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to see what it has, clear it, and reach its billing pages. Drivers have no route options, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
@@ -66,33 +52,32 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
|
||||
### 4. Types
|
||||
|
||||
- Type the method where you wrote it, in JSDoc: `@param`/`@returns` on the implementation, one `@overload` block per accepted call form, and `@typedef {Object}` + `@property` for any new shape. The JSDoc is the source of truth — declarations are generated from it, so there is nothing to keep in sync by hand.
|
||||
- Put a shape more than one file needs in the module's `types.js` (e.g. [src/puter-js/src/modules/kv/types.js](../src/puter-js/src/modules/kv/types.js)); anything shared across modules goes in [src/puter-js/src/lib/types.js](../src/puter-js/src/lib/types.js). A shape with one consumer can stay next to it.
|
||||
- Run `npm run check:puterjs:types` — it generates the declarations and type-checks the published surface without `skipLibCheck`. **Never edit anything under `src/puter-js/types/`**: it is gitignored build output, produced by the SDK build and shipped in the npm tarball, and the next build overwrites it.
|
||||
- Name the new type in [src/puter-js/index.d.ts](../src/puter-js/index.d.ts) if consumers should be able to import it. That file is the one hand-written declaration in the package: it decides what is public and re-exports nothing else.
|
||||
Type the method in JSDoc on the implementation, following the [puter.js type rules](../AGENTS.md#types) (overloads, where shapes live, `index.d.ts`), then run `npm run check:puterjs:types`. Declarations are generated from the JSDoc, so there is nothing to keep in sync by hand.
|
||||
|
||||
### 5. Docs
|
||||
|
||||
- Add the method page at [src/docs/src/](../src/docs/src/)`<Area>/<method>.md` — frontmatter (`title`, `description`, `platforms`), syntax, parameters, return value, and at least one runnable example — and update the area overview (`<Area>.md`). Copy the structure of an existing page.
|
||||
- The docs are the contract users code against: signatures, defaults, and return shapes match the implementation exactly.
|
||||
|
||||
### 6. Tests
|
||||
|
||||
- **Backend:** colocated Vitest tests; prefer the in-memory test server (`setupPuterTestEnv` in [src/backend/testUtil.ts](../src/backend/testUtil.ts)) over mocking.
|
||||
- **SDK:** add cases to [src/puter-js/tests/api/suites/](../src/puter-js/tests/api/suites/)`<area>.suite.ts` (register new suites in `suites/index.ts`). One suite runs on node, browser, and workerd via `npm run test:puterjs` — never write per-platform tests, and rebuild first with `npm run build:workerLib` since the runners execute the built bundle.
|
||||
- **Backend:** see [backend tests](../AGENTS.md#backend-tests).
|
||||
- **SDK:** add cases to [src/puter-js/tests/api/suites/](../src/puter-js/tests/api/suites/)`<area>.suite.ts` (register new suites in `suites/index.ts`; there is no globbing). One suite runs on node, browser, and workerd via `npm run test:puterjs`; never write per-platform tests. The runners execute the built bundle, so run `npm run build:workerLib` first or the suite silently tests stale code.
|
||||
- **Desktop-rendered UI** (`puter.ui.*`): add a Playwright spec per [src/puter-js/TESTING.md](../src/puter-js/TESTING.md).
|
||||
|
||||
### 7. Security pass
|
||||
|
||||
Scan the diff before opening the PR: no internals leaked in errors or logs, no over-broad responses, auth gates present. Flag anything auth-, permission-, or data-export-related in the PR description.
|
||||
Run the [security & privacy](../AGENTS.md#security--privacy) check on the diff, and confirm the auth gates are present.
|
||||
|
||||
## Maintaining an existing API
|
||||
|
||||
Changes are **additive by default**:
|
||||
|
||||
- Existing call signatures keep working, including both positional and options-object forms where a method supports them. Say in the PR how existing callers are unaffected.
|
||||
- New parameters are optional, with defaults that reproduce the old behavior exactly.
|
||||
- Never rename, repurpose, or remove existing parameters, response fields, or error codes. Don't change types, ordering guarantees, or which fields are present when.
|
||||
- New behavior that could surprise existing callers goes behind an opt-in flag.
|
||||
- Docs, types, and tests move in the same PR as the behavior. A signature change with stale docs is a bug — the docs are the contract users code against.
|
||||
- Docs, types, and tests move in the same PR as the behavior; a signature change with stale docs is a bug.
|
||||
- Bug fixes come with a regression test that fails before the fix. Be suspicious of fixes that change observable behavior: someone may depend on the bug. When in doubt, ask a maintainer.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
+7
-6
@@ -28,9 +28,6 @@ Paginated responses are an envelope:
|
||||
`cursor` is still returned. Never use `items.length < limit` as an
|
||||
end-of-list signal.
|
||||
|
||||
New request params are camelCase (`includeTotal`, `fetchUntilFull`).
|
||||
Pre-existing snake_case params stay for compatibility.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Requests without pagination params keep returning the full result in the
|
||||
@@ -48,8 +45,11 @@ The envelope trigger depends on the endpoint's history:
|
||||
## Cursors
|
||||
|
||||
Cursors are opaque base64-encoded JSON, produced and consumed only by the
|
||||
backend (`src/backend/util/pagination.ts`). What a cursor wraps is an
|
||||
implementation detail per store:
|
||||
backend (`src/backend/util/pagination.ts`). A cursor over a global sequence
|
||||
(an auto-increment `id`) is sealed with `sealCursor`/`openCursor` instead, so
|
||||
two pages can't be decoded into how fast the table grows; `openCursor` still
|
||||
reads a plain cursor. What a cursor wraps is an implementation detail per
|
||||
store:
|
||||
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
|
||||
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
|
||||
@@ -91,7 +91,8 @@ hit, the response simply carries a cursor — still convention-legal.
|
||||
## Adding pagination to a new endpoint
|
||||
|
||||
1. Use `encodeCursor`/`decodeCursor`/`normalizeLimit`/`normalizeOffset` from
|
||||
`src/backend/util/pagination.ts`.
|
||||
`src/backend/util/pagination.ts` — `sealCursor`/`openCursor` when the
|
||||
cursor wraps a global id.
|
||||
2. Push equality filters into the query so pages and counts operate on the
|
||||
true result set; only genuinely per-actor filtering may remain post-query
|
||||
(short-pages rule covers it).
|
||||
|
||||
+19
-26
@@ -216,7 +216,7 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
|
||||
```
|
||||
…and update the S3 public endpoint:
|
||||
```json
|
||||
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
|
||||
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.localhost", ... } }
|
||||
```
|
||||
|
||||
## Running behind your own reverse proxy
|
||||
@@ -263,15 +263,15 @@ Healthy startup:
|
||||
|
||||
```
|
||||
[config] override from /etc/puter/config.json
|
||||
[mysql] running migrations from /opt/puter/dist/src/backend/clients/database/migrations/mysql: 2 file(s)
|
||||
[mysql] running migrations from /opt/puter/dist/src/backend/clients/database/migrations/mysql: <n> file(s)
|
||||
[mysql] applied mysql_mig_1.sql (...)
|
||||
[mysql] applied mysql_mig_2.sql (9 statements)
|
||||
...
|
||||
```
|
||||
|
||||
Then open **<https://puter.local>** (or `http://` if you skipped TLS). Login is `admin` — the temp password is printed once in the puter container logs on first boot:
|
||||
Then open **<https://puter.localhost>** (or `http://` if you skipped TLS). Login is `admin`; its temporary password is printed in the puter container logs on every boot until you change it:
|
||||
|
||||
```bash
|
||||
docker compose logs puter | grep tmp_password
|
||||
docker compose logs puter | grep "password for admin"
|
||||
```
|
||||
|
||||
Change it in Settings after first login.
|
||||
@@ -394,14 +394,14 @@ Full provider list (chat, image, video, TTS, OCR) is in the template.
|
||||
|
||||
### Per-user storage quota
|
||||
|
||||
Default is 100 MB per user.
|
||||
Storage is unlimited by default (bounded by host disk). To cap each user:
|
||||
|
||||
```json
|
||||
"storage_capacity": 5368709120, // 5 GB
|
||||
"is_storage_limited": true
|
||||
```
|
||||
|
||||
Set `is_storage_limited: false` for unlimited (bounded by host disk).
|
||||
`storage_capacity` defaults to 100 MB.
|
||||
|
||||
### Usage metering and budgets
|
||||
|
||||
@@ -413,8 +413,8 @@ deletion, stay available so an account can always see what it has and clear it.
|
||||
|
||||
On a self-hosted install this is almost certainly not what you want. There is
|
||||
nowhere to buy more, so accounts are held to the free monthly allowance
|
||||
(US$0.25 of measured cost — roughly 2 GiB of downloads) and start being refused
|
||||
after that. Turn it off:
|
||||
(US$0.50 of measured cost for a registered account, roughly 2.5 GiB of
|
||||
downloads) and start being refused after that. Turn it off:
|
||||
|
||||
```json
|
||||
"unlimitedMetering": true
|
||||
@@ -424,12 +424,6 @@ Every account then resolves to an unlimited policy. Usage is still recorded, so
|
||||
the dashboard still shows what is being consumed; nothing is ever refused for
|
||||
lack of budget.
|
||||
|
||||
For API testing, you can also set `"unlimitedMetering": true` in your
|
||||
ignored runtime config: `puter/config/config.json` for Docker, or the repository's
|
||||
`config.json` for `npm start`. Merge it into the existing config and restart Puter.
|
||||
This includes guest accounts and applies to the whole local instance. Keep normal
|
||||
metering settings when testing budget or subscription enforcement.
|
||||
|
||||
To keep the budgets but stop them blocking anything — recording only:
|
||||
|
||||
```json
|
||||
@@ -470,9 +464,10 @@ temporary or permanent one.
|
||||
"disable_user_signup": true
|
||||
```
|
||||
|
||||
### Block disposable email TLDs
|
||||
### Block disposable email domains
|
||||
|
||||
Only enforced when `env: "prod"`.
|
||||
Addresses on these domains (suffix match, so subdomains too) can't sign up or be
|
||||
set as an account's email.
|
||||
|
||||
```json
|
||||
"blockedEmailDomains": ["mailinator.com", "tempmail.com", "guerrillamail.com"]
|
||||
@@ -623,12 +618,10 @@ services:
|
||||
```
|
||||
|
||||
If that file already exists, merge these settings into its `puter` service.
|
||||
Compose loads and merges the override automatically. `build.context: .` selects
|
||||
this checkout, and `pull_policy: never` prevents pulling the published Puter image.
|
||||
|
||||
Use this override for local build settings instead of editing `docker-compose.yml`.
|
||||
The override is ignored by Git, keeping local configuration out of pull requests
|
||||
and avoiding conflicts in the shared Compose file when pulling updates.
|
||||
Compose merges the override automatically: `build.context: .` builds this
|
||||
checkout, and `pull_policy: never` stops it pulling the published image. Git
|
||||
ignores the override, so local build settings stay out of pull requests and
|
||||
don't conflict with `docker-compose.yml` when you pull updates.
|
||||
|
||||
From the repository root, build and start the stack:
|
||||
|
||||
@@ -667,11 +660,11 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
|
||||
**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`).
|
||||
|
||||
**Login screen says "admin password not set".**
|
||||
First-boot temp password is logged once. Find it: `docker compose logs puter | grep "tmp_password"`. After login, change it in Settings.
|
||||
**Can't find the admin password.**
|
||||
It's logged on every boot until you change it: `docker compose logs puter | grep "password for admin"`. After login, change it in Settings.
|
||||
|
||||
**Healthcheck reports unhealthy but the site works.**
|
||||
The healthcheck hits `puter.localhost:4100/test` from inside the container. If you changed `domain` or `port`, the check still uses defaults. The site itself is fine.
|
||||
The compose healthcheck requests `http://localhost:4100/` from inside the container. If you changed `port` or set `allow_all_host_values: false`, the check fails while the site itself is fine.
|
||||
|
||||
**Nothing resolves at `puter.example.com` after DNS changes.**
|
||||
DNS propagates slowly. `dig puter.example.com` and `dig api.puter.example.com` should both return your server IP. If not, give it 5–60 minutes.
|
||||
|
||||
+23
-51
@@ -1,5 +1,4 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import type { Actor } from '@heyputer/backend/src/core/actor';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
|
||||
import type {
|
||||
@@ -133,51 +132,30 @@ export class AppTelemetryDriver extends PuterDriver {
|
||||
const owner = await this.stores.user.getById(ownerId);
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) throw new HttpError(401, 'Authentication required');
|
||||
const ownsApp = await this.services.permission
|
||||
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
// `/drivers/call` requires auth, so there is always an actor.
|
||||
const actor = Context.get('actor')!;
|
||||
let ownsApp = false;
|
||||
try {
|
||||
ownsApp = await this.services.permission.check(
|
||||
actor,
|
||||
`apps-of-user:${owner.uuid}:write`,
|
||||
);
|
||||
} catch {
|
||||
// A failed check is a denial.
|
||||
}
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
const appId = (app as { id: number }).id;
|
||||
|
||||
const users = (await this.clients.db.read(
|
||||
`SELECT u.id, u.username, u.uuid, u.email FROM user_to_app_permissions p
|
||||
INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[appId, safeLimit, safeOffset],
|
||||
)) as Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
uuid: string;
|
||||
email: string | null;
|
||||
}>;
|
||||
|
||||
// Only surface a user's email if *that user* granted this app the
|
||||
// `user:<their-uuid>:email:read` permission — the same grant
|
||||
// `puter.perms.requestEmail()` obtains and `whoami` honours. This is a
|
||||
// per-user check keyed on the app (not the calling owner-actor): a
|
||||
// user may have authenticated into the app without sharing their
|
||||
// email. Resolve the whole page in one query.
|
||||
const emailPermitted = new Set<number>();
|
||||
if (users.length > 0) {
|
||||
const permStrings = users.map((u) => `user:${u.uuid}:email:read`);
|
||||
const placeholders = permStrings.map(() => '?').join(', ');
|
||||
const grants = (await this.clients.db.read(
|
||||
`SELECT user_id FROM user_to_app_permissions
|
||||
WHERE app_id = ? AND permission IN (${placeholders})`,
|
||||
[appId, ...permStrings],
|
||||
)) as Array<{ user_id: number }>;
|
||||
for (const g of grants) emailPermitted.add(g.user_id);
|
||||
}
|
||||
|
||||
return users.map((e) =>
|
||||
emailPermitted.has(e.id)
|
||||
? { user: e.username, user_uuid: e.uuid, user_email: e.email }
|
||||
: { user: e.username, user_uuid: e.uuid },
|
||||
// An email surfaces only for a user who granted *this app*
|
||||
// `user:<their-uuid>:email:read` — the grant `puter.perms.requestEmail()`
|
||||
// obtains and `whoami` honours. Authenticating alone doesn't share it.
|
||||
const users = await this.stores.permission.listAppAuthenticatedUsers(
|
||||
(app as { id: number }).id,
|
||||
{ limit: safeLimit, offset: safeOffset },
|
||||
);
|
||||
return users.map((u) =>
|
||||
u.emailShared
|
||||
? { user: u.username, user_uuid: u.uuid, user_email: u.email }
|
||||
: { user: u.username, user_uuid: u.uuid },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,13 +168,7 @@ export class AppTelemetryDriver extends PuterDriver {
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
const [row] = (await this.clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as { id: number }).id],
|
||||
)) as Array<{ n: number }>;
|
||||
|
||||
return row?.n ?? 0;
|
||||
return this.stores.permission.countAppUsers((app as { id: number }).id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,15 +74,6 @@ const grantInstalled = async (appId: number, userId: number) => {
|
||||
};
|
||||
|
||||
describe('installedApps extension — handleInstalledApps', () => {
|
||||
it('throws HttpError(401) when no actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleInstalledApps(makeReq({}), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when orderBy is not in the allowlist', async () => {
|
||||
const user = await seedUser();
|
||||
const { res } = makeRes();
|
||||
|
||||
+16
-43
@@ -2,68 +2,41 @@ import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import {
|
||||
INSTALLED_APPS_ORDERS,
|
||||
type InstalledAppsOrder,
|
||||
} from '@heyputer/backend/src/stores/permission/PermissionStore.js';
|
||||
import {
|
||||
getAppIconCdnUrl,
|
||||
getAppIconUrl,
|
||||
} from '@heyputer/backend/src/util/appIcon.js';
|
||||
|
||||
const clients = extension.import('client');
|
||||
|
||||
const ALLOWED_ORDER_BY = [
|
||||
'id',
|
||||
'name',
|
||||
'uid',
|
||||
'title',
|
||||
'installed_at',
|
||||
] as const;
|
||||
const ORDER_BY_FIELD_MAP: Record<string, string> = {
|
||||
id: 'apps.id',
|
||||
name: 'apps.name',
|
||||
uid: 'apps.uid',
|
||||
title: 'apps.title',
|
||||
installed_at: 'installed_at',
|
||||
};
|
||||
const stores = extension.import('store');
|
||||
|
||||
export const handleInstalledApps = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user?.id) throw new HttpError(401, 'Authentication required');
|
||||
// Behind `requireUserActor`, so there is always an actor.
|
||||
const actor = Context.get('actor')!;
|
||||
|
||||
const orderBy = String(req.query.orderBy ?? 'installed_at');
|
||||
if (!(ALLOWED_ORDER_BY as readonly string[]).includes(orderBy)) {
|
||||
const orderBy = String(
|
||||
req.query.orderBy ?? 'installed_at',
|
||||
) as InstalledAppsOrder;
|
||||
if (!INSTALLED_APPS_ORDERS.includes(orderBy)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Invalid orderBy. Allowed: ${ALLOWED_ORDER_BY.join(', ')}`,
|
||||
`Invalid orderBy. Allowed: ${INSTALLED_APPS_ORDERS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const page = Math.max(Number(req.query.page) || 1, 1);
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 100);
|
||||
const offset = (page - 1) * limit;
|
||||
const orderByField = ORDER_BY_FIELD_MAP[orderBy];
|
||||
const sortDirection = req.query.desc ? 'DESC' : 'ASC';
|
||||
|
||||
const installedApps = (await clients.db.read(
|
||||
`SELECT
|
||||
apps.name,
|
||||
apps.uid,
|
||||
apps.title,
|
||||
apps.description,
|
||||
apps.icon,
|
||||
apps.index_url,
|
||||
apps.owner_user_id,
|
||||
MIN(perm.dt) AS installed_at
|
||||
FROM apps
|
||||
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
|
||||
WHERE perm.user_id = ?
|
||||
GROUP BY apps.id, apps.name, apps.uid, apps.title, apps.description
|
||||
ORDER BY ${orderByField} ${sortDirection}
|
||||
LIMIT ?
|
||||
OFFSET ?`,
|
||||
[actor.user.id, limit, offset],
|
||||
)) as Array<Record<string, unknown>>;
|
||||
const installedApps = await stores.permission.listAppsGrantedByUser(
|
||||
actor.user.id!,
|
||||
{ orderBy, descending: Boolean(req.query.desc), limit, offset },
|
||||
);
|
||||
|
||||
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
||||
res.json(
|
||||
|
||||
@@ -63,15 +63,6 @@ const seedUser = async () => {
|
||||
};
|
||||
|
||||
describe('metering extension — handleMeteringUsage', () => {
|
||||
it('throws HttpError(401) when no user actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleMeteringUsage(makeReq(), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('returns usage details merged with allowanceInfo for an authenticated user', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -166,18 +157,6 @@ describe('metering extension — handleMeteringUsage', () => {
|
||||
});
|
||||
|
||||
describe('metering extension — handleMeteringUsageForApp', () => {
|
||||
it('throws HttpError(401) when no user actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleMeteringUsageForApp(
|
||||
makeReq({ params: { appIdOrName: 'any' } }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when no appId is supplied', async () => {
|
||||
const user = await seedUser();
|
||||
const { res } = makeRes();
|
||||
|
||||
+8
-14
@@ -14,7 +14,7 @@ import {
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const services = extension.import('service');
|
||||
const clients = extension.import('client');
|
||||
const stores = extension.import('store');
|
||||
|
||||
// Cached on first request — the underlying cost catalogues are baked into
|
||||
// driver/controller source so they only change on deploy.
|
||||
@@ -95,8 +95,8 @@ export const handleMeteringUsage = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
||||
// Behind `requireAuth`, so there is always an actor.
|
||||
const actor = Context.get('actor')!;
|
||||
|
||||
const [actorUsage, allowanceInfo] = await Promise.all([
|
||||
services.metering.getActorCurrentMonthUsageDetails(actor),
|
||||
@@ -146,8 +146,8 @@ export const handleMeteringUsageForApp = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
||||
// Behind `requireAuth`, so there is always an actor.
|
||||
const actor = Context.get('actor')!;
|
||||
|
||||
let appId = String(req.params.appIdOrName ?? '');
|
||||
if (!appId) throw new HttpError(400, 'appId parameter is required');
|
||||
@@ -155,15 +155,9 @@ export const handleMeteringUsageForApp = async (
|
||||
// If not a UUID-shaped app UID or the global sentinel, look up by name.
|
||||
// Which apps an actor may read is MeteringService's call, not this route's.
|
||||
if (!appId.startsWith('app-') && appId !== GLOBAL_APP_KEY) {
|
||||
const appRows = (await clients.db.read(
|
||||
'SELECT `uid` FROM `apps` WHERE `name` = ? LIMIT 1',
|
||||
[appId],
|
||||
)) as Array<{ uid: string }>;
|
||||
if (appRows.length > 0) {
|
||||
appId = appRows[0].uid;
|
||||
} else {
|
||||
throw new HttpError(404, 'App not found');
|
||||
}
|
||||
const app = await stores.app.getByName(appId);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
appId = app.uid;
|
||||
}
|
||||
|
||||
const appUsage =
|
||||
|
||||
+342
-57
@@ -2,7 +2,7 @@ import {
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import type { Request, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
@@ -304,8 +304,6 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
let s3: S3Client;
|
||||
let s3Presign: S3Client;
|
||||
|
||||
const stubDb = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
s3 = server.clients.s3.get();
|
||||
@@ -344,7 +342,7 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
|
||||
expect(typeof entry.thumbnail).toBe('string');
|
||||
@@ -383,7 +381,7 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
expect(entry.thumbnail).toBeNull();
|
||||
});
|
||||
@@ -398,7 +396,7 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
// Signed for OUR bucket; `attacker-named-bucket` never reached S3.
|
||||
const signed = entry.thumbnail as string;
|
||||
@@ -414,7 +412,7 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
expect(entry.thumbnail).toBe('about:blank');
|
||||
});
|
||||
@@ -426,43 +424,157 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
expect(entry.thumbnail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates an inline data: URL by uploading to S3 and updating the DB row', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
const entry: Record<string, unknown> = {
|
||||
uuid: entryUuid,
|
||||
thumbnail: `data:image/png;base64,${TINY_PNG_BASE64}`,
|
||||
};
|
||||
// -- inline data: URL migration --
|
||||
|
||||
// A file whose thumbnail was stored inline, as rows from before storage
|
||||
// pointers are. Creating it leaves it in the entry cache.
|
||||
const inlineThumbnailEntry = async (thumbnail: string) => {
|
||||
const username = `thumb-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
user,
|
||||
);
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
return server.stores.fsEntry.createNonFileEntry({
|
||||
parent: documents,
|
||||
name: 'legacy.png',
|
||||
kind: 'empty-file',
|
||||
thumbnail,
|
||||
});
|
||||
};
|
||||
|
||||
// Passes every command through, keeping the keys of the uploads.
|
||||
const recordingClient = () => {
|
||||
const putKeys: string[] = [];
|
||||
const client = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof PutObjectCommand) {
|
||||
putKeys.push(command.input.Key!);
|
||||
}
|
||||
return s3.send(command as never);
|
||||
},
|
||||
} as unknown as S3Client;
|
||||
return { client, putKeys };
|
||||
};
|
||||
|
||||
// What a listing does: read the entry through the store, then sign it.
|
||||
const readThumbnail = async (uuid: string, client: S3Client) => {
|
||||
const stored = (await server.stores.fsEntry.getEntryByUuid(uuid))!;
|
||||
const entry: Record<string, unknown> = {
|
||||
uuid,
|
||||
thumbnail: stored.thumbnail,
|
||||
};
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3: client,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
bucketEndpoint: BUCKET_ENDPOINT,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
return entry.thumbnail;
|
||||
};
|
||||
|
||||
// The handler should have replaced the data URL with a signed
|
||||
// S3 URL and kicked off the DB migration write.
|
||||
expect(typeof entry.thumbnail).toBe('string');
|
||||
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
|
||||
// Allow the best-effort write microtask to settle.
|
||||
await Promise.resolve();
|
||||
expect(stubDb.write).toHaveBeenCalledWith(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[
|
||||
expect.stringMatching(
|
||||
new RegExp(`^s3://${BUCKET}/thumbnails/${entryUuid}/`),
|
||||
),
|
||||
entryUuid,
|
||||
],
|
||||
it('migrates an inline data: URL to storage once, then serves the stored pointer', async () => {
|
||||
const entry = await inlineThumbnailEntry(
|
||||
`data:image/png;base64,${TINY_PNG_BASE64}`,
|
||||
);
|
||||
const { client, putKeys } = recordingClient();
|
||||
|
||||
const first = await readThumbnail(entry.uuid, client);
|
||||
const second = await readThumbnail(entry.uuid, client);
|
||||
|
||||
expect(putKeys).toHaveLength(1);
|
||||
const [key] = putKeys;
|
||||
expect(key).toMatch(new RegExp(`^thumbnails/${entry.uuid}/`));
|
||||
const pointer = `s3://${BUCKET}/${key}`;
|
||||
// The cached entry and the row both hold the new pointer.
|
||||
expect(
|
||||
(await server.stores.fsEntry.getEntryByUuid(entry.uuid))!.thumbnail,
|
||||
).toBe(pointer);
|
||||
expect(
|
||||
(await server.stores.fsEntry.getEntryByUuidFromPrimary(entry.uuid))!
|
||||
.thumbnail,
|
||||
).toBe(pointer);
|
||||
for (const signed of [first, second]) {
|
||||
expect(signed).toMatch(/^http/);
|
||||
expect(signed).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps one object when reads of the same entry migrate it concurrently', async () => {
|
||||
const thumbnail = `data:image/png;base64,${TINY_PNG_BASE64}`;
|
||||
const entry = await inlineThumbnailEntry(thumbnail);
|
||||
const { client, putKeys } = recordingClient();
|
||||
const read = async () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
uuid: entry.uuid,
|
||||
thumbnail,
|
||||
};
|
||||
await handleThumbnailRead(payload, {
|
||||
s3: client,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: BUCKET_ENDPOINT,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
return payload.thumbnail;
|
||||
};
|
||||
|
||||
await Promise.all([read(), read()]);
|
||||
|
||||
expect(putKeys).toHaveLength(2);
|
||||
const stored = (await server.stores.fsEntry.getEntryByUuidFromPrimary(
|
||||
entry.uuid,
|
||||
))!.thumbnail;
|
||||
const winner = putKeys.find(
|
||||
(key) => stored === `s3://${BUCKET}/${key}`,
|
||||
);
|
||||
expect(winner).toBeDefined();
|
||||
// The losing read's upload is not left behind.
|
||||
for (const key of putKeys) {
|
||||
expect(await objectExists(s3, key)).toBe(key === winner);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'does not decode to an image',
|
||||
`data:image/png;base64,${Buffer.from('not an image').toString('base64')}`,
|
||||
],
|
||||
['has no payload', 'data:image/png;base64'],
|
||||
])(
|
||||
'does not migrate an inline thumbnail that %s',
|
||||
async (_label, thumbnail) => {
|
||||
const entry = await inlineThumbnailEntry(thumbnail);
|
||||
const { client, putKeys } = recordingClient();
|
||||
|
||||
expect(await readThumbnail(entry.uuid, client)).toBe(thumbnail);
|
||||
|
||||
expect(putKeys).toHaveLength(0);
|
||||
expect(
|
||||
(await server.stores.fsEntry.getEntryByUuidFromPrimary(
|
||||
entry.uuid,
|
||||
))!.thumbnail,
|
||||
).toBe(thumbnail);
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves an inline data: URL as is when there is no entry to bind it to', async () => {
|
||||
const thumbnail = `data:image/png;base64,${TINY_PNG_BASE64}`;
|
||||
const entry: Record<string, unknown> = { thumbnail };
|
||||
@@ -471,7 +583,7 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: BUCKET_ENDPOINT,
|
||||
db: stubDb,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
expect(entry.thumbnail).toBe(thumbnail);
|
||||
});
|
||||
@@ -563,13 +675,38 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const copyNode = async (thumbnail: string | null, copyUuid: string) => {
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
const copyNode = async (
|
||||
thumbnail: string | null,
|
||||
copyUuid: string,
|
||||
client: S3Client = s3,
|
||||
) => {
|
||||
const fsEntry = {
|
||||
updateEntryThumbnailByUuid: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{ copy: { thumbnail, uuid: copyUuid } },
|
||||
{ s3, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT, db },
|
||||
{
|
||||
s3: client,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: BUCKET_ENDPOINT,
|
||||
fsEntry,
|
||||
},
|
||||
);
|
||||
return db;
|
||||
return fsEntry.updateEntryThumbnailByUuid;
|
||||
};
|
||||
|
||||
// The key the copied row was repointed at, asserting it is bound to it.
|
||||
const repointedKey = (
|
||||
update: Awaited<ReturnType<typeof copyNode>>,
|
||||
copyUuid: string,
|
||||
): string => {
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const [uuid, pointer] = update.mock.calls[0] as [string, string];
|
||||
expect(uuid).toBe(copyUuid);
|
||||
expect(pointer).toMatch(
|
||||
new RegExp(`^s3://${BUCKET}/thumbnails/${copyUuid}/`),
|
||||
);
|
||||
return pointer.slice(`s3://${BUCKET}/`.length);
|
||||
};
|
||||
|
||||
it.each([
|
||||
@@ -583,15 +720,17 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
||||
await putObject(s3, sourceKey, body);
|
||||
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const db = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid);
|
||||
const update = await copyNode(
|
||||
`s3://${BUCKET}/${sourceKey}`,
|
||||
copyUuid,
|
||||
);
|
||||
|
||||
// The copied row was repointed at a fresh object of its own...
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
const [, params] = db.write.mock.calls[0] as [
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const [updatedUuid, newPointer] = update.mock.calls[0] as [
|
||||
string,
|
||||
string,
|
||||
[string, string],
|
||||
];
|
||||
const [newPointer, updatedUuid] = params;
|
||||
expect(updatedUuid).toBe(copyUuid);
|
||||
expect(
|
||||
newPointer.startsWith(`s3://${BUCKET}/thumbnails/${copyUuid}/`),
|
||||
@@ -603,6 +742,7 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
||||
const duplicated = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: newKey }),
|
||||
);
|
||||
expect(duplicated.ContentType).toBe('image/png');
|
||||
expect(
|
||||
(await streamToBuffer(duplicated.Body as never)).equals(body),
|
||||
).toBe(true);
|
||||
@@ -610,17 +750,80 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
||||
},
|
||||
);
|
||||
|
||||
// The SDK puts an endpoint's path in front of every Bucket/Key request but
|
||||
// not in front of a CopyObject source, so on such a store HeadObject finds
|
||||
// the source and CopyObject reports NoSuchKey.
|
||||
it('duplicates through a client whose endpoint carries a path', async () => {
|
||||
const endpoint = await s3.config.endpoint!();
|
||||
const client = new S3Client({
|
||||
region: await s3.config.region(),
|
||||
endpoint: `${endpoint.protocol}//${endpoint.hostname}:${endpoint.port}/${BUCKET}`,
|
||||
credentials: await s3.config.credentials(),
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const sourceKey = mintedKey();
|
||||
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
|
||||
await putObject(client, sourceKey, body);
|
||||
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const update = await copyNode(
|
||||
`s3://${BUCKET}/${sourceKey}`,
|
||||
copyUuid,
|
||||
client,
|
||||
);
|
||||
|
||||
const duplicated = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: repointedKey(update, copyUuid),
|
||||
}),
|
||||
);
|
||||
expect(duplicated.ContentType).toBe('image/png');
|
||||
expect(
|
||||
(await streamToBuffer(duplicated.Body as never)).equals(body),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("lets either entry's removal leave the other's thumbnail in place", async () => {
|
||||
const sourceUuid = crypto.randomUUID();
|
||||
const sourceKey = mintedKey(sourceUuid);
|
||||
const sourcePointer = `s3://${BUCKET}/${sourceKey}`;
|
||||
await putObject(s3, sourceKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
const remove = (thumbnail: string, uuid: string) =>
|
||||
handleFsRemoveNodeThumbnail(
|
||||
{ target: { thumbnail, uuid } },
|
||||
{ s3, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT },
|
||||
);
|
||||
|
||||
// Removing a copy leaves the source's object...
|
||||
const firstUuid = crypto.randomUUID();
|
||||
const firstKey = repointedKey(
|
||||
await copyNode(sourcePointer, firstUuid),
|
||||
firstUuid,
|
||||
);
|
||||
await remove(`s3://${BUCKET}/${firstKey}`, firstUuid);
|
||||
expect(await objectExists(s3, firstKey)).toBe(false);
|
||||
expect(await objectExists(s3, sourceKey)).toBe(true);
|
||||
|
||||
// ...and removing the source leaves a copy's.
|
||||
const secondUuid = crypto.randomUUID();
|
||||
const secondKey = repointedKey(
|
||||
await copyNode(sourcePointer, secondUuid),
|
||||
secondUuid,
|
||||
);
|
||||
await remove(sourcePointer, sourceUuid);
|
||||
expect(await objectExists(s3, sourceKey)).toBe(false);
|
||||
expect(await objectExists(s3, secondKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the pointer when the shared object is already gone', async () => {
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const db = await copyNode(
|
||||
const update = await copyNode(
|
||||
`s3://${BUCKET}/${mintedKey()}`, // never uploaded
|
||||
copyUuid,
|
||||
);
|
||||
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = db.write.mock.calls[0] as [string, [string]];
|
||||
expect(sql).toContain('NULL');
|
||||
expect(params).toEqual([copyUuid]);
|
||||
expect(update.mock.calls).toEqual([[copyUuid, null]]);
|
||||
});
|
||||
|
||||
// Only reachable for objects uploaded before upload URLs were size-bound;
|
||||
@@ -630,36 +833,64 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
||||
await putObject(s3, sourceKey, Buffer.alloc(2 * 1024 * 1024 + 1));
|
||||
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const db = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid);
|
||||
const update = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid);
|
||||
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = db.write.mock.calls[0] as [string, [string]];
|
||||
expect(sql).toContain('NULL');
|
||||
expect(params).toEqual([copyUuid]);
|
||||
expect(update.mock.calls).toEqual([[copyUuid, null]]);
|
||||
});
|
||||
|
||||
it('drops the pointer when the object outgrows the bound after its size was checked', async () => {
|
||||
const sourceKey = mintedKey();
|
||||
await putObject(s3, sourceKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
const sent: unknown[] = [];
|
||||
// Replaces the object between the HEAD and the GET.
|
||||
const racing = {
|
||||
send: async (command: unknown) => {
|
||||
sent.push(command);
|
||||
const result = await s3.send(command as never);
|
||||
if (command instanceof HeadObjectCommand) {
|
||||
await putObject(
|
||||
s3,
|
||||
sourceKey,
|
||||
Buffer.alloc(2 * 1024 * 1024 + 1),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
} as unknown as S3Client;
|
||||
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const update = await copyNode(
|
||||
`s3://${BUCKET}/${sourceKey}`,
|
||||
copyUuid,
|
||||
racing,
|
||||
);
|
||||
|
||||
expect(update.mock.calls).toEqual([[copyUuid, null]]);
|
||||
expect(sent.some((c) => c instanceof PutObjectCommand)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not duplicate an object the pointer names but we did not mint', async () => {
|
||||
const foreignKey = crypto.randomUUID(); // shaped like an fs object key
|
||||
const db = await copyNode(
|
||||
const update = await copyNode(
|
||||
`s3://${BUCKET}/${foreignKey}`,
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
expect(db.write).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an external image URL to ride along as is', async () => {
|
||||
const sourceKey = legacyKey();
|
||||
await putObject(s3, sourceKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
const db = await copyNode(
|
||||
const update = await copyNode(
|
||||
`https://cdn.example.com/${BUCKET}/${sourceKey}`,
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
expect(db.write).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when the copy has no thumbnail', async () => {
|
||||
const db = await copyNode(null, crypto.randomUUID());
|
||||
expect(db.write).not.toHaveBeenCalled();
|
||||
const update = await copyNode(null, crypto.randomUUID());
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -828,4 +1059,58 @@ describe('thumbnails extension — signed batch upload through /fs', () => {
|
||||
);
|
||||
expect(entry.thumbnail).toBe('https://example.com/thumb.png');
|
||||
});
|
||||
|
||||
it('gives a copy its own thumbnail that survives moves and the source being removed', async () => {
|
||||
const { actor, username } = await makeActor();
|
||||
const s3 = server.clients.s3.get();
|
||||
const fs = server.services.fs;
|
||||
const { entry: source } = await upload(
|
||||
actor,
|
||||
`/${username}/Documents/photo.png`,
|
||||
(s) => s.thumbnailUrl,
|
||||
);
|
||||
const userId = source.userId;
|
||||
const sourceKey = source.thumbnail!.slice(`s3://${BUCKET}/`.length);
|
||||
const desktop = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Desktop`,
|
||||
))!;
|
||||
// Through the entry cache, which the listener's write refreshes.
|
||||
const storedThumbnail = async (uuid: string) =>
|
||||
(await server.stores.fsEntry.getEntryByUuid(uuid))?.thumbnail ??
|
||||
null;
|
||||
|
||||
const copy = await runWithContext({ actor }, () =>
|
||||
fs.copy(userId, { source, destinationParent: desktop }),
|
||||
);
|
||||
// `fs.copy.node` is fire-and-forget.
|
||||
const copyPointer = await vi.waitFor(async () => {
|
||||
const pointer = await storedThumbnail(copy.uuid);
|
||||
expect(pointer).toMatch(
|
||||
new RegExp(`^s3://${BUCKET}/thumbnails/${copy.uuid}/`),
|
||||
);
|
||||
return pointer!;
|
||||
});
|
||||
const copyKey = copyPointer.slice(`s3://${BUCKET}/`.length);
|
||||
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
const moved = await runWithContext({ actor }, () =>
|
||||
fs.move(userId, {
|
||||
source: copy,
|
||||
destinationParent: documents,
|
||||
newName: 'moved.png',
|
||||
}),
|
||||
);
|
||||
expect(moved.uuid).toBe(copy.uuid);
|
||||
expect(await storedThumbnail(moved.uuid)).toBe(copyPointer);
|
||||
|
||||
await runWithContext({ actor }, () =>
|
||||
fs.remove(userId, { entry: source }),
|
||||
);
|
||||
await vi.waitFor(async () => {
|
||||
expect(await objectExists(s3, sourceKey)).toBe(false);
|
||||
});
|
||||
expect(await objectExists(s3, copyKey)).toBe(true);
|
||||
});
|
||||
});
|
||||
+62
-35
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
@@ -8,10 +7,15 @@ import {
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import type { FSEntryStore } from '@heyputer/backend/src/stores/fs/FSEntryStore';
|
||||
import { isMissingObjectError } from '@heyputer/backend/src/stores/fs/S3ObjectStore';
|
||||
import crypto from 'node:crypto';
|
||||
import type { Readable } from 'node:stream';
|
||||
import sharp from 'sharp';
|
||||
const clients = extension.import('client');
|
||||
const stores = extension.import('store');
|
||||
|
||||
type ThumbnailEntryStore = Pick<FSEntryStore, 'updateEntryThumbnailByUuid'>;
|
||||
|
||||
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_THUMBNAIL_PIXELS = 64e6;
|
||||
@@ -135,13 +139,6 @@ function getPresignClient(): S3Client {
|
||||
return resolveClients().presign;
|
||||
}
|
||||
|
||||
function base64ParseDataUrl(dataURL: string) {
|
||||
dataURL = dataURL.slice(5);
|
||||
const mimeType = dataURL.split(';')[0];
|
||||
const data = Buffer.from(dataURL.split(',')[1], 'base64');
|
||||
return { mimeType, data };
|
||||
}
|
||||
|
||||
// Strictly decode a data: URL and validate the decoded image. Encoded-string
|
||||
// length lies about decoded byte count (whitespace, padding) and says nothing
|
||||
// about pixel count — a 2MB PNG can decompress to hundreds of MB of raster.
|
||||
@@ -304,7 +301,7 @@ export const handleThumbnailRead = async (
|
||||
s3Presign: S3Client;
|
||||
bucketName: string;
|
||||
bucketEndpoint: string;
|
||||
db: { write: (sql: string, params: unknown[]) => Promise<unknown> };
|
||||
fsEntry: ThumbnailEntryStore;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const thumb = entry.thumbnail;
|
||||
@@ -325,32 +322,52 @@ export const handleThumbnailRead = async (
|
||||
{ expiresIn: 604800 },
|
||||
);
|
||||
} else if (thumb.startsWith('data')) {
|
||||
// Inline data-URL migration: upload to S3 and update the DB entry.
|
||||
// Move an inline thumbnail into storage. The row only takes the new
|
||||
// pointer while it still holds this data URL, so of concurrent reads
|
||||
// one wins and the rest discard their upload.
|
||||
const uuid = entry.uuid ?? entry.uid;
|
||||
if (!isUuid(uuid)) return;
|
||||
// Same bar as an upload; anything else stays inline, unmigrated.
|
||||
const decoded = await decodeAndValidateThumbnail(thumb);
|
||||
if (!decoded) return;
|
||||
const key = mintThumbnailKey(uuid);
|
||||
const { mimeType, data } = base64ParseDataUrl(thumb);
|
||||
const newUrl = `s3://${deps.bucketName}/${key}`;
|
||||
|
||||
await deps.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: deps.bucketName,
|
||||
Key: key,
|
||||
Body: data,
|
||||
ContentType: mimeType,
|
||||
Body: decoded.data,
|
||||
ContentType: decoded.mimeType,
|
||||
}),
|
||||
);
|
||||
|
||||
// Best-effort async DB update
|
||||
if (uuid) {
|
||||
deps.db
|
||||
.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[newUrl, uuid],
|
||||
)
|
||||
.catch((err: unknown) =>
|
||||
console.warn('[thumbnails] inline migration failed', err),
|
||||
let migrated: boolean;
|
||||
try {
|
||||
migrated = await deps.fsEntry.updateEntryThumbnailByUuid(
|
||||
uuid,
|
||||
`s3://${deps.bucketName}/${key}`,
|
||||
thumb,
|
||||
);
|
||||
} catch (err) {
|
||||
// The row may or may not point at the object now, so keep it.
|
||||
console.warn('[thumbnails] inline migration failed', err);
|
||||
return;
|
||||
}
|
||||
if (!migrated) {
|
||||
try {
|
||||
await deps.s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: deps.bucketName,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[thumbnails] failed to remove unused thumbnail',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
entry.thumbnail = await getSignedUrl(
|
||||
@@ -367,7 +384,7 @@ export const handleFsCopyNodeThumbnail = async (
|
||||
s3: S3Client;
|
||||
bucketName: string;
|
||||
bucketEndpoint: string;
|
||||
db: { write: (sql: string, params: unknown[]) => Promise<unknown> };
|
||||
fsEntry: ThumbnailEntryStore;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const copy = payload.copy;
|
||||
@@ -396,27 +413,37 @@ export const handleFsCopyNodeThumbnail = async (
|
||||
) {
|
||||
throw new Error('thumbnail exceeds the size bound');
|
||||
}
|
||||
// Read and rewrite rather than CopyObject: a copy source skips any path
|
||||
// in the client's endpoint, so it can miss an object Bucket/Key reach.
|
||||
const source = await deps.s3.send(
|
||||
new GetObjectCommand({ Bucket: deps.bucketName, Key: sourceKey }),
|
||||
);
|
||||
// The object can be replaced after the HEAD; bound what gets buffered.
|
||||
if ((source.ContentLength ?? 0) > MAX_THUMBNAIL_BYTES) {
|
||||
(source.Body as Readable | undefined)?.destroy();
|
||||
throw new Error('thumbnail exceeds the size bound');
|
||||
}
|
||||
const body = await source.Body?.transformToByteArray();
|
||||
if (!body) throw new Error('thumbnail has no body');
|
||||
await deps.s3.send(
|
||||
new CopyObjectCommand({
|
||||
new PutObjectCommand({
|
||||
Bucket: deps.bucketName,
|
||||
CopySource: `${deps.bucketName}/${sourceKey}`,
|
||||
Key: newKey,
|
||||
Body: body,
|
||||
ContentType: source.ContentType,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// The shared object is gone or oversized — drop the pointer rather
|
||||
// than leave the row advertising a thumbnail it doesn't have.
|
||||
await deps.db.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = NULL WHERE `uuid` = ?',
|
||||
[copy.uuid],
|
||||
);
|
||||
await deps.fsEntry.updateEntryThumbnailByUuid(copy.uuid, null);
|
||||
console.warn('[thumbnails] failed to duplicate thumbnail on copy', err);
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.db.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[`s3://${deps.bucketName}/${newKey}`, copy.uuid],
|
||||
await deps.fsEntry.updateEntryThumbnailByUuid(
|
||||
copy.uuid,
|
||||
`s3://${deps.bucketName}/${newKey}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -472,7 +499,7 @@ extension.on('thumbnail.read', async (_key, entry: Record<string, unknown>) => {
|
||||
s3Presign: getPresignClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
bucketEndpoint: extensionBucketEndpoint,
|
||||
db: clients.db,
|
||||
fsEntry: stores.fsEntry,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -489,7 +516,7 @@ extension.on('fs.copy.node', async (_key, payload) => {
|
||||
s3: getClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
bucketEndpoint: extensionBucketEndpoint,
|
||||
db: clients.db,
|
||||
fsEntry: stores.fsEntry,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+12
-228
@@ -1,11 +1,10 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import {
|
||||
cardFallbackDepsFrom,
|
||||
isCardFallbackEligible,
|
||||
} from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import {
|
||||
buildUserDetails,
|
||||
scrubSensitive,
|
||||
} from '@heyputer/backend/src/util/userDetails.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
import localeEn from 'javascript-time-ago/locale/en';
|
||||
@@ -19,58 +18,6 @@ const timeago = (() => {
|
||||
return new TimeAgo('en-US');
|
||||
})();
|
||||
|
||||
// User timestamps come off the DB as SQL datetime strings; the wire format
|
||||
// for all of them is unix seconds. Unparseable values are dropped rather
|
||||
// than sent as NaN.
|
||||
const toUnixSeconds = (value: unknown): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
const ms = new Date(value as string | number | Date).getTime();
|
||||
return Number.isNaN(ms) ? undefined : Math.round(ms / 1000);
|
||||
};
|
||||
|
||||
// Allowlist of `config.feature_flags` keys safe to surface via /whoami.
|
||||
// Anything not listed here stays server-side, so internal flags
|
||||
// (payment_bypass, staff_only_*, etc.) cannot leak by accident. Add a
|
||||
// flag here when, and only when, the client actually needs to read it.
|
||||
const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
|
||||
'create_shortcut',
|
||||
'download_directory',
|
||||
'prompt_user_when_navigation_away_from_puter',
|
||||
]);
|
||||
|
||||
// Keys that must never leave the server, whoever put them on the response.
|
||||
// `details` is an explicit pick, but the `whoami.details` event hands
|
||||
// listeners the full UserRow next to the object they may write to, and
|
||||
// `metadata` is a free-form blob — so any of these can arrive on the
|
||||
// response without an edit to the pick above. The scrub runs last, over the
|
||||
// whole payload, and is the one place that decides what "sensitive" means.
|
||||
//
|
||||
// Credentials and single-use tokens, the payment/phone identifiers used for
|
||||
// verification (`card_fingerprint` is the Stripe fingerprint, stable per
|
||||
// card number), the network identity recorded at signup, and internal
|
||||
// anti-abuse bookkeeping. `requires_phone_verification` /
|
||||
// `requires_card_verification` stay: they are the flags the GUI acts on, and
|
||||
// they carry no identifier.
|
||||
const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
|
||||
'password',
|
||||
'tmp_password',
|
||||
'pass_recovery_token',
|
||||
'email_confirm_code',
|
||||
'email_confirm_token',
|
||||
'change_email_confirm_token',
|
||||
'otp_secret',
|
||||
'otp_recovery_codes',
|
||||
'card_fingerprint',
|
||||
'phone',
|
||||
'clean_email',
|
||||
'signup_ip',
|
||||
'signup_ip_forwarded',
|
||||
'signup_user_agent',
|
||||
'signup_origin',
|
||||
'signup_server',
|
||||
'audit_metadata',
|
||||
]);
|
||||
|
||||
// What an app actor may receive from `whoami.details` listeners. Anything
|
||||
// else a listener adds describes the account, and only ships to user actors.
|
||||
const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
|
||||
@@ -78,35 +25,6 @@ const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
|
||||
'paid_storage',
|
||||
]);
|
||||
|
||||
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
|
||||
// at any level (`metadata` and `taskbar_items` are both nested structures).
|
||||
const scrubSensitive = (
|
||||
value: unknown,
|
||||
seen: Set<object> = new Set(),
|
||||
depth = 0,
|
||||
): void => {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') return;
|
||||
if (seen.has(value as object)) return;
|
||||
seen.add(value as object);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) scrubSensitive(entry, seen, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
delete (value as Record<string, unknown>)[key];
|
||||
continue;
|
||||
}
|
||||
scrubSensitive(
|
||||
(value as Record<string, unknown>)[key],
|
||||
seen,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleWhoami = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -124,7 +42,6 @@ export const handleWhoami = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const oidcOnly = user.password === null;
|
||||
const rawIconSize =
|
||||
typeof req.query?.icon_size === 'string'
|
||||
? Number(req.query.icon_size)
|
||||
@@ -133,148 +50,15 @@ export const handleWhoami = async (
|
||||
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
|
||||
? rawIconSize
|
||||
: undefined;
|
||||
const noIcons = !iconSize;
|
||||
|
||||
// Feature flags come from `config.feature_flags`. We only forward keys
|
||||
// listed in CLIENT_VISIBLE_FEATURE_FLAGS so internal flags can't leak.
|
||||
// Non-boolean values (e.g. `"true"` as a string) are coerced so the
|
||||
// client never has to guess.
|
||||
const rawFlags = extension.config.feature_flags ?? {};
|
||||
const feature_flags: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(rawFlags)) {
|
||||
if (CLIENT_VISIBLE_FEATURE_FLAGS.has(k)) {
|
||||
feature_flags[k] = Boolean(v);
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-copied (it is decoded JSON) so the scrub below edits the response
|
||||
// and not the cached UserRow. Sensitive keys inside it — tmp_password and
|
||||
// anything else on the denylist — are removed by scrubSensitive.
|
||||
const metadata = user.metadata
|
||||
? structuredClone(user.metadata)
|
||||
: user.metadata;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
email: user.email,
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
// The phone number itself is deliberately absent: nothing on the
|
||||
// client reads it, and it is PII that would otherwise be handed to
|
||||
// every app actor. Only the verification flag ships.
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
// A seat reaches nothing until it replaces its admin's password.
|
||||
requires_password_change: user.requires_password_change,
|
||||
// The SMS-to-card escape hatch: true once this user is out of SMS send
|
||||
// attempts and may verify a card instead. It has to ship from here
|
||||
// because /send-confirm-phone can no longer say so — by the time the
|
||||
// fallback opens, further sends are rejected by that route's own rate
|
||||
// limit before any handler runs, so a page reload would otherwise lose
|
||||
// an offer that stays valid for 24 hours. Only for user actors, and it
|
||||
// costs no KV read unless the account is actually phone-gated.
|
||||
card_fallback_available: isUser
|
||||
? await isCardFallbackEligible(
|
||||
extension.config,
|
||||
user,
|
||||
async (key) => (await stores.kv.get({ key })).res,
|
||||
cardFallbackDepsFrom(clients),
|
||||
)
|
||||
: false,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
is_user_token: true,
|
||||
// Present only once the account has actually asked for a code (see the
|
||||
// referral extension) — null until then, and never minted from here:
|
||||
// this endpoint is polled, and a mint is a write.
|
||||
referral_code: user.referral_code,
|
||||
oidc_only: oidcOnly,
|
||||
otp: !!user.otp_enabled,
|
||||
feature_flags,
|
||||
created_ts: toUnixSeconds(user.timestamp),
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
metadata,
|
||||
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
|
||||
};
|
||||
|
||||
// OIDC revalidate URL for password-less accounts
|
||||
if (oidcOnly) {
|
||||
try {
|
||||
const provider = await services.oidc.getLinkedProviderForUser(
|
||||
user.id as number,
|
||||
);
|
||||
if (provider) {
|
||||
const origin = (extension.config.origin ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
details.oidc_revalidate_url = `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_uuid=${encodeURIComponent(user.uuid)}`;
|
||||
}
|
||||
} catch {
|
||||
// OIDC not configured
|
||||
}
|
||||
}
|
||||
|
||||
// Taskbar items — only sent to user actors
|
||||
if (isUser) {
|
||||
details.taskbar_items = await getTaskbarItems(
|
||||
user,
|
||||
{
|
||||
clients,
|
||||
stores,
|
||||
services,
|
||||
apiBaseUrl: String(extension.config.api_base_url ?? ''),
|
||||
config: extension.config,
|
||||
},
|
||||
{ iconSize, noIcons },
|
||||
);
|
||||
}
|
||||
|
||||
// Directories — only sent to user actors
|
||||
if (isUser) {
|
||||
const directories: Record<string, unknown> = {};
|
||||
const nameToProp: Record<string, string> = {
|
||||
desktop_uuid: `/${user.username}/Desktop`,
|
||||
appdata_uuid: `/${user.username}/AppData`,
|
||||
documents_uuid: `/${user.username}/Documents`,
|
||||
pictures_uuid: `/${user.username}/Pictures`,
|
||||
videos_uuid: `/${user.username}/Videos`,
|
||||
trash_uuid: `/${user.username}/Trash`,
|
||||
};
|
||||
for (const k in nameToProp) {
|
||||
directories[nameToProp[k]] = user[k];
|
||||
}
|
||||
details.directories = directories;
|
||||
}
|
||||
|
||||
// The team an account belongs to, when it is one a team pays for. User
|
||||
// actors only, and only where teams are on.
|
||||
if (isUser && extension.config.teams_enabled === true) {
|
||||
try {
|
||||
const seat = await stores.team.getOrgSeat(user.id);
|
||||
if (seat) {
|
||||
details.team = {
|
||||
uid: seat.team_uid,
|
||||
name: seat.team_name ?? null,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Never fail whoami over this; the account still works without it.
|
||||
console.warn('[whoami] team lookup failed:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Last activity
|
||||
const lastActivityTs = toUnixSeconds(user.last_activity_ts);
|
||||
if (lastActivityTs !== undefined) {
|
||||
details.last_activity_ts = lastActivityTs;
|
||||
}
|
||||
const details = await buildUserDetails(
|
||||
user,
|
||||
{ config: extension.config, clients, stores, services },
|
||||
{ isUser, iconSize, noIcons: !iconSize },
|
||||
);
|
||||
details.human_readable_age = user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null;
|
||||
|
||||
// Strip sensitive fields for app actors
|
||||
if (!isUser) {
|
||||
|
||||
Generated
+646
-27
@@ -2554,6 +2554,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2576,6 +2577,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2633,6 +2635,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2649,6 +2652,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2665,6 +2669,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2684,6 +2689,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2703,6 +2709,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2722,6 +2729,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2741,6 +2749,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2760,6 +2769,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2779,6 +2789,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -2798,6 +2809,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -2817,6 +2829,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2842,6 +2855,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2867,6 +2881,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2892,6 +2907,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2917,6 +2933,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2942,6 +2959,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -2967,6 +2985,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -2992,6 +3011,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -3014,6 +3034,7 @@
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -3068,6 +3089,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3087,6 +3109,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3106,6 +3129,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10354,12 +10378,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
|
||||
"version": "4.7.10",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz",
|
||||
"integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"minimist": "^1.2.8",
|
||||
"neo-async": "^2.6.2",
|
||||
"source-map": "^0.6.1",
|
||||
"wordwrap": "^1.0.0"
|
||||
@@ -13339,9 +13363,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.24.4",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
||||
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
|
||||
"version": "3.24.5",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
|
||||
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
@@ -14439,9 +14463,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
@@ -14449,6 +14473,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr/node_modules/ipaddr.js": {
|
||||
@@ -15286,6 +15314,7 @@
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
@@ -15335,6 +15364,7 @@
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -15357,6 +15387,7 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -15391,9 +15422,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
||||
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -15547,13 +15578,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smtp-server": {
|
||||
"version": "3.19.13",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.13.tgz",
|
||||
"integrity": "sha512-W1CjNcrPqW4+1gUOYRyjPvTOxu7GgKR9+7l50yQBF0GM8v8jpYwheHFCHX7s+p7pYI8iXKQQZiF2Rv2Na/3i2g==",
|
||||
"version": "3.19.17",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.17.tgz",
|
||||
"integrity": "sha512-/9RwyNTcR8osMQWOvC8NV8l57ddPyYXvuG+wa1Ua4cD/I41z7zCMsGtXLa9vxlrggjRrfejr50X0WS/9NuVWqQ==",
|
||||
"license": "MIT-0",
|
||||
"dependencies": {
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "10.0.10",
|
||||
"nodemailer": "10.0.14",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15561,9 +15592,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
||||
"version": "10.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
|
||||
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
|
||||
"version": "10.0.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.14.tgz",
|
||||
"integrity": "sha512-eJoLFKg55fesSlzUekmfOv/SBXh4KUYT8oiQc2RLP0tfG0rbNMJBK7wgHJwXTJwqyKlSJMz9UzDZ4v+aHEvo8g==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -15733,9 +15764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -17663,7 +17694,7 @@
|
||||
"express": "^5.0.0",
|
||||
"fauxqs": "^2.5.0",
|
||||
"groq-sdk": "^0.5.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"handlebars": "^4.7.10",
|
||||
"helmet": "^7.2.0",
|
||||
"hi-base32": "^0.5.1",
|
||||
"html-entities": "^2.3.3",
|
||||
@@ -17673,15 +17704,15 @@
|
||||
"lorem-ipsum": "^2.0.8",
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.22.6",
|
||||
"mysql2": "^3.24.5",
|
||||
"nodemailer": "^9.1.1",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
"pg": "^8.21.0",
|
||||
"postal-mime": "^3.0.1",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.4",
|
||||
"smtp-server": "^3.19.13",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.17",
|
||||
"socket.io": "^4.8.4",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"together-ai": "^0.33.0",
|
||||
@@ -17708,6 +17739,545 @@
|
||||
"vitest": "^4.0.14"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"src/backend/node_modules/@types/mime-types": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
|
||||
@@ -17715,6 +18285,55 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"src/backend/node_modules/sharp": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.5",
|
||||
"@img/sharp-darwin-x64": "0.35.5",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||
"@img/sharp-linux-arm": "0.35.5",
|
||||
"@img/sharp-linux-arm64": "0.35.5",
|
||||
"@img/sharp-linux-ppc64": "0.35.5",
|
||||
"@img/sharp-linux-riscv64": "0.35.5",
|
||||
"@img/sharp-linux-s390x": "0.35.5",
|
||||
"@img/sharp-linux-x64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||
"@img/sharp-win32-arm64": "0.35.5",
|
||||
"@img/sharp-win32-ia32": "0.35.5",
|
||||
"@img/sharp-win32-x64": "0.35.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"src/cli": {
|
||||
"name": "@heyputer/cli",
|
||||
"version": "0.4.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AlarmClient } from './AlarmClient';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ALARM_IDLE_TTL_MS,
|
||||
AlarmClient,
|
||||
MAX_TRACKED_ALARMS,
|
||||
} from './AlarmClient';
|
||||
import type { IConfig, IPagerConfig } from '../../types';
|
||||
import type { AlertPayload } from './types';
|
||||
|
||||
@@ -342,6 +346,19 @@ describe('AlarmClient alarm registry', () => {
|
||||
expect(client.get(shortId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the short id of another alarm that shares it when one is cleared', () => {
|
||||
const client = makeClient();
|
||||
// These two ids hash to the same short id.
|
||||
client.create('alarm:274', 'first');
|
||||
const shortId = client.get('alarm:274')!.shortId;
|
||||
client.create('alarm:643', 'second');
|
||||
expect(client.get('alarm:643')!.shortId).toBe(shortId);
|
||||
|
||||
client.clear('alarm:274');
|
||||
|
||||
expect(client.get(shortId)?.id).toBe('alarm:643');
|
||||
});
|
||||
|
||||
it('ignores a clear for an alarm that is not active', () => {
|
||||
const client = makeClient();
|
||||
expect(() => client.clear('never-raised')).not.toThrow();
|
||||
@@ -428,6 +445,110 @@ describe('AlarmClient alarm registry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlarmClient alarm eviction', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('forgets alarms that stay quiet past the idle window, under both ids', () => {
|
||||
const client = makeClient();
|
||||
const ids = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `metering_usage_dropped:user-${i}`,
|
||||
);
|
||||
for (const id of ids) client.create(id, 'usage dropped');
|
||||
const shortIds = ids.map((id) => client.get(id)!.shortId);
|
||||
|
||||
vi.advanceTimersByTime(ALARM_IDLE_TTL_MS + 1);
|
||||
client.create('unrelated', 'the next alarm sweeps the registry');
|
||||
|
||||
expect(ids.filter((id) => client.get(id))).toEqual([]);
|
||||
expect(shortIds.filter((shortId) => client.get(shortId))).toEqual([]);
|
||||
expect(client.get('unrelated')).toBeDefined();
|
||||
});
|
||||
|
||||
it('treats a recurrence after the idle window as a new alarm', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
client.create('scan:failed', 'first');
|
||||
client.create('scan:failed', 'second');
|
||||
vi.advanceTimersByTime(ALARM_IDLE_TTL_MS + 1);
|
||||
client.create('scan:failed', 'back again');
|
||||
|
||||
expect(seen.at(-1)).toMatchObject({ repeatCount: 1, isRepeat: false });
|
||||
// A fresh start keeps per-occurrence incident keys from colliding.
|
||||
expect(seen.at(-1)!.dedupKey).not.toBe(seen[0].dedupKey);
|
||||
});
|
||||
|
||||
it('still treats a repeat inside the window as a repeat after a sweep', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 10; i++) client.create('flap', 'same fault');
|
||||
vi.advanceTimersByTime(ALARM_IDLE_TTL_MS - 1000);
|
||||
client.create('other', 'sweeps the registry');
|
||||
client.create('flap', 'same fault');
|
||||
|
||||
// Occurrence 11 is past the burst, so nothing new goes out for it.
|
||||
expect(seen.map((alert) => alert.id)).toEqual([
|
||||
...Array(10).fill('flap'),
|
||||
'other',
|
||||
]);
|
||||
expect(client.get('flap')?.count).toBe(11);
|
||||
});
|
||||
|
||||
it('keeps an alarm that keeps recurring for longer than the window', () => {
|
||||
const client = makeClient();
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
client.create('hot', 'still broken');
|
||||
vi.advanceTimersByTime(ALARM_IDLE_TTL_MS / 2);
|
||||
client.create(`noise:${i}`, 'sweeps the registry');
|
||||
}
|
||||
|
||||
expect(client.get('hot')?.count).toBe(6);
|
||||
});
|
||||
|
||||
it('drops the least recently raised alarm once full', () => {
|
||||
const client = makeClient();
|
||||
|
||||
for (let i = 0; i < MAX_TRACKED_ALARMS; i++) {
|
||||
client.create(`user:${i}`, 'per-user alarm');
|
||||
vi.advanceTimersByTime(1);
|
||||
}
|
||||
// Raised again, so user:1 is now the one seen longest ago.
|
||||
client.create('user:0', 'per-user alarm');
|
||||
client.create('one-more', 'over the cap');
|
||||
|
||||
expect(client.get('user:1')).toBeUndefined();
|
||||
expect(client.get('user:0')?.count).toBe(2);
|
||||
expect(client.get('user:2')).toBeDefined();
|
||||
expect(client.get('one-more')).toBeDefined();
|
||||
});
|
||||
|
||||
it('never holds more than the cap', () => {
|
||||
const client = makeClient();
|
||||
const ids = Array.from(
|
||||
{ length: MAX_TRACKED_ALARMS * 3 },
|
||||
(_, i) => `user:${i}`,
|
||||
);
|
||||
|
||||
for (const id of ids) client.create(id, 'per-user alarm');
|
||||
|
||||
const held = ids.filter((id) => client.get(id));
|
||||
expect(held).toHaveLength(MAX_TRACKED_ALARMS);
|
||||
expect(held[0]).toBe(`user:${MAX_TRACKED_ALARMS * 2}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlarmClient known-error rules', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
@@ -61,14 +61,24 @@ interface RegisteredHandler {
|
||||
/** Severity used when neither the call site nor config picks one. */
|
||||
const FALLBACK_SEVERITY: PagerSeverity = 'critical';
|
||||
/**
|
||||
* How many recent occurrences an alarm keeps. An alarm is never cleared unless
|
||||
* something calls `clear`, and a hot one repeats for as long as the fault lasts
|
||||
* — so retaining every occurrence means retaining every message and field set
|
||||
* it was ever raised with, request bodies and actors included, for the life of
|
||||
* the process. The last few are what a human reads; the rest is only a count,
|
||||
* and `count` keeps that.
|
||||
* How many recent occurrences an alarm keeps. A hot alarm repeats for as long
|
||||
* as the fault lasts, so retaining every occurrence means retaining every
|
||||
* message and field set it was ever raised with, request bodies and actors
|
||||
* included. The last few are what a human reads; the rest is only a count, and
|
||||
* `count` keeps that.
|
||||
*/
|
||||
const OCCURRENCE_HISTORY_LIMIT = 20;
|
||||
/**
|
||||
* An alarm that hasn't recurred for this long is forgotten; its next occurrence
|
||||
* is a new alarm with a fresh count. Kept far past the chat transport's repeat
|
||||
* throttle so a slowly recurring fault still counts as one alarm.
|
||||
*/
|
||||
export const ALARM_IDLE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* Most alarms held at once. Ids that embed a user or key can outrun the idle
|
||||
* window; past this the least recently raised alarm is dropped.
|
||||
*/
|
||||
export const MAX_TRACKED_ALARMS = 1000;
|
||||
|
||||
// A fast-recurring fault repeats far faster than it is useful to report, and
|
||||
// every report allocates a log line and a payload. Report every occurrence up
|
||||
@@ -187,6 +197,7 @@ function cleanFields(fields: AlarmFields): Record<string, string> {
|
||||
* `pager.severityOverrides` in {@link IConfig}.
|
||||
*/
|
||||
export class AlarmClient extends PuterClient {
|
||||
/** Ordered by last occurrence: a repeat moves its alarm to the back. */
|
||||
private alarms = new Map<string, Alarm>();
|
||||
private aliases = new Map<string, Alarm>();
|
||||
private alertHandlers: RegisteredHandler[] = [];
|
||||
@@ -290,7 +301,8 @@ export class AlarmClient extends PuterClient {
|
||||
|
||||
/**
|
||||
* Create or update an alarm. If the alarm ID already exists, the occurrence
|
||||
* count is incremented and a repeat alert is dispatched.
|
||||
* count is incremented and a repeat alert is dispatched. An alarm idle for
|
||||
* {@link ALARM_IDLE_TTL_MS} is forgotten, so its next occurrence is new.
|
||||
*
|
||||
* `severity` decides where the alarm lands:
|
||||
*
|
||||
@@ -321,9 +333,12 @@ export class AlarmClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
this.evict();
|
||||
const existing = this.alarms.get(id);
|
||||
|
||||
if (existing) {
|
||||
this.alarms.delete(id);
|
||||
this.alarms.set(id, existing);
|
||||
this.recordOccurrence(existing, message, fields);
|
||||
this.handleRepeat(existing);
|
||||
return;
|
||||
@@ -348,6 +363,7 @@ export class AlarmClient extends PuterClient {
|
||||
this.alarms.set(id, alarm);
|
||||
this.aliases.set(alarm.shortId, alarm);
|
||||
this.recordOccurrence(alarm, message, fields);
|
||||
this.evict();
|
||||
this.handleNew(alarm);
|
||||
}
|
||||
|
||||
@@ -356,8 +372,7 @@ export class AlarmClient extends PuterClient {
|
||||
const alarm = this.alarms.get(id);
|
||||
if (!alarm) return;
|
||||
|
||||
this.alarms.delete(id);
|
||||
this.aliases.delete(alarm.shortId);
|
||||
this.forget(alarm);
|
||||
console.log(`[alarm] CLEAR ${displayId(alarm)} :: ${alarm.message}`);
|
||||
}
|
||||
|
||||
@@ -395,6 +410,32 @@ export class AlarmClient extends PuterClient {
|
||||
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Drop idle alarms, then the least recently raised ones past the cap. The
|
||||
* map's order puts both at the front, so this stops at the first keeper.
|
||||
*/
|
||||
private evict(): void {
|
||||
const now = Date.now();
|
||||
for (const alarm of this.alarms.values()) {
|
||||
const lastSeen = alarm.timestamps.at(-1) ?? alarm.started;
|
||||
if (
|
||||
this.alarms.size <= MAX_TRACKED_ALARMS &&
|
||||
now - lastSeen < ALARM_IDLE_TTL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.forget(alarm);
|
||||
}
|
||||
}
|
||||
|
||||
private forget(alarm: Alarm): void {
|
||||
this.alarms.delete(alarm.id);
|
||||
// Short ids can collide; leave an alias that now names another alarm.
|
||||
if (this.aliases.get(alarm.shortId) === alarm) {
|
||||
this.aliases.delete(alarm.shortId);
|
||||
}
|
||||
}
|
||||
|
||||
private recordOccurrence(
|
||||
alarm: Alarm,
|
||||
message: string,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { ISlackAlertConfig, PagerSeverity } from '../../types';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap';
|
||||
import type { AlertHandler, AlertPayload } from './types';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
@@ -123,30 +124,14 @@ export function createSlackAlertHandler(
|
||||
): AlertHandler {
|
||||
const webhookUrl = conf.webhookUrl as string;
|
||||
const throttleMs = conf.repeatThrottleMs ?? DEFAULT_REPEAT_THROTTLE_MS;
|
||||
const lastPosted = new Map<string, number>();
|
||||
|
||||
const shouldPost = (alert: AlertPayload): boolean => {
|
||||
if (throttleMs <= 0) return true;
|
||||
const now = Date.now();
|
||||
const previous = lastPosted.get(alert.id);
|
||||
if (previous !== undefined && now - previous < throttleMs) return false;
|
||||
|
||||
if (lastPosted.size >= MAX_THROTTLE_ENTRIES) {
|
||||
for (const [id, at] of lastPosted) {
|
||||
if (now - at >= throttleMs) lastPosted.delete(id);
|
||||
}
|
||||
// Still full of live entries — drop the oldest to stay bounded.
|
||||
if (lastPosted.size >= MAX_THROTTLE_ENTRIES) {
|
||||
const oldest = lastPosted.keys().next().value;
|
||||
if (oldest !== undefined) lastPosted.delete(oldest);
|
||||
}
|
||||
}
|
||||
lastPosted.set(alert.id, now);
|
||||
return true;
|
||||
};
|
||||
// A zero or negative throttle expires every entry on arrival.
|
||||
const lastPosted = new BoundedTtlMap<string, true>({
|
||||
maxEntries: MAX_THROTTLE_ENTRIES,
|
||||
ttlMs: throttleMs,
|
||||
});
|
||||
|
||||
return async (alert) => {
|
||||
if (!shouldPost(alert)) return;
|
||||
if (!lastPosted.shouldEmit(alert.id)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
@@ -45,10 +45,15 @@ class FixtureDatabaseClient extends AbstractDatabaseClient {
|
||||
constructor(
|
||||
private readonly replicaRows: Record<string, unknown>[] | Error,
|
||||
private readonly primaryRows: Record<string, unknown>[] = [],
|
||||
private readonly replica = true,
|
||||
) {
|
||||
super(config('fixture'));
|
||||
}
|
||||
|
||||
protected override hasReadReplica(): boolean {
|
||||
return this.replica;
|
||||
}
|
||||
|
||||
override async read(query: string): Promise<Record<string, unknown>[]> {
|
||||
this.reads.push(query);
|
||||
if (this.replicaRows instanceof Error) throw this.replicaRows;
|
||||
@@ -127,14 +132,22 @@ describe('AbstractDatabaseClient — replica-aware reads', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the failing query when a required read finds nothing', async () => {
|
||||
const client = new FixtureDatabaseClient([], []);
|
||||
await expect(
|
||||
client.requireRead('SELECT * FROM `user` WHERE `id` = ?'),
|
||||
).rejects.toThrow(
|
||||
'required read returned no rows: SELECT * FROM `user` WHERE `id` = ?',
|
||||
it('surfaces the primary error when both reads fail', async () => {
|
||||
const client = new FixtureDatabaseClient(new Error('replica down'));
|
||||
client.pread = async () => {
|
||||
throw new Error('primary down');
|
||||
};
|
||||
await expect(client.tryHardRead('SELECT 1')).rejects.toThrow(
|
||||
'primary down',
|
||||
);
|
||||
});
|
||||
|
||||
it('issues a single read when there is no replica', async () => {
|
||||
const client = new FixtureDatabaseClient([], [{ id: 2 }], false);
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([]);
|
||||
expect(client.reads).toEqual(['SELECT 1']);
|
||||
expect(client.preads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
@@ -235,6 +248,25 @@ describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
expect(mysql().returningIdClause()).toBe('');
|
||||
});
|
||||
|
||||
it('stamps a timestamp from unix seconds in each dialect', () => {
|
||||
expect(sqlite().timestampFromUnixSql()).toBe(
|
||||
"datetime(?, 'unixepoch')",
|
||||
);
|
||||
expect(mysql().timestampFromUnixSql()).toBe('FROM_UNIXTIME(?)');
|
||||
expect(postgres().timestampFromUnixSql()).toBe('to_timestamp(?)');
|
||||
});
|
||||
|
||||
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');
|
||||
expect(mysql().timestampValue(seconds)).toEqual(
|
||||
new Date(seconds * 1000),
|
||||
);
|
||||
expect(postgres().timestampValue(seconds)).toEqual(
|
||||
new Date(seconds * 1000),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to `otherwise` for an engine with no explicit choice', () => {
|
||||
const client = new FixtureDatabaseClient([]);
|
||||
expect(
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import type { IConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
|
||||
export interface WriteResult {
|
||||
insertId: number | bigint;
|
||||
@@ -33,6 +37,12 @@ export interface BatchEntry {
|
||||
|
||||
type SqlJsonPath = readonly [string, ...string[]];
|
||||
|
||||
/** The slice of a pooled connection that migrations need. */
|
||||
export interface MigrationConnection {
|
||||
query(sql: string): Promise<unknown>;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base database client. Subclasses must override every method that throws here.
|
||||
*
|
||||
@@ -104,39 +114,133 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
return this.write(sql, values);
|
||||
}
|
||||
|
||||
/** Whether `read()` goes to a separate replica rather than the primary. */
|
||||
protected hasReadReplica(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `read()` but falls back to the primary when read-replicas are in
|
||||
* use. Subclasses may override with replica-aware logic; the default
|
||||
* delegates to `pread()`.
|
||||
* The replica leg of `tryHardRead`. Override when `read()` falls back to
|
||||
* the primary by itself, or a replica failure reads the primary twice.
|
||||
*/
|
||||
protected replicaRead(
|
||||
query: string,
|
||||
params: unknown[],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `read()`, but an empty or failed replica read falls back to the
|
||||
* primary, so replication lag can't hide a row that was just written. The
|
||||
* primary read starts alongside the replica one rather than after it.
|
||||
* Without a replica this is a single `read()`.
|
||||
*/
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const primary = this.pread(query, params);
|
||||
primary.catch(() => {});
|
||||
if (!this.hasReadReplica()) return this.read(query, params);
|
||||
|
||||
const primary = settle(this.pread(query, params));
|
||||
try {
|
||||
const rows = await this.read(query, params);
|
||||
if (rows.length > 0) {
|
||||
return rows;
|
||||
}
|
||||
const rows = await this.replicaRead(query, params);
|
||||
if (rows.length > 0) return rows;
|
||||
} catch {
|
||||
// replica failed — fall through to primary
|
||||
}
|
||||
return primary;
|
||||
const result = await primary;
|
||||
if ('error' in result) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/** Like `tryHardRead()` but throws when the result set is empty. */
|
||||
async requireRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const rows = await this.tryHardRead(query, params);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`required read returned no rows: ${query}`);
|
||||
/**
|
||||
* Apply the `.sql` files named `<engineName>*` in each configured migration
|
||||
* directory, in filename order. Files must be idempotent: nothing records
|
||||
* which ones already ran. A failure aborts startup. `transactional` wraps
|
||||
* each file in BEGIN/COMMIT, for engines whose DDL can roll back.
|
||||
*/
|
||||
protected async applyMigrationPaths(
|
||||
connect: () => Promise<MigrationConnection>,
|
||||
splitStatements: (contents: string) => string[],
|
||||
{ transactional }: { transactional: boolean },
|
||||
): Promise<void> {
|
||||
const paths = this.config.database?.migrationPaths;
|
||||
if (!paths || paths.length === 0) return;
|
||||
|
||||
const tag = `[${this.engineName}]`;
|
||||
const conn = await connect();
|
||||
try {
|
||||
for (const rawPath of paths) {
|
||||
const dir = isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: resolvePath(process.cwd(), rawPath);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir)
|
||||
.filter(
|
||||
(f) =>
|
||||
f.endsWith('.sql') &&
|
||||
f.startsWith(this.engineName),
|
||||
)
|
||||
.sort(compareMigrationFilenames);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`${tag} migration path is unreadable: ${dir}`,
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`${tag} no migrations in ${dir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${tag} running migrations from ${dir}: ${files.length} file(s)`,
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const contents = readFileSync(
|
||||
resolvePath(dir, file),
|
||||
'utf8',
|
||||
);
|
||||
const statements = splitStatements(contents);
|
||||
if (transactional) await conn.query('BEGIN');
|
||||
try {
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
try {
|
||||
await conn.query(statements[i]);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`${tag} failed to apply ${file} at statement ${i}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (transactional) await conn.query('COMMIT');
|
||||
} catch (e) {
|
||||
if (transactional) {
|
||||
try {
|
||||
await conn.query('ROLLBACK');
|
||||
} catch {
|
||||
// the original failure is the one worth surfacing
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
console.log(
|
||||
`${tag} applied ${file} (${statements.length} statements)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,7 +344,43 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp column value from one bound parameter in unix seconds, stored
|
||||
* the way `CURRENT_TIMESTAMP` would store that instant.
|
||||
*/
|
||||
timestampFromUnixSql(): string {
|
||||
return this.case({
|
||||
sqlite: "datetime(?, 'unixepoch')",
|
||||
postgres: 'to_timestamp(?)',
|
||||
otherwise: 'FROM_UNIXTIME(?)',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* zone (so this matches when node and the database session share one).
|
||||
*/
|
||||
timestampValue(seconds: number): Date | string {
|
||||
const date = new Date(seconds * 1000);
|
||||
return this.case<Date | string>({
|
||||
sqlite: date.toISOString().slice(0, 19).replace('T', ' '),
|
||||
otherwise: date,
|
||||
});
|
||||
}
|
||||
|
||||
protected sqlStringLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Await later without an unhandled rejection in the meantime. */
|
||||
const settle = async <T>(
|
||||
promise: Promise<T>,
|
||||
): Promise<{ value: T } | { error: unknown }> => {
|
||||
try {
|
||||
return { value: await promise };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
@@ -619,6 +619,21 @@ describe('MySQLDatabaseClient — tryHardRead', () => {
|
||||
{ from: 'primary' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the primary once when the replica fails in a way read() fails over on', async () => {
|
||||
const replica = {
|
||||
execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')),
|
||||
};
|
||||
const primary = {
|
||||
execute: vi.fn().mockResolvedValue([[{ from: 'primary' }]]),
|
||||
};
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
{ from: 'primary' },
|
||||
]);
|
||||
expect(primary.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient — migrations', () => {
|
||||
@@ -696,46 +711,6 @@ describe('MySQLDatabaseClient — migrations', () => {
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient — pool lifecycle', () => {
|
||||
it('replaces and closes the previous primary pool on reinit', async () => {
|
||||
const client = await startClient();
|
||||
const original = createdPools[0];
|
||||
|
||||
client.reinitPrimary();
|
||||
|
||||
expect(createdPools).toHaveLength(2);
|
||||
expect(original.ended).toBe(true);
|
||||
// Single-node: the replica batcher must follow the new primary.
|
||||
createdPools[1].respond = () => rowSets([{ from: 'new-primary' }]);
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([
|
||||
{ from: 'new-primary' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces only the replica pool on replica reinit', async () => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
const [primary, originalReplica] = createdPools;
|
||||
|
||||
client.reinitReplica();
|
||||
|
||||
expect(createdPools).toHaveLength(3);
|
||||
expect(originalReplica.ended).toBe(true);
|
||||
expect(primary.ended).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a replica reinit when no replica is configured', async () => {
|
||||
const client = await startClient();
|
||||
client.reinitReplica();
|
||||
expect(createdPools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores reinit once shutdown has started', async () => {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
client.reinitPrimary();
|
||||
expect(createdPools).toHaveLength(1);
|
||||
await client.onServerShutdown();
|
||||
});
|
||||
|
||||
it('closes both pools on shutdown when a replica is configured', async () => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
await client.onServerShutdown();
|
||||
@@ -779,108 +754,3 @@ describe('MySQLDatabaseClient — pool lifecycle', () => {
|
||||
await expect(client.onServerShutdown()).rejects.toThrow('end exploded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient.readWithRetry', () => {
|
||||
const codedFailure = (code: string) => {
|
||||
const err = new Error(code) as Error & { code: string };
|
||||
err.code = code;
|
||||
return err;
|
||||
};
|
||||
|
||||
it('classifies transient connection failures as retriable', () => {
|
||||
expect(
|
||||
MySQLDatabaseClient.isRetriableError(codedFailure('ECONNRESET')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
MySQLDatabaseClient.isRetriableError(codedFailure('ER_DUP_ENTRY')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
MySQLDatabaseClient.isRetriableError(new Error('Connection lost')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a transient failure and returns the eventual result', async () => {
|
||||
const client = await startClient();
|
||||
let attempts = 0;
|
||||
const operation = vi.fn(async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) throw codedFailure('PROTOCOL_CONNECTION_LOST');
|
||||
return [{ ok: 1 }];
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
|
||||
).resolves.toEqual([{ ok: 1 }]);
|
||||
expect(operation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('gives up after the attempt budget is spent', async () => {
|
||||
const client = await startClient();
|
||||
const operation = vi.fn(async () => {
|
||||
throw codedFailure('ETIMEDOUT');
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, {
|
||||
maxAttempts: 2,
|
||||
baseBackoffMs: 0,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ETIMEDOUT' });
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never retries a deterministic SQL error', async () => {
|
||||
const client = await startClient();
|
||||
const operation = vi.fn(async () => {
|
||||
throw codedFailure('ER_PARSE_ERROR');
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
|
||||
).rejects.toMatchObject({ code: 'ER_PARSE_ERROR' });
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops retrying once shutdown has started', async () => {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
const operation = vi.fn(async () => {
|
||||
throw codedFailure('ECONNRESET');
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
|
||||
).rejects.toMatchObject({ code: 'ECONNRESET' });
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
await client.onServerShutdown();
|
||||
});
|
||||
|
||||
it('applies jittered exponential backoff capped at maxBackoffMs', async () => {
|
||||
const client = await startClient();
|
||||
const delays: number[] = [];
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, 'setTimeout')
|
||||
.mockImplementation(((fn: () => void, ms?: number) => {
|
||||
delays.push(ms ?? 0);
|
||||
return realSetTimeout(fn, 0);
|
||||
}) as typeof setTimeout);
|
||||
|
||||
try {
|
||||
const operation = vi.fn(async () => {
|
||||
throw codedFailure('ECONNRESET');
|
||||
});
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, {
|
||||
maxAttempts: 4,
|
||||
baseBackoffMs: 100,
|
||||
maxBackoffMs: 150,
|
||||
jitterRatio: 0,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ECONNRESET' });
|
||||
expect(delays).toEqual([100, 150, 150]);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,6 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import { createPool, ExecuteValues, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
@@ -26,7 +24,6 @@ import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
import { isRetriableError } from './retriableErrors.js';
|
||||
import { splitMysqlStatements } from './splitMysqlStatements.js';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const DEFAULT_SELECT_TIMEOUT_MS = 30_000;
|
||||
@@ -40,7 +37,7 @@ const replicaFailoverCounter = metrics
|
||||
'Reads that failed on the replica batcher and were retried on the primary',
|
||||
});
|
||||
|
||||
export { compareMigrationFilenames };
|
||||
export { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
|
||||
type PoolConfig = Parameters<typeof createPool>[0];
|
||||
|
||||
@@ -95,15 +92,19 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
await this.runMigrations();
|
||||
await this.applyMigrationPaths(
|
||||
() => this.primaryPool.promise().getConnection(),
|
||||
splitMysqlStatements,
|
||||
{ transactional: false },
|
||||
);
|
||||
}
|
||||
|
||||
override async onServerPrepareShutdown(): Promise<void> {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
// Blocks reinitPrimary/reinitReplica from here on. Pools stay open;
|
||||
// onServerShutdown closes them after the layers above have drained.
|
||||
// Pools stay open; onServerShutdown closes them after the layers
|
||||
// above have drained.
|
||||
console.log('[mysql] entering drain mode');
|
||||
}
|
||||
|
||||
@@ -200,101 +201,18 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
// Run both reads in parallel — prefer replica when it returns rows,
|
||||
// otherwise fall back to primary to handle replication lag.
|
||||
const primaryPromise = this.dbPrimaryRead.execute(query, params);
|
||||
try {
|
||||
const replicaResult = await this.dbReplica.execute(query, params);
|
||||
if (
|
||||
Array.isArray(replicaResult?.[0]) &&
|
||||
(replicaResult[0] as unknown[]).length > 0
|
||||
) {
|
||||
primaryPromise.catch(() => {}); // suppress unhandled rejection
|
||||
return replicaResult[0] as Record<string, unknown>[];
|
||||
}
|
||||
} catch {
|
||||
// fall through to primary
|
||||
}
|
||||
|
||||
const primaryResult = await primaryPromise;
|
||||
return (primaryResult?.[0] as Record<string, unknown>[]) ?? [];
|
||||
protected override hasReadReplica(): boolean {
|
||||
return this.configuration === Configuration.REPLICA;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Migrations
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply `.sql` files from each configured migration directory in order.
|
||||
* Files within a directory are sorted lexically. Files MUST be idempotent —
|
||||
* there is no per-file applied-state tracking. Failures abort startup so
|
||||
* operators see schema problems loud.
|
||||
*/
|
||||
private async runMigrations(): Promise<void> {
|
||||
const paths = this.config.database?.migrationPaths;
|
||||
if (!paths || paths.length === 0) return;
|
||||
|
||||
const conn = await this.primaryPool.promise().getConnection();
|
||||
try {
|
||||
for (const rawPath of paths) {
|
||||
const dir = isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: resolvePath(process.cwd(), rawPath);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir)
|
||||
.filter(
|
||||
(f) => f.endsWith('.sql') && f.startsWith('mysql'),
|
||||
)
|
||||
.sort(compareMigrationFilenames);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[mysql] migration path is unreadable: ${dir}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`[mysql] no migrations in ${dir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[mysql] running migrations from ${dir}: ${files.length} file(s)`,
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = resolvePath(dir, file);
|
||||
const contents = readFileSync(filePath, 'utf8');
|
||||
const statements = splitMysqlStatements(contents);
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
try {
|
||||
await conn.query(statements[i]);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[mysql] failed to apply ${file} at statement ${i}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[mysql] applied ${file} (${statements.length} statements)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
// No failover: `tryHardRead` already has a primary read in flight.
|
||||
protected override async replicaRead(
|
||||
query: string,
|
||||
params: unknown[],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.dbReplica.execute(query, params);
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -376,57 +294,6 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Reinitialize the primary pool (e.g. after a health-check failure). */
|
||||
reinitPrimary(): void {
|
||||
if (this.shutdownStarted) return;
|
||||
|
||||
const dbConf = this.config.database!;
|
||||
const previous = this.primaryPool;
|
||||
this.primaryPool = this.createPool({
|
||||
host: dbConf.host ?? '127.0.0.1',
|
||||
port: dbConf.port ?? 3306,
|
||||
user: dbConf.user ?? 'root',
|
||||
password: dbConf.password ?? '',
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
this.replicaPool = this.primaryPool;
|
||||
this.dbReplica = this.createReplicaBatcher(this.primaryPool);
|
||||
}
|
||||
|
||||
if (previous && previous !== this.primaryPool) {
|
||||
this.closePool(previous, 'reinit:primary').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Reinitialize the replica pool. */
|
||||
reinitReplica(): void {
|
||||
if (this.shutdownStarted || !this.config.database?.replica) return;
|
||||
|
||||
const previous = this.replicaPool;
|
||||
this.replicaPool = this.createPool(this.config.database.replica);
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
if (
|
||||
previous &&
|
||||
previous !== this.replicaPool &&
|
||||
previous !== this.primaryPool
|
||||
) {
|
||||
this.closePool(previous, 'reinit:replica').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Retry helpers (for health checks or resilient reads)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
static isRetriableError(error: unknown): boolean {
|
||||
return isRetriableError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replica failures worth retrying on the primary: batcher load-shed or
|
||||
* transient connection errors — never deterministic SQL errors.
|
||||
@@ -436,52 +303,6 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return code === 'dbBatchFailed' || isRetriableError(error);
|
||||
}
|
||||
|
||||
async readWithRetry(
|
||||
label: string,
|
||||
operation: () => Promise<unknown[]>,
|
||||
opts?: {
|
||||
maxAttempts?: number;
|
||||
baseBackoffMs?: number;
|
||||
maxBackoffMs?: number;
|
||||
jitterRatio?: number;
|
||||
},
|
||||
): Promise<unknown[]> {
|
||||
const maxAttempts = opts?.maxAttempts ?? 3;
|
||||
const baseBackoffMs = opts?.baseBackoffMs ?? 100;
|
||||
const maxBackoffMs = opts?.maxBackoffMs ?? 500;
|
||||
const jitterRatio = opts?.jitterRatio ?? 0.2;
|
||||
|
||||
let attempt = 1;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (this.shutdownStarted) throw error;
|
||||
if (
|
||||
attempt >= maxAttempts ||
|
||||
!MySQLDatabaseClient.isRetriableError(error)
|
||||
)
|
||||
throw error;
|
||||
|
||||
const raw = baseBackoffMs * 2 ** (attempt - 1);
|
||||
const capped = Math.min(maxBackoffMs, raw);
|
||||
const window = Math.round(capped * jitterRatio);
|
||||
const jitter =
|
||||
window === 0
|
||||
? 0
|
||||
: Math.floor(Math.random() * (window * 2 + 1)) - window;
|
||||
const delay = Math.max(0, capped + jitter);
|
||||
|
||||
console.warn(
|
||||
`[${label}] transient mysql error (${(error as { code?: string })?.code ?? 'unknown'}); retry ${attempt + 1}/${maxAttempts} in ${delay}ms`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal pool lifecycle
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -28,6 +28,20 @@ import {
|
||||
type PostgresPoolClient,
|
||||
} from './PostgresDatabaseClient.js';
|
||||
|
||||
// Pass-through spy: the rewrite is pure, so its call count shows the memo.
|
||||
const { rewriteSpy } = vi.hoisted(() => ({ rewriteSpy: vi.fn() }));
|
||||
vi.mock('./preparePostgresSql.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./preparePostgresSql.js')>();
|
||||
return {
|
||||
...actual,
|
||||
preparePostgresSql: (sql: string) => {
|
||||
rewriteSpy(sql);
|
||||
return actual.preparePostgresSql(sql);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type QueryCall = {
|
||||
text: string;
|
||||
values?: unknown[];
|
||||
@@ -167,6 +181,23 @@ describe('PostgresDatabaseClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rewrites a repeated query once', async () => {
|
||||
const pool = new RecordingPool();
|
||||
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
|
||||
await client.onServerStart();
|
||||
const query = 'SELECT `name` FROM `apps` WHERE `id` = ?';
|
||||
rewriteSpy.mockClear();
|
||||
|
||||
await client.read(query, [1]);
|
||||
await client.pread(query, [2]);
|
||||
await client.write(query, [3]);
|
||||
|
||||
expect(rewriteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pool.calls.slice(-3).map((c) => c.text)).toEqual(
|
||||
Array(3).fill('SELECT "name" FROM "apps" WHERE "id" = $1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes int8 fields on read and primary read rows', async () => {
|
||||
const pool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
|
||||
@@ -17,16 +17,14 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { Pool, type PoolConfig, type QueryResult } from 'pg';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
import {
|
||||
AbstractDatabaseClient,
|
||||
type BatchEntry,
|
||||
type WriteResult,
|
||||
} from './DatabaseClient';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import { preparePostgresSql } from './preparePostgresSql.js';
|
||||
import { splitPostgresStatements } from './splitPostgresStatements.js';
|
||||
import type { IConfig } from '../../types';
|
||||
@@ -62,6 +60,7 @@ enum Configuration {
|
||||
}
|
||||
|
||||
const POSTGRES_INT8_OID = 20;
|
||||
const PREPARED_SQL_CACHE_SIZE = 1000;
|
||||
const INTEGER_TEXT_PATTERN = /^-?\d+$/u;
|
||||
|
||||
const normalizePostgresInt8 = (
|
||||
@@ -138,6 +137,10 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
private replicaPool!: PostgresPool;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
/** Query text as written → its Postgres rewrite, which is pure. */
|
||||
private readonly preparedSql = new BoundedTtlMap<string, string>({
|
||||
maxEntries: PREPARED_SQL_CACHE_SIZE,
|
||||
});
|
||||
|
||||
constructor(
|
||||
config: IConfig,
|
||||
@@ -164,7 +167,11 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
await this.runMigrations();
|
||||
await this.applyMigrationPaths(
|
||||
() => this.primaryPool.connect(),
|
||||
splitPostgresStatements,
|
||||
{ transactional: true },
|
||||
);
|
||||
}
|
||||
|
||||
override async onServerPrepareShutdown(): Promise<void> {
|
||||
@@ -248,98 +255,8 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
const primaryPromise = this.query(this.primaryPool, query, params);
|
||||
try {
|
||||
const replicaResult = await this.query(
|
||||
this.replicaPool,
|
||||
query,
|
||||
params,
|
||||
);
|
||||
if (replicaResult.rows.length > 0) {
|
||||
primaryPromise.catch(() => {});
|
||||
return normalizePostgresRows(replicaResult);
|
||||
}
|
||||
} catch {
|
||||
// fall through to primary
|
||||
}
|
||||
|
||||
const primaryResult = await primaryPromise;
|
||||
return normalizePostgresRows(primaryResult);
|
||||
}
|
||||
|
||||
private async runMigrations(): Promise<void> {
|
||||
const paths = this.config.database?.migrationPaths;
|
||||
if (!paths || paths.length === 0) return;
|
||||
|
||||
const conn = await this.primaryPool.connect();
|
||||
try {
|
||||
for (const rawPath of paths) {
|
||||
const dir = isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: resolvePath(process.cwd(), rawPath);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir)
|
||||
.filter(
|
||||
(f) =>
|
||||
f.endsWith('.sql') && f.startsWith('postgres'),
|
||||
)
|
||||
.sort(compareMigrationFilenames);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[postgres] migration path is unreadable: ${dir}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`[postgres] no migrations in ${dir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[postgres] running migrations from ${dir}: ${files.length} file(s)`,
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = resolvePath(dir, file);
|
||||
const contents = readFileSync(filePath, 'utf8');
|
||||
const statements = splitPostgresStatements(contents);
|
||||
await conn.query('BEGIN');
|
||||
try {
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
try {
|
||||
await conn.query(statements[i]);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[postgres] failed to apply ${file} at statement ${i}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
}
|
||||
await conn.query('COMMIT');
|
||||
} catch (e) {
|
||||
await conn.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
console.log(
|
||||
`[postgres] applied ${file} (${statements.length} statements)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
protected override hasReadReplica(): boolean {
|
||||
return this.configuration === Configuration.REPLICA;
|
||||
}
|
||||
|
||||
private createPool(dbConf: PostgresEndpointConfig): PostgresPool {
|
||||
@@ -366,8 +283,12 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<QueryResult> {
|
||||
const prepared = preparePostgresSql(query);
|
||||
return target.query(prepared.text, params);
|
||||
let text = this.preparedSql.get(query);
|
||||
if (text === undefined) {
|
||||
text = preparePostgresSql(query).text;
|
||||
this.preparedSql.set(query, text);
|
||||
}
|
||||
return target.query(text, params);
|
||||
}
|
||||
|
||||
private async closeCurrentPools(): Promise<void> {
|
||||
|
||||
@@ -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 = 86;
|
||||
const CURRENT_SCHEMA_VERSION = 89;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -1038,8 +1038,7 @@ describe('SqliteDatabaseClient — query interface', () => {
|
||||
});
|
||||
|
||||
// Single-node engines have no replica to race, so `tryHardRead` must not
|
||||
// issue the query twice — the base-class default fires `pread()` and
|
||||
// `read()` in parallel, which on sqlite is the same connection.
|
||||
// issue the query twice on the one connection.
|
||||
describe('SqliteDatabaseClient — tryHardRead', () => {
|
||||
let client: SqliteDatabaseClient;
|
||||
|
||||
@@ -1078,20 +1077,50 @@ describe('SqliteDatabaseClient — tryHardRead', () => {
|
||||
expect(readSpy).toHaveBeenCalledTimes(1);
|
||||
expect(preadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('throws from requireRead when nothing matches', async () => {
|
||||
await expect(
|
||||
client.requireRead(
|
||||
'SELECT `name` FROM `widget` WHERE `id` = ?',
|
||||
[42],
|
||||
),
|
||||
).rejects.toThrow('required read returned no rows');
|
||||
describe('SqliteDatabaseClient — prepared statements', () => {
|
||||
let client: SqliteDatabaseClient;
|
||||
let prepare: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = await bootClient();
|
||||
await client.write(
|
||||
'CREATE TABLE `widget` (`id` INTEGER PRIMARY KEY, `name` TEXT)',
|
||||
);
|
||||
// better-sqlite3 is the driver boundary; count compilations there.
|
||||
const db = (client as unknown as { db: Database.Database }).db;
|
||||
prepare = vi.spyOn(db, 'prepare');
|
||||
});
|
||||
|
||||
it('returns the rows from requireRead when something matches', async () => {
|
||||
await client.write('INSERT INTO `widget` (`name`) VALUES (?)', ['ok']);
|
||||
await expect(
|
||||
client.requireRead('SELECT `name` FROM `widget`'),
|
||||
).resolves.toEqual([{ name: 'ok' }]);
|
||||
afterEach(() => {
|
||||
client.onServerShutdown();
|
||||
});
|
||||
|
||||
it('compiles a repeated query once', async () => {
|
||||
const insert = 'INSERT INTO `widget` (`name`) VALUES (?)';
|
||||
await client.write(insert, ['a']);
|
||||
await client.write(insert, ['b']);
|
||||
await client.batchWrite([{ statement: insert, values: ['c'] }]);
|
||||
const select = 'SELECT `name` FROM `widget` ORDER BY `id`';
|
||||
await client.read(select);
|
||||
|
||||
await expect(client.read(select)).resolves.toEqual([
|
||||
{ name: 'a' },
|
||||
{ name: 'b' },
|
||||
{ name: 'c' },
|
||||
]);
|
||||
expect(prepare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps serving a cached query after the schema changes', async () => {
|
||||
const select = 'SELECT * FROM `widget`';
|
||||
await client.write('INSERT INTO `widget` (`name`) VALUES (?)', ['a']);
|
||||
await client.read(select);
|
||||
await client.write('ALTER TABLE `widget` ADD COLUMN `size` INTEGER');
|
||||
|
||||
await expect(client.read(select)).resolves.toEqual([
|
||||
{ id: 1, name: 'a', size: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,15 @@ import { existsSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { basename, dirname, extname, join, resolve } from 'path';
|
||||
import { createContext, runInContext } from 'vm';
|
||||
import type { IConfig } from '../../types';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
|
||||
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
|
||||
const STATEMENT_CACHE_SIZE = 1000;
|
||||
|
||||
type SqliteDatabase = InstanceType<typeof import('better-sqlite3')>;
|
||||
type SqliteStatement = import('better-sqlite3').Statement<unknown[]>;
|
||||
|
||||
/**
|
||||
* Ordered list of [threshold_version, files[]] pairs. A database whose
|
||||
@@ -120,13 +125,20 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[83, ['0088_apps-index-url.sql']],
|
||||
[84, ['0089_team-require-2fa.sql']],
|
||||
[85, ['0090_user-app-permission-index.sql']],
|
||||
[86, ['0091_icloud-dot-significance.sql']],
|
||||
[87, ['0092_share-entry-owner.sql']],
|
||||
[88, ['0093_fsentries-parent-uid-index.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
override readonly engineName = 'sqlite';
|
||||
|
||||
// better-sqlite3 instance — set during onServerStart
|
||||
private db!: InstanceType<typeof import('better-sqlite3')>;
|
||||
private db!: SqliteDatabase;
|
||||
/** Prepared statements by query text; SQLite re-prepares on schema change. */
|
||||
private readonly statements = new BoundedTtlMap<string, SqliteStatement>({
|
||||
maxEntries: STATEMENT_CACHE_SIZE,
|
||||
});
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
@@ -148,12 +160,14 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
this.statements.clear();
|
||||
this.db = new Database(dbPath);
|
||||
|
||||
await this.runMigrations(isNew);
|
||||
}
|
||||
|
||||
override onServerShutdown(): void {
|
||||
this.statements.clear();
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
}
|
||||
@@ -168,12 +182,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
query = this.transformQuery(query);
|
||||
params = this.transformParams(params);
|
||||
return this.db.prepare(query).all(...params) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
return this.prepare(query).all(
|
||||
...this.transformParams(params),
|
||||
) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
override async pread(
|
||||
@@ -184,25 +195,12 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
// No replica to race, so the base class's parallel
|
||||
// primary-plus-replica read would run the same statement twice on
|
||||
// the one connection.
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<WriteResult> {
|
||||
query = this.transformQuery(query);
|
||||
params = this.transformParams(params);
|
||||
|
||||
const info = this.db.prepare(query).run(...params);
|
||||
const info = this.prepare(query).run(...this.transformParams(params));
|
||||
|
||||
return {
|
||||
insertId: info.lastInsertRowid,
|
||||
@@ -218,10 +216,8 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
this.db.transaction(() => {
|
||||
for (let { statement, values } of entries) {
|
||||
statement = this.transformQuery(statement);
|
||||
values = this.transformParams(values);
|
||||
this.db.prepare(statement).run(...values);
|
||||
for (const { statement, values } of entries) {
|
||||
this.prepare(statement).run(...this.transformParams(values));
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -230,6 +226,15 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
// SQLite-specific transforms
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private prepare(query: string): SqliteStatement {
|
||||
let statement = this.statements.get(query);
|
||||
if (!statement) {
|
||||
statement = this.db.prepare(this.transformQuery(query));
|
||||
this.statements.set(query, statement);
|
||||
}
|
||||
return statement;
|
||||
}
|
||||
|
||||
private transformQuery(query: string): string {
|
||||
return query.replace(/now\(\)/gi, "datetime('now')");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Recompute `clean_email` for Apple's domains: dots are significant there, so
|
||||
-- rows written while they were stripped still collapse two mailboxes into one.
|
||||
-- Only rows still holding exactly what the old rule produced, so a NULL or a
|
||||
-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag`
|
||||
-- or case into a key another row already owns. Restores dots and nothing else.
|
||||
|
||||
UPDATE `user`
|
||||
SET `clean_email` = CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1))
|
||||
WHERE `email` IS NOT NULL
|
||||
AND LOCATE('@', `email`) > 1
|
||||
AND (
|
||||
LOWER(`email`) LIKE '%@icloud.com'
|
||||
OR LOWER(`email`) LIKE '%@me.com'
|
||||
OR LOWER(`email`) LIKE '%@mac.com'
|
||||
)
|
||||
AND `clean_email` = CONCAT(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '.', ''), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1))
|
||||
AND `clean_email` <> CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1));
|
||||
|
||||
-- The same collapse decided who a pending invite belongs to, so the holder
|
||||
-- of the undotted address claims invites addressed to the dotted one. The
|
||||
-- typed address rides along as `invitedAddress` whenever it differed.
|
||||
UPDATE `share`
|
||||
SET `recipient_email` = CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1))
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress')) IS NOT NULL
|
||||
AND LOCATE('@', JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))) > 1
|
||||
AND (
|
||||
LOWER(`recipient_email`) LIKE '%@icloud.com'
|
||||
OR LOWER(`recipient_email`) LIKE '%@me.com'
|
||||
OR LOWER(`recipient_email`) LIKE '%@mac.com'
|
||||
)
|
||||
AND `recipient_email` = CONCAT(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '.', ''), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1))
|
||||
AND `recipient_email` <> CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1));
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- The owner of the shared entry, recorded on the share row. Mirrors SQLite
|
||||
-- migration 0092. Guarded like mysql_mig_45, because there is no per-file
|
||||
-- applied-state tracking and a replay must do nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_entry_owner;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_share_entry_owner()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND COLUMN_NAME = 'entry_owner_user_id'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD COLUMN `entry_owner_user_id` BIGINT DEFAULT NULL;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_entry_owner'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD INDEX `idx_share_entry_owner` (`entry_owner_user_id`, `id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_share_entry_owner();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_entry_owner;
|
||||
|
||||
UPDATE `share` JOIN `fsentries` ON `fsentries`.`id` = `share`.`fsentry_id`
|
||||
SET `share`.`entry_owner_user_id` = `fsentries`.`user_id`
|
||||
WHERE `share`.`fsentry_id` IS NOT NULL
|
||||
AND `share`.`entry_owner_user_id` IS NULL;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Recompute `clean_email` for Apple's domains: dots are significant there, so
|
||||
-- rows written while they were stripped still collapse two mailboxes into one.
|
||||
-- Only rows still holding exactly what the old rule produced, so a NULL or a
|
||||
-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag`
|
||||
-- or case into a key another row already owns. Restores dots and nothing else.
|
||||
|
||||
UPDATE "user"
|
||||
SET clean_email = (split_part(split_part(lower(email), '@', 1), '+', 1) || '@' || split_part(lower(email), '@', 2))
|
||||
WHERE email IS NOT NULL
|
||||
AND position('@' in email) > 1
|
||||
AND (
|
||||
lower(email) LIKE '%@icloud.com'
|
||||
OR lower(email) LIKE '%@me.com'
|
||||
OR lower(email) LIKE '%@mac.com'
|
||||
)
|
||||
AND clean_email = (replace(split_part(split_part(lower(email), '@', 1), '+', 1), '.', '') || '@' || split_part(lower(email), '@', 2))
|
||||
AND clean_email IS DISTINCT FROM (split_part(split_part(lower(email), '@', 1), '+', 1) || '@' || split_part(lower(email), '@', 2));
|
||||
|
||||
-- The same collapse decided who a pending invite belongs to, so the holder
|
||||
-- of the undotted address claims invites addressed to the dotted one. The
|
||||
-- typed address rides along as `invitedAddress` whenever it differed.
|
||||
UPDATE "share"
|
||||
SET recipient_email = (split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1) || '@' || split_part(lower((data->>'invitedAddress')), '@', 2))
|
||||
WHERE (data->>'invitedAddress') IS NOT NULL
|
||||
AND position('@' in (data->>'invitedAddress')) > 1
|
||||
AND (
|
||||
lower(recipient_email) LIKE '%@icloud.com'
|
||||
OR lower(recipient_email) LIKE '%@me.com'
|
||||
OR lower(recipient_email) LIKE '%@mac.com'
|
||||
)
|
||||
AND recipient_email = (replace(split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1), '.', '') || '@' || split_part(lower((data->>'invitedAddress')), '@', 2))
|
||||
AND recipient_email IS DISTINCT FROM (split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1) || '@' || split_part(lower((data->>'invitedAddress')), '@', 2));
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- The owner of the shared entry, recorded on the share row. Mirrors SQLite
|
||||
-- migration 0092. Guarded like mysql_mig_45, because there is no per-file
|
||||
-- applied-state tracking and a replay must do nothing.
|
||||
|
||||
ALTER TABLE "share" ADD COLUMN IF NOT EXISTS entry_owner_user_id BIGINT DEFAULT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_entry_owner
|
||||
ON "share" (entry_owner_user_id, id);
|
||||
|
||||
UPDATE "share"
|
||||
SET entry_owner_user_id = f.user_id
|
||||
FROM fsentries f
|
||||
WHERE f.id = "share".fsentry_id
|
||||
AND "share".fsentry_id IS NOT NULL
|
||||
AND "share".entry_owner_user_id IS NULL;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Recompute `clean_email` for Apple's domains: dots are significant there, so
|
||||
-- rows written while they were stripped still collapse two mailboxes into one.
|
||||
-- Only rows still holding exactly what the old rule produced, so a NULL or a
|
||||
-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag`
|
||||
-- or case into a key another row already owns. Restores dots and nothing else.
|
||||
|
||||
UPDATE `user`
|
||||
SET `clean_email` = (CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1,
|
||||
instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1)
|
||||
END || substr(lower(`email`), instr(`email`, '@')))
|
||||
WHERE `email` IS NOT NULL
|
||||
AND instr(`email`, '@') > 1
|
||||
AND (
|
||||
lower(`email`) LIKE '%@icloud.com'
|
||||
OR lower(`email`) LIKE '%@me.com'
|
||||
OR lower(`email`) LIKE '%@mac.com'
|
||||
)
|
||||
AND `clean_email` = (replace(CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1,
|
||||
instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1)
|
||||
END, '.', '') || substr(lower(`email`), instr(`email`, '@')))
|
||||
AND `clean_email` <> (CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1,
|
||||
instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1)
|
||||
END || substr(lower(`email`), instr(`email`, '@')));
|
||||
|
||||
-- The same collapse decided who a pending invite belongs to, so the holder
|
||||
-- of the undotted address claims invites addressed to the dotted one. The
|
||||
-- typed address rides along as `invitedAddress` whenever it differed.
|
||||
UPDATE `share`
|
||||
SET `recipient_email` = (CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1,
|
||||
instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1)
|
||||
END || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@')))
|
||||
WHERE json_extract(`data`, '$.invitedAddress') IS NOT NULL
|
||||
AND instr(json_extract(`data`, '$.invitedAddress'), '@') > 1
|
||||
AND (
|
||||
lower(`recipient_email`) LIKE '%@icloud.com'
|
||||
OR lower(`recipient_email`) LIKE '%@me.com'
|
||||
OR lower(`recipient_email`) LIKE '%@mac.com'
|
||||
)
|
||||
AND `recipient_email` = (replace(CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1,
|
||||
instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1)
|
||||
END, '.', '') || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@')))
|
||||
AND `recipient_email` <> (CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1,
|
||||
instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1)
|
||||
END || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@')));
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- The owner of the shared entry, recorded on the share row.
|
||||
--
|
||||
-- The outbound listings answer "shares on things I own that someone else
|
||||
-- issued" by joining `share` to `fsentries` and filtering on the owner there.
|
||||
-- Neither side of that join bounds the work: driven from `fsentries` it walks
|
||||
-- every file the user owns, driven from `share` it walks every share until it
|
||||
-- has a page. `#outboundAppsSql` has no LIMIT at all.
|
||||
--
|
||||
-- Written with the row, and re-pointed when a move re-owns the subtree it
|
||||
-- names; `FSService.move` does that whenever the destination is someone
|
||||
-- else's tree.
|
||||
|
||||
ALTER TABLE `share` ADD COLUMN `entry_owner_user_id` INTEGER DEFAULT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_share_entry_owner`
|
||||
ON `share` (`entry_owner_user_id`, `id`);
|
||||
|
||||
UPDATE `share`
|
||||
SET `entry_owner_user_id` = (
|
||||
SELECT `user_id` FROM `fsentries` WHERE `fsentries`.`id` = `share`.`fsentry_id`
|
||||
)
|
||||
WHERE `fsentry_id` IS NOT NULL
|
||||
AND `entry_owner_user_id` IS NULL;
|
||||
@@ -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/>.
|
||||
|
||||
-- Directory listings and subtree walks (delete, copy) find children by
|
||||
-- `parent_uid`, the one link every row carries; with no index each lookup
|
||||
-- scans the table. MySQL and Postgres already index it.
|
||||
CREATE INDEX IF NOT EXISTS `idx_fsentries_parent_uid`
|
||||
ON `fsentries` (`parent_uid`);
|
||||
@@ -20,7 +20,17 @@
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockInstance,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DDBClient } from './DDBClient';
|
||||
|
||||
@@ -763,3 +773,391 @@ describe('DDBClient — unprocessed batch keys', () => {
|
||||
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// Answers by operation (X-Amz-Target) and records how many requests overlap, so
|
||||
// chunk concurrency and the TTL sweep's retries can be asserted.
|
||||
describe('DDBClient — batch write chunks and TTL sweep', () => {
|
||||
let server: Server;
|
||||
let endpoint: string;
|
||||
let handlers: Record<string, (body: Record<string, unknown>) => unknown>;
|
||||
let calls: string[];
|
||||
let inFlight = 0;
|
||||
let peakInFlight = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const op = String(req.headers['x-amz-target']).split('.')[1];
|
||||
calls.push(op);
|
||||
inFlight += 1;
|
||||
peakInFlight = Math.max(peakInFlight, inFlight);
|
||||
setTimeout(() => {
|
||||
inFlight -= 1;
|
||||
const reply = handlers[op]?.(JSON.parse(body)) ?? {};
|
||||
const { status = 200, ...payload } = reply as {
|
||||
status?: number;
|
||||
};
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/x-amz-json-1.0',
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
}, 20);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
inFlight = 0;
|
||||
peakInFlight = 0;
|
||||
});
|
||||
|
||||
const stubClient = (extra: Record<string, unknown> = {}) =>
|
||||
new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
|
||||
endpoint,
|
||||
...extra,
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
it('writes chunks concurrently, a bounded number at a time', async () => {
|
||||
handlers = { BatchWriteItem: () => ({ UnprocessedItems: {} }) };
|
||||
|
||||
await stubClient().batchPut(
|
||||
Array.from({ length: 200 }, (_, i) => ({
|
||||
table: TABLE,
|
||||
item: { pk: `item-${i}` },
|
||||
})),
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(8);
|
||||
expect(peakInFlight).toBeGreaterThan(1);
|
||||
expect(peakInFlight).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('stops starting chunks once one fails', async () => {
|
||||
handlers = {
|
||||
BatchWriteItem: () => ({
|
||||
status: 400,
|
||||
__type: 'com.amazon.coral.validate#ValidationException',
|
||||
message: 'bad item',
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
stubClient().batchPut(
|
||||
Array.from({ length: 250 }, (_, i) => ({
|
||||
table: TABLE,
|
||||
item: { pk: `item-${i}` },
|
||||
})),
|
||||
),
|
||||
).rejects.toThrow('bad item');
|
||||
// The first wave fails; none of the six chunks queued behind it run.
|
||||
expect(calls.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('lets chunks already in flight finish before rejecting', async () => {
|
||||
let replies = 0;
|
||||
handlers = {
|
||||
BatchWriteItem: (body) => {
|
||||
replies += 1;
|
||||
if (replies === 1) {
|
||||
return {
|
||||
status: 400,
|
||||
__type: 'com.amazon.coral.validate#ValidationException',
|
||||
message: 'bad item',
|
||||
};
|
||||
}
|
||||
// The rest need one retry, so they are still writing when the
|
||||
// failure comes back.
|
||||
return replies <= 4
|
||||
? { UnprocessedItems: body.RequestItems }
|
||||
: { UnprocessedItems: {} };
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
stubClient().batchPut(
|
||||
Array.from({ length: 100 }, (_, i) => ({
|
||||
table: TABLE,
|
||||
item: { pk: `item-${i}` },
|
||||
})),
|
||||
),
|
||||
).rejects.toThrow('bad item');
|
||||
const sentBeforeReject = calls.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Nothing is written after the caller has seen the error.
|
||||
expect(calls.length).toBe(sentBeforeReject);
|
||||
expect(inFlight).toBe(0);
|
||||
});
|
||||
|
||||
it('retries the unprocessed deletes of the TTL sweep', async () => {
|
||||
const expired = [{ pk: { S: 'old' } }];
|
||||
let batchWrites = 0;
|
||||
handlers = {
|
||||
CreateTable: () => ({
|
||||
status: 400,
|
||||
__type: 'com.amazonaws.dynamodb.v20120810#ResourceInUseException',
|
||||
message: 'Table already exists',
|
||||
}),
|
||||
Scan: () => ({ Items: expired }),
|
||||
BatchWriteItem: (body) => {
|
||||
batchWrites += 1;
|
||||
return batchWrites === 1
|
||||
? { UnprocessedItems: body.RequestItems }
|
||||
: { UnprocessedItems: {} };
|
||||
},
|
||||
};
|
||||
|
||||
await stubClient({ bootstrapTables: true }).createTableIfNotExists(
|
||||
{
|
||||
TableName: TABLE,
|
||||
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' }],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
},
|
||||
'ttl',
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
'CreateTable',
|
||||
'Scan',
|
||||
'BatchWriteItem',
|
||||
'BatchWriteItem',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Throttles are a service-side signal the emulator never produces, so these
|
||||
// drive the client against a stub answering in DynamoDB's error shape.
|
||||
describe('DDBClient — throttle logging', () => {
|
||||
let server: Server;
|
||||
let endpoint: string;
|
||||
let responses: { status: number; body: unknown }[];
|
||||
let warn: MockInstance<typeof console.warn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on('end', () => {
|
||||
const next = responses.shift() ?? { status: 200, body: {} };
|
||||
res.writeHead(next.status, {
|
||||
'content-type': 'application/x-amz-json-1.0',
|
||||
});
|
||||
res.end(JSON.stringify(next.body));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const stubClient = () =>
|
||||
new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: {
|
||||
access_key: 'a',
|
||||
secret_key: 'b',
|
||||
region: 'eu-central-1',
|
||||
},
|
||||
endpoint,
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
const throttled = (reason = 'TableWriteKeyRangeThroughputExceeded') => ({
|
||||
status: 400,
|
||||
body: {
|
||||
__type: 'com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException',
|
||||
message:
|
||||
'The level of configured provisioned throughput for the table was exceeded.',
|
||||
ThrottlingReasons: [
|
||||
{
|
||||
reason,
|
||||
resource: `arn:aws:dynamodb:eu-central-1:0:table/${TABLE}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const ok = (body: unknown = {}) => ({ status: 200, body });
|
||||
|
||||
const ddbWarnings = () =>
|
||||
warn.mock.calls
|
||||
.map(([line]) => String(line))
|
||||
.filter((line) => line.startsWith('[ddb] '));
|
||||
|
||||
it('logs the key of a throttled update that succeeds on retry', async () => {
|
||||
responses = [
|
||||
throttled(),
|
||||
ok({
|
||||
Attributes: {
|
||||
pk: { S: 'hot' },
|
||||
sk: { S: 'counter' },
|
||||
v: { N: '1' },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = await stubClient().update(
|
||||
TABLE,
|
||||
{ pk: 'hot', sk: 'counter' },
|
||||
'SET v = :v',
|
||||
{ ':v': 1 },
|
||||
);
|
||||
|
||||
expect(result.Attributes?.v).toBe(1);
|
||||
expect(ddbWarnings()).toEqual([
|
||||
'[ddb] throttled UpdateItem kv-items pk=hot sk=counter in eu-central-1: TableWriteKeyRangeThroughputExceeded',
|
||||
]);
|
||||
}, 15_000);
|
||||
|
||||
it('logs only the key attributes of a throttled put', async () => {
|
||||
const client = stubClient();
|
||||
// On a managed deployment this creates nothing but learns the key schema.
|
||||
await client.createTableIfNotExists(tableSchema);
|
||||
responses = [throttled(), ok()];
|
||||
|
||||
await client.put(TABLE, {
|
||||
pk: 'user-1',
|
||||
sk: 'settings',
|
||||
secret: 'do-not-log',
|
||||
});
|
||||
|
||||
expect(ddbWarnings()).toEqual([
|
||||
'[ddb] throttled PutItem kv-items pk=user-1 sk=settings in eu-central-1: TableWriteKeyRangeThroughputExceeded',
|
||||
]);
|
||||
expect(warn.mock.calls.flat().join('\n')).not.toContain('do-not-log');
|
||||
}, 15_000);
|
||||
|
||||
it('logs a hot key once a minute and counts the throttles in between', async () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000);
|
||||
const client = stubClient();
|
||||
const read = 'TableReadKeyRangeThroughputExceeded';
|
||||
|
||||
responses = [throttled(read), ok(), throttled(read), ok()];
|
||||
await client.get(TABLE, { pk: 'burst', sk: 'a' });
|
||||
await client.get(TABLE, { pk: 'burst', sk: 'a' });
|
||||
|
||||
now.mockReturnValue(1_000_000 + 61_000);
|
||||
responses = [throttled(read), ok()];
|
||||
await client.get(TABLE, { pk: 'burst', sk: 'a' });
|
||||
|
||||
expect(ddbWarnings()).toEqual([
|
||||
`[ddb] throttled GetItem kv-items pk=burst sk=a in eu-central-1: ${read}`,
|
||||
`[ddb] throttled GetItem kv-items pk=burst sk=a in eu-central-1: ${read} (+1 since last logged)`,
|
||||
]);
|
||||
}, 15_000);
|
||||
|
||||
it('names the partition a throttled query read', async () => {
|
||||
responses = [throttled('IndexReadKeyRangeThroughputExceeded'), ok()];
|
||||
|
||||
await stubClient().query(TABLE, { kind: 'note' }, 0, undefined, GSI);
|
||||
|
||||
expect(ddbWarnings()).toEqual([
|
||||
'[ddb] throttled Query kv-items index=by-kind kind=note in eu-central-1: IndexReadKeyRangeThroughputExceeded',
|
||||
]);
|
||||
}, 15_000);
|
||||
|
||||
it('names up to five keys of a throttled batch', async () => {
|
||||
responses = [
|
||||
throttled('TableReadKeyRangeThroughputExceeded'),
|
||||
ok({ Responses: {}, UnprocessedKeys: {} }),
|
||||
];
|
||||
|
||||
await stubClient().batchGet(
|
||||
Array.from({ length: 7 }, (_, index) => ({
|
||||
table: TABLE,
|
||||
items: { pk: `batch-${index}`, sk: 's' },
|
||||
})),
|
||||
);
|
||||
|
||||
expect(ddbWarnings()).toEqual([
|
||||
'[ddb] throttled BatchGetItem kv-items pk=batch-0 sk=s, pk=batch-1 sk=s, pk=batch-2 sk=s, pk=batch-3 sk=s, pk=batch-4 sk=s (+2 more) in eu-central-1: TableReadKeyRangeThroughputExceeded',
|
||||
]);
|
||||
}, 15_000);
|
||||
|
||||
it('logs batch items DynamoDB hands back unprocessed', async () => {
|
||||
const client = stubClient();
|
||||
await client.createTableIfNotExists(tableSchema);
|
||||
responses = [
|
||||
ok({
|
||||
UnprocessedItems: {
|
||||
[TABLE]: [
|
||||
{
|
||||
PutRequest: {
|
||||
Item: {
|
||||
pk: { S: 'left' },
|
||||
sk: { S: 'over' },
|
||||
v: { N: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
ok({ UnprocessedItems: {} }),
|
||||
];
|
||||
|
||||
await client.batchPut([
|
||||
{ table: TABLE, item: { pk: 'left', sk: 'over', v: 1 } },
|
||||
{ table: TABLE, item: { pk: 'done', sk: 'fine', v: 2 } },
|
||||
]);
|
||||
|
||||
expect(ddbWarnings()).toEqual([
|
||||
'[ddb] unprocessed BatchWriteItem kv-items pk=left sk=over in eu-central-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not log errors that are not throttles', async () => {
|
||||
responses = [
|
||||
{
|
||||
status: 400,
|
||||
body: {
|
||||
__type: 'com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException',
|
||||
message: 'The conditional request failed',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
stubClient().del(
|
||||
TABLE,
|
||||
{ pk: 'guarded', sk: 'a' },
|
||||
{ condition: { expression: 'attribute_exists(pk)' } },
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
|
||||
expect(ddbWarnings()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,8 @@ import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
@@ -50,14 +52,15 @@ import {
|
||||
isRepairableMarshallError,
|
||||
repairForMarshall,
|
||||
} from './marshallRepair.js';
|
||||
import { attachThrottleLogging } from './throttleLog.js';
|
||||
|
||||
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
|
||||
const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
|
||||
const MAX_BATCH_WRITE_ITEMS = 25;
|
||||
const MAX_BATCH_WRITE_RETRIES = 8;
|
||||
const BATCH_WRITE_RETRY_BASE_MS = 25;
|
||||
const MAX_BATCH_GET_RETRIES = 8;
|
||||
const BATCH_GET_RETRY_BASE_MS = 25;
|
||||
// Chunks of one batch write in flight at once.
|
||||
const BATCH_WRITE_CONCURRENCY = 4;
|
||||
const MAX_BATCH_RETRIES = 8;
|
||||
const BATCH_RETRY_BASE_MS = 25;
|
||||
|
||||
// In-memory mode gives each DDBClient its own unique key, which keys
|
||||
// into a fresh dynalite server. This is what test parallelism needs:
|
||||
@@ -117,21 +120,45 @@ const chunkValues = <T>(values: T[], size: number): T[][] => {
|
||||
return chunks;
|
||||
};
|
||||
|
||||
type BatchWriteItems = NonNullable<BatchWriteCommandInput['RequestItems']>;
|
||||
type WriteRequest = BatchWriteItems[string][number];
|
||||
|
||||
/** Consumed capacity summed per table across the requests of one batch. */
|
||||
class ConsumedCapacityTally {
|
||||
#byTable = new Map<string, number>();
|
||||
|
||||
add(entries: { TableName?: string; CapacityUnits?: number }[] = []) {
|
||||
for (const { TableName, CapacityUnits } of entries) {
|
||||
if (!TableName) continue;
|
||||
this.#byTable.set(
|
||||
TableName,
|
||||
(this.#byTable.get(TableName) ?? 0) +
|
||||
Number(CapacityUnits ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
entries() {
|
||||
return Array.from(this.#byTable, ([TableName, CapacityUnits]) => ({
|
||||
TableName,
|
||||
CapacityUnits,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = async (ms: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
// One caller writing an out-of-range number usually keeps doing it, so the
|
||||
// warning is per-target and throttled rather than one line per write.
|
||||
const REPAIR_WARNING_INTERVAL_MS = 60_000;
|
||||
const lastRepairWarningAt = new Map<string, number>();
|
||||
const repairWarnings = new BoundedTtlMap<string, true>({
|
||||
maxEntries: 1_000,
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
|
||||
const warnRepaired = (target: string, message: string): void => {
|
||||
const now = Date.now();
|
||||
const lastWarnedAt = lastRepairWarningAt.get(target) ?? 0;
|
||||
if (now - lastWarnedAt < REPAIR_WARNING_INTERVAL_MS) return;
|
||||
|
||||
lastRepairWarningAt.set(target, now);
|
||||
if (!repairWarnings.shouldEmit(target)) return;
|
||||
console.warn(
|
||||
`[ddb] clamped an out-of-range value in ${target}: ${message}`,
|
||||
);
|
||||
@@ -172,6 +199,7 @@ export class DDBClient extends PuterClient {
|
||||
// key per instance so parallel clients don't share state, but
|
||||
// `recreateClient()` reuses this same key and so reuses the server.
|
||||
#localPathKey: string;
|
||||
#keyNamesByTable = new Map<string, string[]>();
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
@@ -255,9 +283,7 @@ export class DDBClient extends PuterClient {
|
||||
{} as Record<string, Record<string, unknown>[]>,
|
||||
);
|
||||
|
||||
let requestItems: BatchGetCommandInput['RequestItems'] = Object.entries(
|
||||
allRequestItemsPerTable,
|
||||
).reduce(
|
||||
const requestItems = Object.entries(allRequestItemsPerTable).reduce(
|
||||
(acc, [table, keyList]) => {
|
||||
acc[table] = {
|
||||
Keys: keyList,
|
||||
@@ -270,53 +296,30 @@ export class DDBClient extends PuterClient {
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
const responsesByTable = new Map<string, Record<string, unknown>[]>();
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
const capacity = new ConsumedCapacityTally();
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_BATCH_GET_RETRIES; attempt++) {
|
||||
if (Object.keys(requestItems).length === 0) break;
|
||||
|
||||
const response = await client.send(
|
||||
new BatchGetCommand({
|
||||
RequestItems: requestItems,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [table, items] of Object.entries(
|
||||
response.Responses ?? {},
|
||||
)) {
|
||||
const existing = responsesByTable.get(table) ?? [];
|
||||
existing.push(...items);
|
||||
responsesByTable.set(table, existing);
|
||||
}
|
||||
for (const entry of response.ConsumedCapacity ?? []) {
|
||||
if (!entry.TableName) continue;
|
||||
consumedCapacityByTable.set(
|
||||
entry.TableName,
|
||||
(consumedCapacityByTable.get(entry.TableName) ?? 0) +
|
||||
Number(entry.CapacityUnits ?? 0),
|
||||
const drained = await this.#sendWithUnprocessedRetry(
|
||||
requestItems,
|
||||
async (pending) => {
|
||||
const response = await client.send(
|
||||
new BatchGetCommand({
|
||||
RequestItems: pending,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [table, items] of Object.entries(
|
||||
response.Responses ?? {},
|
||||
)) {
|
||||
const existing = responsesByTable.get(table) ?? [];
|
||||
existing.push(...items);
|
||||
responsesByTable.set(table, existing);
|
||||
}
|
||||
capacity.add(response.ConsumedCapacity);
|
||||
return response.UnprocessedKeys as typeof pending | undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const unprocessedKeys = response.UnprocessedKeys ?? {};
|
||||
if (Object.keys(unprocessedKeys).length === 0) {
|
||||
requestItems = {};
|
||||
break;
|
||||
}
|
||||
|
||||
requestItems = unprocessedKeys as NonNullable<
|
||||
BatchGetCommandInput['RequestItems']
|
||||
>;
|
||||
if (attempt < MAX_BATCH_GET_RETRIES) {
|
||||
const delayMs = Math.min(
|
||||
1000,
|
||||
BATCH_GET_RETRY_BASE_MS * 2 ** attempt,
|
||||
);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(requestItems).length > 0) {
|
||||
if (!drained) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Failed to batch get all items from DynamoDB',
|
||||
@@ -326,12 +329,7 @@ export class DDBClient extends PuterClient {
|
||||
|
||||
return {
|
||||
Responses: Object.fromEntries(responsesByTable),
|
||||
ConsumedCapacity: Array.from(consumedCapacityByTable.entries()).map(
|
||||
([TableName, CapacityUnits]) => ({
|
||||
TableName,
|
||||
CapacityUnits,
|
||||
}),
|
||||
),
|
||||
ConsumedCapacity: capacity.entries(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,122 +357,95 @@ export class DDBClient extends PuterClient {
|
||||
);
|
||||
}
|
||||
|
||||
// Shared BatchWriteItem plumbing for batchPut/batchDel: 25-item chunks,
|
||||
// UnprocessedItems retried with capped exponential backoff, consumed
|
||||
// capacity accumulated per table across every request.
|
||||
async #batchWrite(
|
||||
params: {
|
||||
table: string;
|
||||
request: NonNullable<
|
||||
BatchWriteCommandInput['RequestItems']
|
||||
>[string][number];
|
||||
}[],
|
||||
) {
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
// Shared BatchWriteItem plumbing for batchPut/batchDel and the TTL sweep:
|
||||
// 25-item chunks a few at a time, UnprocessedItems retried, consumed
|
||||
// capacity summed per table across every request.
|
||||
async #batchWrite(params: { table: string; request: WriteRequest }[]) {
|
||||
if (params.length === 0) {
|
||||
return { ConsumedCapacity: [] };
|
||||
}
|
||||
|
||||
const accumulateConsumedCapacity = (
|
||||
consumedCapacityEntries:
|
||||
| Array<{ TableName?: string; CapacityUnits?: number }>
|
||||
| undefined,
|
||||
) => {
|
||||
if (!consumedCapacityEntries) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const consumedCapacityEntry of consumedCapacityEntries) {
|
||||
const table = consumedCapacityEntry.TableName;
|
||||
if (!table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingUsage = consumedCapacityByTable.get(table) ?? 0;
|
||||
consumedCapacityByTable.set(
|
||||
table,
|
||||
existingUsage +
|
||||
Number(consumedCapacityEntry.CapacityUnits ?? 0),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
const chunks = chunkValues(params, MAX_BATCH_WRITE_ITEMS);
|
||||
const capacity = new ConsumedCapacityTally();
|
||||
// Once a chunk fails, the rest aren't started. Those in flight settle
|
||||
// before the error is thrown, so no write lands after the caller's
|
||||
// cleanup.
|
||||
let failed = false;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let requestItems = chunk.reduce(
|
||||
(acc, curr) => {
|
||||
const tableRequests = acc[curr.table] ?? [];
|
||||
tableRequests.push(curr.request);
|
||||
acc[curr.table] = tableRequests;
|
||||
return acc;
|
||||
},
|
||||
{} as NonNullable<BatchWriteCommandInput['RequestItems']>,
|
||||
);
|
||||
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt <= MAX_BATCH_WRITE_RETRIES;
|
||||
attempt++
|
||||
) {
|
||||
if (Object.keys(requestItems).length === 0) {
|
||||
break;
|
||||
const outcomes = await runWithConcurrencyLimitSettled(
|
||||
chunkValues(params, MAX_BATCH_WRITE_ITEMS),
|
||||
BATCH_WRITE_CONCURRENCY,
|
||||
async (chunk) => {
|
||||
if (failed) return;
|
||||
const requestItems: BatchWriteItems = {};
|
||||
for (const { table, request } of chunk) {
|
||||
(requestItems[table] ??= []).push(request);
|
||||
}
|
||||
|
||||
const response = await sendRepairingValues(
|
||||
requestItems,
|
||||
(itemsToWrite) =>
|
||||
client.send(
|
||||
new BatchWriteCommand({
|
||||
RequestItems: itemsToWrite,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
`a batch write to ${Object.keys(requestItems).join(', ')}`,
|
||||
);
|
||||
accumulateConsumedCapacity(
|
||||
response.ConsumedCapacity as
|
||||
| Array<{ TableName?: string; CapacityUnits?: number }>
|
||||
| undefined,
|
||||
);
|
||||
|
||||
const unprocessedItems = response.UnprocessedItems ?? {};
|
||||
if (Object.keys(unprocessedItems).length === 0) {
|
||||
requestItems = {};
|
||||
break;
|
||||
}
|
||||
|
||||
requestItems = unprocessedItems as NonNullable<
|
||||
BatchWriteCommandInput['RequestItems']
|
||||
>;
|
||||
if (attempt < MAX_BATCH_WRITE_RETRIES) {
|
||||
const delayMs = Math.min(
|
||||
1000,
|
||||
BATCH_WRITE_RETRY_BASE_MS * 2 ** attempt,
|
||||
try {
|
||||
const drained = await this.#sendWithUnprocessedRetry(
|
||||
requestItems,
|
||||
async (pending) => {
|
||||
const response = await sendRepairingValues(
|
||||
pending,
|
||||
(itemsToWrite) =>
|
||||
client.send(
|
||||
new BatchWriteCommand({
|
||||
RequestItems: itemsToWrite,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
`a batch write to ${Object.keys(pending).join(', ')}`,
|
||||
);
|
||||
capacity.add(response.ConsumedCapacity);
|
||||
return response.UnprocessedItems as
|
||||
BatchWriteItems | undefined;
|
||||
},
|
||||
);
|
||||
await sleep(delayMs);
|
||||
if (!drained) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Failed to batch write all items to DynamoDB',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
const rejected = outcomes.find(
|
||||
(outcome): outcome is PromiseRejectedResult =>
|
||||
outcome.status === 'rejected',
|
||||
);
|
||||
if (rejected) throw rejected.reason;
|
||||
|
||||
if (Object.keys(requestItems).length > 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Failed to batch write all items to DynamoDB',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
return { ConsumedCapacity: capacity.entries() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch request and resend whatever comes back unprocessed, with
|
||||
* capped exponential backoff. `send` returns the unprocessed remainder.
|
||||
* Resolves false when items are still left after the last retry.
|
||||
*/
|
||||
async #sendWithUnprocessedRetry<TItems extends object>(
|
||||
requestItems: TItems,
|
||||
send: (pending: TItems) => Promise<TItems | undefined>,
|
||||
): Promise<boolean> {
|
||||
let pending: TItems | undefined = requestItems;
|
||||
for (let attempt = 0; attempt <= MAX_BATCH_RETRIES; attempt++) {
|
||||
if (!pending || Object.keys(pending).length === 0) return true;
|
||||
pending = await send(pending);
|
||||
if (
|
||||
attempt < MAX_BATCH_RETRIES &&
|
||||
pending &&
|
||||
Object.keys(pending).length > 0
|
||||
) {
|
||||
await sleep(Math.min(1000, BATCH_RETRY_BASE_MS * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ConsumedCapacity: Array.from(consumedCapacityByTable.entries()).map(
|
||||
([TableName, CapacityUnits]) => ({
|
||||
TableName,
|
||||
CapacityUnits,
|
||||
}),
|
||||
),
|
||||
};
|
||||
return !pending || Object.keys(pending).length === 0;
|
||||
}
|
||||
|
||||
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
|
||||
@@ -633,10 +604,25 @@ export class DDBClient extends PuterClient {
|
||||
);
|
||||
}
|
||||
|
||||
/** Lets throttle logs name a put's key without logging the item. */
|
||||
registerKeySchema(
|
||||
table: string,
|
||||
keySchema: NonNullable<CreateTableCommandInput['KeySchema']>,
|
||||
) {
|
||||
this.#keyNamesByTable.set(
|
||||
table,
|
||||
keySchema.map((key) => key.AttributeName!),
|
||||
);
|
||||
}
|
||||
|
||||
async createTableIfNotExists(
|
||||
params: CreateTableCommandInput,
|
||||
ttlAttribute?: string,
|
||||
) {
|
||||
if (params.TableName && params.KeySchema) {
|
||||
this.registerKeySchema(params.TableName, params.KeySchema);
|
||||
}
|
||||
|
||||
// Real-AWS deployments provision tables externally (Terraform / IaC),
|
||||
// so we no-op there by default. Self-hosters pointing at
|
||||
// dynamodb-local opt in via `dynamo.bootstrapTables: true`.
|
||||
@@ -711,30 +697,10 @@ export class DDBClient extends PuterClient {
|
||||
);
|
||||
}
|
||||
|
||||
const ddbClient = new DynamoDBClient({
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
maxAttempts: 3,
|
||||
requestHandler: new NodeHttpHandler({
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 5000,
|
||||
httpsAgent: new httpsAgent({ keepAlive: true }),
|
||||
}),
|
||||
...(this.#ddbConfig.endpoint
|
||||
? { endpoint: this.#ddbConfig.endpoint }
|
||||
: {}),
|
||||
this.#bindClient({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
region: this.#ddbConfig.aws?.region || 'us-west-2',
|
||||
});
|
||||
|
||||
this.#documentClient = DynamoDBDocumentClient.from(ddbClient, {
|
||||
marshallOptions: {
|
||||
removeUndefinedValues: true,
|
||||
},
|
||||
unmarshallOptions: {
|
||||
wrapNumbers: clampStoredNumber,
|
||||
},
|
||||
endpoint: this.#ddbConfig.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -742,21 +708,36 @@ export class DDBClient extends PuterClient {
|
||||
const endpoint = await getOrCreateLocalDynaliteEndpoint(
|
||||
this.#localPathKey,
|
||||
);
|
||||
this.#bindClient({
|
||||
credentials: { accessKeyId: 'fake', secretAccessKey: 'fake' },
|
||||
region: 'us-west-2',
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
#bindClient({
|
||||
credentials,
|
||||
region,
|
||||
endpoint,
|
||||
}: {
|
||||
credentials: { accessKeyId: string; secretAccessKey: string };
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
}) {
|
||||
const ddbClient = new DynamoDBClient({
|
||||
credentials: {
|
||||
accessKeyId: 'fake',
|
||||
secretAccessKey: 'fake',
|
||||
},
|
||||
credentials,
|
||||
maxAttempts: 3,
|
||||
requestHandler: new NodeHttpHandler({
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 5000,
|
||||
httpsAgent: new httpsAgent({ keepAlive: true }),
|
||||
}),
|
||||
endpoint,
|
||||
region: 'us-west-2',
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
region,
|
||||
});
|
||||
attachThrottleLogging(ddbClient, region, (table) =>
|
||||
this.#keyNamesByTable.get(table),
|
||||
);
|
||||
|
||||
this.#documentClient = DynamoDBDocumentClient.from(ddbClient, {
|
||||
marshallOptions: {
|
||||
@@ -806,22 +787,18 @@ export class DDBClient extends PuterClient {
|
||||
const items = scan.Items;
|
||||
if (!items || items.length === 0) continue;
|
||||
|
||||
const chunks = chunkValues(items, MAX_BATCH_WRITE_ITEMS);
|
||||
for (const chunk of chunks) {
|
||||
await client.send(
|
||||
new BatchWriteCommand({
|
||||
RequestItems: {
|
||||
[table]: chunk.map((item) => ({
|
||||
DeleteRequest: {
|
||||
Key: Object.fromEntries(
|
||||
keyNames.map((key) => [key, item[key]]),
|
||||
),
|
||||
},
|
||||
})),
|
||||
await this.#batchWrite(
|
||||
items.map((item) => ({
|
||||
table,
|
||||
request: {
|
||||
DeleteRequest: {
|
||||
Key: Object.fromEntries(
|
||||
keyNames.map((key) => [key, item[key]]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
})),
|
||||
);
|
||||
} while (lastEvaluatedKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
|
||||
const THROTTLE_ERROR_NAMES = new Set([
|
||||
'ProvisionedThroughputExceededException',
|
||||
'ThrottlingException',
|
||||
'RequestLimitExceeded',
|
||||
]);
|
||||
|
||||
// A hot key throttles in bursts, so each target logs at most once a minute and
|
||||
// the next line says how many were folded into it.
|
||||
const LOG_INTERVAL_MS = 60_000;
|
||||
const MAX_TRACKED_TARGETS = 1_000;
|
||||
const MAX_KEYS_PER_LINE = 5;
|
||||
const MAX_VALUE_LENGTH = 120;
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
type KeyNamesFor = (table: string) => string[] | undefined;
|
||||
interface Target {
|
||||
table: string;
|
||||
keys: string;
|
||||
}
|
||||
|
||||
// No TTL: an entry past its interval still carries the count to report.
|
||||
const lastLogged = new BoundedTtlMap<string, { at: number; folded: number }>({
|
||||
maxEntries: MAX_TRACKED_TARGETS,
|
||||
});
|
||||
|
||||
const logOnce = (target: string, detail?: string): void => {
|
||||
const now = Date.now();
|
||||
const previous = lastLogged.get(target);
|
||||
if (previous && now - previous.at < LOG_INTERVAL_MS) {
|
||||
previous.folded += 1;
|
||||
return;
|
||||
}
|
||||
lastLogged.set(target, { at: now, folded: 0 });
|
||||
|
||||
const folded = previous?.folded
|
||||
? ` (+${previous.folded} since last logged)`
|
||||
: '';
|
||||
console.warn(`[ddb] ${target}${detail ? `: ${detail}` : ''}${folded}`);
|
||||
};
|
||||
|
||||
// Inputs reach this middleware marshalled and outputs arrive unmarshalled, so
|
||||
// a key value can be either shape.
|
||||
const formatValue = (value: unknown): string => {
|
||||
let text: string;
|
||||
if (value && typeof value === 'object') {
|
||||
const attr = value as { S?: string; N?: string; B?: unknown };
|
||||
text =
|
||||
attr.S ??
|
||||
attr.N ??
|
||||
(attr.B !== undefined ? '<binary>' : JSON.stringify(value));
|
||||
} else {
|
||||
text = String(value);
|
||||
}
|
||||
return text.length > MAX_VALUE_LENGTH
|
||||
? `${text.slice(0, MAX_VALUE_LENGTH)}...`
|
||||
: text;
|
||||
};
|
||||
|
||||
const formatKey = (key: unknown, names?: string[]): string => {
|
||||
if (!key || typeof key !== 'object') return '<no key>';
|
||||
const record = key as AnyRecord;
|
||||
const parts = (names ?? Object.keys(record))
|
||||
.filter((name) => name in record)
|
||||
.map((name) => `${name}=${formatValue(record[name])}`);
|
||||
return parts.length > 0 ? parts.join(' ') : '<no key>';
|
||||
};
|
||||
|
||||
// A put carries the whole item; only its key attributes are logged, never values.
|
||||
const formatItemKey = (
|
||||
table: string,
|
||||
item: unknown,
|
||||
keyNamesFor: KeyNamesFor,
|
||||
): string => {
|
||||
const names = keyNamesFor(table);
|
||||
return names ? formatKey(item, names) : '<key schema unknown>';
|
||||
};
|
||||
|
||||
const formatList = (keys: string[]): string => {
|
||||
const shown = keys.slice(0, MAX_KEYS_PER_LINE).join(', ');
|
||||
const hidden = keys.length - MAX_KEYS_PER_LINE;
|
||||
return hidden > 0 ? `${shown} (+${hidden} more)` : shown;
|
||||
};
|
||||
|
||||
const formatQueryKey = (input: AnyRecord): string => {
|
||||
const values = (input.ExpressionAttributeValues ?? {}) as AnyRecord;
|
||||
const placeholders =
|
||||
String(input.KeyConditionExpression ?? '').match(/:[\w-]+/g) ?? [];
|
||||
const parts = placeholders.map(
|
||||
(placeholder) =>
|
||||
`${placeholder.slice(1)}=${formatValue(values[placeholder])}`,
|
||||
);
|
||||
const index = input.IndexName ? `index=${input.IndexName} ` : '';
|
||||
return index + (parts.length > 0 ? parts.join(' ') : '<no key>');
|
||||
};
|
||||
|
||||
const describeBatchWrite = (
|
||||
requestItems: unknown,
|
||||
keyNamesFor: KeyNamesFor,
|
||||
): Target[] =>
|
||||
Object.entries((requestItems ?? {}) as AnyRecord).map(
|
||||
([table, requests]) => ({
|
||||
table,
|
||||
keys: formatList(
|
||||
((requests ?? []) as AnyRecord[]).map((request) => {
|
||||
const put = request.PutRequest as AnyRecord | undefined;
|
||||
if (put) return formatItemKey(table, put.Item, keyNamesFor);
|
||||
const del = request.DeleteRequest as AnyRecord | undefined;
|
||||
return formatKey(del?.Key);
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const describeBatchGet = (requestItems: unknown): Target[] =>
|
||||
Object.entries((requestItems ?? {}) as AnyRecord).map(([table, entry]) => ({
|
||||
table,
|
||||
keys: formatList(
|
||||
((entry as { Keys?: unknown[] })?.Keys ?? []).map((key) =>
|
||||
formatKey(key),
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
const describeRequest = (
|
||||
commandName: string,
|
||||
input: AnyRecord,
|
||||
keyNamesFor: KeyNamesFor,
|
||||
): Target[] => {
|
||||
const table = String(input.TableName ?? '');
|
||||
switch (commandName) {
|
||||
case 'GetItemCommand':
|
||||
case 'UpdateItemCommand':
|
||||
case 'DeleteItemCommand':
|
||||
return [{ table, keys: formatKey(input.Key) }];
|
||||
case 'PutItemCommand':
|
||||
return [
|
||||
{ table, keys: formatItemKey(table, input.Item, keyNamesFor) },
|
||||
];
|
||||
case 'QueryCommand':
|
||||
return [{ table, keys: formatQueryKey(input) }];
|
||||
case 'BatchWriteItemCommand':
|
||||
return describeBatchWrite(input.RequestItems, keyNamesFor);
|
||||
case 'BatchGetItemCommand':
|
||||
return describeBatchGet(input.RequestItems);
|
||||
default:
|
||||
return [{ table, keys: '<no key>' }];
|
||||
}
|
||||
};
|
||||
|
||||
const throttleReasons = (error: unknown): string => {
|
||||
const err = error as {
|
||||
name?: string;
|
||||
ThrottlingReasons?: { reason?: string }[];
|
||||
throttlingReasons?: { reason?: string }[];
|
||||
};
|
||||
const reasons = (err.ThrottlingReasons ?? err.throttlingReasons ?? [])
|
||||
.map((entry) => entry.reason)
|
||||
.filter(Boolean);
|
||||
return reasons.length > 0 ? reasons.join(', ') : String(err.name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Log the table and key of every throttled attempt, including the ones the SDK
|
||||
* retries through, plus batch items DynamoDB hands back unprocessed. Sits
|
||||
* inside the retry loop because the CloudWatch throttle metrics count attempts,
|
||||
* and most throttled attempts succeed on retry.
|
||||
*/
|
||||
export const attachThrottleLogging = (
|
||||
client: DynamoDBClient,
|
||||
region: string,
|
||||
keyNamesFor: KeyNamesFor,
|
||||
): void => {
|
||||
client.middlewareStack.addRelativeTo(
|
||||
<TArgs extends { input: unknown }, TResult extends { output: unknown }>(
|
||||
next: (args: TArgs) => Promise<TResult>,
|
||||
context: { commandName?: string },
|
||||
) =>
|
||||
async (args: TArgs): Promise<TResult> => {
|
||||
const commandName = context.commandName ?? '';
|
||||
const operation = commandName.replace(/Command$/, '');
|
||||
try {
|
||||
const result = await next(args);
|
||||
const output = result.output as {
|
||||
UnprocessedItems?: unknown;
|
||||
UnprocessedKeys?: unknown;
|
||||
};
|
||||
const leftovers = [
|
||||
...describeBatchWrite(
|
||||
output?.UnprocessedItems,
|
||||
keyNamesFor,
|
||||
),
|
||||
...describeBatchGet(output?.UnprocessedKeys),
|
||||
];
|
||||
for (const { table, keys } of leftovers) {
|
||||
logOnce(
|
||||
`unprocessed ${operation} ${table} ${keys} in ${region}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (THROTTLE_ERROR_NAMES.has((error as Error)?.name)) {
|
||||
const targets = describeRequest(
|
||||
commandName,
|
||||
args.input as AnyRecord,
|
||||
keyNamesFor,
|
||||
);
|
||||
for (const { table, keys } of targets) {
|
||||
logOnce(
|
||||
`throttled ${operation} ${table} ${keys} in ${region}`,
|
||||
throttleReasons(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'throttleKeyLogger',
|
||||
relation: 'after',
|
||||
toMiddleware: 'retryMiddleware',
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -425,86 +425,3 @@ describe('EmailClient — share notification templates', () => {
|
||||
expect(html).toContain('href="https://puter.test"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailClient.clean', () => {
|
||||
const clean = (email: string) => startClient().clean(email);
|
||||
|
||||
it('strips subaddressing on ordinary domains', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('ignores dots and subaddressing for gmail', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('canonicalizes googlemail onto gmail', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('keeps yahoo subaddressing, which yahoo treats as distinct', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('ignores dots for icloud aliases', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('returns anything that is not an address unchanged', () => {
|
||||
expect(clean('not-an-email')).toBe('not-an-email');
|
||||
expect(clean('@nolocal.test')).toBe('@nolocal.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailClient.validate', () => {
|
||||
it('waves everything through in a dev environment', async () => {
|
||||
const client = startClient({ env: 'dev' } as Partial<IConfig>);
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a blocked domain suffix', async () => {
|
||||
const client = startClient({
|
||||
blockedEmailDomains: ['blocked.test'],
|
||||
} as unknown as Partial<IConfig>);
|
||||
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the blocklist against the cleaned address', async () => {
|
||||
const client = startClient({
|
||||
blockedEmailDomains: ['@gmail.com'],
|
||||
} as unknown as Partial<IConfig>);
|
||||
await expect(
|
||||
client.validate('[email protected]'),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('lets a registered validator veto an address', async () => {
|
||||
const client = startClient();
|
||||
const seen: string[] = [];
|
||||
client.addValidator((email) => {
|
||||
seen.push(email);
|
||||
return !email.startsWith('spam');
|
||||
});
|
||||
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(true);
|
||||
// Validators always see the canonical form.
|
||||
expect(seen).toEqual(['[email protected]', '[email protected]']);
|
||||
});
|
||||
|
||||
it('supports asynchronous validators', async () => {
|
||||
const client = startClient();
|
||||
client.addValidator(async (email) => email !== '[email protected]');
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(false);
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -71,54 +71,12 @@ export interface SendMailOptions {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
|
||||
|
||||
interface CompiledTemplate {
|
||||
subject: ReturnType<typeof template>;
|
||||
html: ReturnType<typeof template>;
|
||||
text?: ReturnType<typeof template>;
|
||||
}
|
||||
|
||||
// -- Clean-email rules ------------------------------------------------
|
||||
|
||||
type CleanRule = (parts: { local: string; domain: string }) => {
|
||||
local: string;
|
||||
domain: string;
|
||||
};
|
||||
|
||||
const CLEAN_RULES: Record<string, CleanRule> = {
|
||||
dots_dont_matter: ({ local, domain }) => ({
|
||||
local: local.replace(/\./g, ''),
|
||||
domain,
|
||||
}),
|
||||
remove_subaddressing: ({ local, domain }) => ({
|
||||
local: local.split('+')[0],
|
||||
domain,
|
||||
}),
|
||||
};
|
||||
|
||||
const PROVIDER_RULES: Record<string, { apply: string[]; skip: string[] }> = {
|
||||
gmail: { apply: ['dots_dont_matter'], skip: [] },
|
||||
icloud: { apply: ['dots_dont_matter'], skip: [] },
|
||||
yahoo: { apply: [], skip: ['remove_subaddressing'] },
|
||||
};
|
||||
|
||||
const DOMAIN_TO_PROVIDER: Record<string, string> = {
|
||||
'gmail.com': 'gmail',
|
||||
'googlemail.com': 'gmail',
|
||||
'yahoo.com': 'yahoo',
|
||||
'yahoo.co.uk': 'yahoo',
|
||||
'yahoo.ca': 'yahoo',
|
||||
'yahoo.com.au': 'yahoo',
|
||||
'icloud.com': 'icloud',
|
||||
'me.com': 'icloud',
|
||||
'mac.com': 'icloud',
|
||||
};
|
||||
|
||||
const DOMAIN_ALIASES: Record<string, string> = {
|
||||
'googlemail.com': 'gmail.com',
|
||||
};
|
||||
|
||||
// -- EmailClient ------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -126,8 +84,8 @@ const DOMAIN_ALIASES: Record<string, string> = {
|
||||
*
|
||||
* - Template-based outbound mail (via `send`)
|
||||
* - Raw nodemailer passthrough (via `sendRaw`)
|
||||
* - Canonical-form normalization for dedup (via `clean`)
|
||||
* - Policy + extensible validation (via `validate`)
|
||||
*
|
||||
* Address normalization and the domain blocklist live in `util/email.ts`.
|
||||
*/
|
||||
export class EmailClient extends PuterClient {
|
||||
private transport: ReturnType<typeof nodemailer.createTransport> | null =
|
||||
@@ -135,7 +93,6 @@ export class EmailClient extends PuterClient {
|
||||
private compiledTemplates: Partial<
|
||||
Record<EmailTemplateName, CompiledTemplate>
|
||||
> = {};
|
||||
private validators: EmailValidator[] = [];
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
@@ -218,72 +175,6 @@ export class EmailClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Public API: clean / validate ---------------------------------
|
||||
|
||||
/**
|
||||
* Normalize an email to its canonical form for dedup comparisons. Applies
|
||||
* provider-specific rules (e.g. Gmail ignores dots in the local part) plus
|
||||
* generic subaddressing removal.
|
||||
*/
|
||||
clean(email: string): string {
|
||||
let [local, domain] = email.split('@');
|
||||
if (!local || !domain) return email;
|
||||
|
||||
if (DOMAIN_ALIASES[domain]) {
|
||||
domain = DOMAIN_ALIASES[domain];
|
||||
}
|
||||
|
||||
// Default: strip subaddressing on everything unless provider skips it
|
||||
const ruleNames = new Set<string>(['remove_subaddressing']);
|
||||
const provider = DOMAIN_TO_PROVIDER[domain];
|
||||
const rules = provider ? PROVIDER_RULES[provider] : undefined;
|
||||
|
||||
if (rules) {
|
||||
rules.apply.forEach((r) => ruleNames.add(r));
|
||||
rules.skip.forEach((r) => ruleNames.delete(r));
|
||||
}
|
||||
|
||||
let parts = { local, domain };
|
||||
for (const name of ruleNames) {
|
||||
parts = CLEAN_RULES[name](parts);
|
||||
}
|
||||
|
||||
return `${parts.local}@${parts.domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an email is allowed to be used. Checks domain blocklist
|
||||
* plus any registered validators (services can call `addValidator()` to
|
||||
* register custom policy hooks).
|
||||
*/
|
||||
async validate(email: string): Promise<boolean> {
|
||||
if (this.config.env === 'dev') return true;
|
||||
|
||||
const cleaned = this.clean(email);
|
||||
|
||||
const blocked = this.config.blockedEmailDomains;
|
||||
if (Array.isArray(blocked)) {
|
||||
for (const suffix of blocked) {
|
||||
if (cleaned.endsWith(suffix)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const validator of this.validators) {
|
||||
const ok = await validator(cleaned);
|
||||
if (!ok) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom validation hook. Services can call this during their
|
||||
* startup to veto specific emails (e.g. a disposable-email service).
|
||||
*/
|
||||
addValidator(fn: EmailValidator): void {
|
||||
this.validators.push(fn);
|
||||
}
|
||||
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
private defaultFrom(): string {
|
||||
|
||||
@@ -352,10 +352,10 @@ [email protected] immediately.
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has created a Puter account for you.</p>
|
||||
<p>Username: <b>{{username}}</b><br>
|
||||
Temporary password: <b>{{temporary_password}}</b></p>
|
||||
<p>You will be asked to choose your own password the first time you sign in.
|
||||
This temporary one stops working then, and it expires on its own if unused.</p>
|
||||
<p>Username: <b>{{username}}</b></p>
|
||||
<p>{{team_name}} will give you a temporary password. You will be asked to choose
|
||||
your own the first time you sign in. The temporary one stops working then, and it
|
||||
expires on its own if unused.</p>
|
||||
<p>What this means:</p>
|
||||
<ul>
|
||||
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
|
||||
@@ -364,6 +364,21 @@ This temporary one stops working then, and it expires on its own if unused.</p>
|
||||
as you. Choose your own password promptly.</li>
|
||||
</ul>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_account_reissued: {
|
||||
subject: 'A new temporary password for your {{team_name}} account',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has issued a new temporary password for your Puter account
|
||||
<b>{{username}}</b>. Any temporary password you were given before this one has
|
||||
stopped working.</p>
|
||||
<p>{{team_name}} will give you the new one. You will be asked to choose your own
|
||||
password the first time you sign in; the temporary one stops working then, and
|
||||
it expires on its own if unused.</p>
|
||||
<p>If you were not expecting this, ask {{team_name}} -- only they can issue it.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -136,6 +136,30 @@ describe('EventClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('repeat emits', () => {
|
||||
it('reaches listeners added after the key was first emitted', () => {
|
||||
target.emit(`${key}.a.b`, {}, {});
|
||||
const wildcard = vi.fn();
|
||||
const exact = vi.fn();
|
||||
target.on(`${key}.a.*`, wildcard);
|
||||
target.on(`${key}.a.b`, exact);
|
||||
target.emit(`${key}.a.b`, {}, {});
|
||||
expect(wildcard).toHaveBeenCalledTimes(1);
|
||||
expect(exact).toHaveBeenCalledTimes(1);
|
||||
expect(target.hasListeners(`${key}.a.b`)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not skip a listener when an earlier one unsubscribes mid-dispatch', () => {
|
||||
const second = vi.fn();
|
||||
const first = vi.fn(() => target.off(key, first));
|
||||
target.on(key, first);
|
||||
target.on(key, second);
|
||||
target.emit(key, {}, {});
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAndWait', () => {
|
||||
it('awaits async listeners before resolving', async () => {
|
||||
let resolved = false;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { extensionStore } from '../../extensions';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
@@ -28,8 +29,13 @@ import {
|
||||
MatchingEvents,
|
||||
} from './types';
|
||||
|
||||
const NO_LISTENERS: readonly EventListener[] = [];
|
||||
|
||||
export class EventClient extends PuterClient {
|
||||
#eventListeners: Partial<Record<ListenKey, EventListener[]>> = {};
|
||||
#matchKeyMemo = new BoundedTtlMap<string, readonly ListenKey[]>({
|
||||
maxEntries: 1024,
|
||||
});
|
||||
|
||||
onServerStart() {
|
||||
this.emit('serverStart', {}, {});
|
||||
@@ -63,19 +69,8 @@ export class EventClient extends PuterClient {
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
for (const matchKey of this.#matchKeys(key)) {
|
||||
for (const listener of this.#listenersFor(matchKey)) {
|
||||
this.#emitEvent(listener, key, data, meta);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +87,7 @@ export class EventClient extends PuterClient {
|
||||
*
|
||||
* Listeners run sequentially in the order they're registered so an earlier
|
||||
* handler's mutation is visible to later ones. A listener that throws is
|
||||
* logged (same as `emit`) and the chain continues.
|
||||
* logged and the chain continues, with `meta.listener_failed` set.
|
||||
*/
|
||||
async emitAndWait<T extends keyof EventMap>(
|
||||
key: T,
|
||||
@@ -102,22 +97,16 @@ export class EventClient extends PuterClient {
|
||||
// Spanned because callers block on listeners — this is where time
|
||||
// spent in extension hooks (e.g. `ip.validate`) gets attributed.
|
||||
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
for (const matchKey of this.#matchKeys(key)) {
|
||||
// A wildcard listener observes the event; it does not implement it.
|
||||
const isExact = matchKey === key;
|
||||
for (const listener of this.#listenersFor(matchKey)) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
// The chain continues, but the caller is told: a hook
|
||||
// that could not run is not a hook that approved.
|
||||
if (isExact) meta.listener_failed = true;
|
||||
console.error(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
@@ -137,13 +126,7 @@ export class EventClient extends PuterClient {
|
||||
* otherwise perfectly cheap and doesn't need this.
|
||||
*/
|
||||
hasListeners<T extends keyof EventMap>(key: T): boolean {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
for (const matchKey of this.#matchKeys(key)) {
|
||||
if (this.#eventListeners[matchKey]?.length) return true;
|
||||
if (extensionStore.events[matchKey]?.length) return true;
|
||||
}
|
||||
@@ -186,6 +169,34 @@ export class EventClient extends PuterClient {
|
||||
const idx = listeners.indexOf(callback as EventListener);
|
||||
if (idx !== -1) listeners.splice(idx, 1);
|
||||
}
|
||||
/**
|
||||
* The keys a listener can be registered under to hear `key`: each
|
||||
* `<prefix>.*` shorter than the key, then the key itself. Memoized, since
|
||||
* the same few keys are emitted on every request.
|
||||
*/
|
||||
#matchKeys(key: string): readonly ListenKey[] {
|
||||
const cached = this.#matchKeyMemo.get(key);
|
||||
if (cached) return cached;
|
||||
const parts = key.split('.');
|
||||
const matchKeys = parts.map(
|
||||
(_, i) =>
|
||||
(i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`) as ListenKey,
|
||||
);
|
||||
this.#matchKeyMemo.set(key, matchKeys);
|
||||
return matchKeys;
|
||||
}
|
||||
|
||||
#listenersFor(matchKey: ListenKey): readonly EventListener[] {
|
||||
const own = this.#eventListeners[matchKey];
|
||||
const fromExtensions = extensionStore.events[matchKey];
|
||||
if (!own?.length && !fromExtensions?.length) return NO_LISTENERS;
|
||||
// A copy, so a listener that unsubscribes mid-dispatch can't make the
|
||||
// loop skip its neighbour.
|
||||
return (own ?? []).concat(fromExtensions ?? []);
|
||||
}
|
||||
|
||||
async #emitEvent<T extends keyof EventMap>(
|
||||
listener: EventListener,
|
||||
key: T,
|
||||
|
||||
@@ -175,6 +175,7 @@ export type EventMap = {
|
||||
index_url?: string | null;
|
||||
owner_user_id?: number | null;
|
||||
name?: string | null;
|
||||
godmode?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -304,6 +305,9 @@ export type EventMap = {
|
||||
device_fingerprint: string | null;
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
// With `allowed` false, offer the card fallback in place of SMS. Only
|
||||
// honored while the fallback is on; otherwise the send proceeds.
|
||||
card_fallback_instead?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Fire-and-forget signal that a code was actually sent — the abuse
|
||||
@@ -314,6 +318,9 @@ export type EventMap = {
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
device_fingerprint: string | null;
|
||||
// Browser-signals dispatch id the client sent with this request, or
|
||||
// null when it sent none.
|
||||
dispatch_id: string | null;
|
||||
};
|
||||
'user.phone-verified': {
|
||||
user_id: number;
|
||||
@@ -333,6 +340,9 @@ export type EventMap = {
|
||||
// the abuse extension cap card-verification setups per device (across
|
||||
// accounts) before any Stripe SetupIntent is created.
|
||||
device_fingerprint: string | null;
|
||||
// 'trial' when the card is being added for a free trial; null for the
|
||||
// verification gates.
|
||||
purpose?: string | null;
|
||||
enabled: boolean | null;
|
||||
// Set false by the extension to refuse this setup (e.g. the per-device
|
||||
// setup-velocity cap); `reason` carries the opaque code. Stays true
|
||||
@@ -623,6 +633,12 @@ export type EventMap = {
|
||||
userId: number;
|
||||
generation: number;
|
||||
};
|
||||
/**
|
||||
* Someone's entry was just shared, so every process must drop its cached
|
||||
* "this owner has nothing shared" answer. `outer.pubsub.*` for the same
|
||||
* reason as the events generation bump: a per-process cache.
|
||||
*/
|
||||
'outer.pubsub.share.ownerShared': { ownerUserId: number };
|
||||
'outer.fs.write-hash': { hash: string; uuid: string };
|
||||
/**
|
||||
* Cache keys the KV read cache must stop serving, because the entries
|
||||
@@ -696,8 +712,19 @@ export type EventMap = {
|
||||
* Sessions were revoked for a user. Anything holding one of them open — a
|
||||
* live socket, say — should drop it: `revoked_at` is otherwise only read on
|
||||
* the next handshake.
|
||||
*
|
||||
* `outer.*` rather than `outer.pubsub.*` on purpose: the eviction runs
|
||||
* through the socket.io Redis adapter a cluster shares, so one node
|
||||
* disconnecting covers every node in it — fanning out to siblings would
|
||||
* just have each of them repeat the same cluster-wide disconnect.
|
||||
*/
|
||||
'auth.sessions.revoked': { user_id: number; session_uids: string[] };
|
||||
'outer.auth.sessions.revoked': {
|
||||
user_id: number;
|
||||
session_uids: string[];
|
||||
};
|
||||
|
||||
/** One access token was revoked; only its own connections should drop. */
|
||||
'auth.access-token.revoked': { token_uid: string };
|
||||
|
||||
/**
|
||||
* A grant was withdrawn, so whatever was standing on it has to be settled
|
||||
@@ -764,6 +791,12 @@ export type EventMap = {
|
||||
// `ai.cost.factor.<driverName>.<provider>:<model>`. Emitted once per model
|
||||
// per batch; the last listener to set `factor` wins.
|
||||
[K in `ai.cost.factor.${string}`]: AiCostFactorEvent;
|
||||
} & {
|
||||
// A plan gate is about to refuse a caller. Keyed per surface the way the
|
||||
// lifecycles above are: `subscription.gate.route.<method>.<path>`,
|
||||
// `subscription.gate.driver.<iface>.<method>`. Emitted via `emitAndWait`;
|
||||
// a listener that sets `allow` waives the plan requirement for that call.
|
||||
[K in `subscription.gate.${string}`]: SubscriptionGateEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
} & {
|
||||
@@ -784,6 +817,18 @@ export type EventMap = {
|
||||
'outer.pubsub.metering.credits-changed': { userUuid: string };
|
||||
} & IExtensionEventMap;
|
||||
|
||||
/** Payload for `subscription.gate.<surface>` events. */
|
||||
export type SubscriptionGateEvent = {
|
||||
actor: Actor;
|
||||
/** The key after `subscription.gate.`, e.g. `driver.puter-kvstore.get`. */
|
||||
surface: string;
|
||||
/** What the gate asked for: any paid plan (`true`) or these policy ids. */
|
||||
requirement: boolean | readonly string[];
|
||||
/** The request being gated, when there is one. */
|
||||
req?: ExpressRequest;
|
||||
allow: boolean;
|
||||
};
|
||||
|
||||
/** Payload for `ai.cost.factor.<driver>.<model>` events. */
|
||||
export type AiCostFactorEvent = {
|
||||
/** Driver doing the pricing, e.g. `ai-chat`. */
|
||||
@@ -874,11 +919,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
@@ -887,7 +931,11 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
|
||||
? Extract<EventKey, `${Prefix}.${string}`>
|
||||
: P & EventKey;
|
||||
|
||||
export type EventMetadata = { from_outside?: boolean };
|
||||
export type EventMetadata = {
|
||||
from_outside?: boolean;
|
||||
/** Set by `emitAndWait` when a listener threw, so a verdict hook can refuse. */
|
||||
listener_failed?: boolean;
|
||||
};
|
||||
export type EventListener<K extends EventKey = EventKey> = (
|
||||
key: K,
|
||||
data: EventMap[K],
|
||||
|
||||
@@ -17,25 +17,21 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
|
||||
import {
|
||||
isAccessTokenActor,
|
||||
isAccountContext,
|
||||
isAppActor,
|
||||
} from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { driversContainers } from '../../exports.js';
|
||||
import {
|
||||
APP_ICON_SIZES,
|
||||
ICON_DATA_URL_MIME_ALLOWLIST,
|
||||
isTrustedIconHost,
|
||||
} from '../../util/appIcon.js';
|
||||
import { resolvePrivateLaunchAccess } from '../../util/privateLaunchAccess.js';
|
||||
import { toAppReadView } from '../../util/appView.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import DEFAULT_APP_ICON from './default-app-icon.js';
|
||||
|
||||
/**
|
||||
* REST endpoints for app management.
|
||||
*
|
||||
* Delegates to AppDriver for the actual CRUD + permission logic — these routes
|
||||
* are just thin shape adapters that translate REST conventions into driver
|
||||
* calls.
|
||||
*/
|
||||
/**
|
||||
* Desktop boot reads the app list and individual app records repeatedly, so the
|
||||
* ceiling is set well above normal boot traffic and exists to catch a runaway
|
||||
@@ -74,11 +70,14 @@ const APP_ICON_LIMIT = {
|
||||
const APP_LOOKUP_MAX_ENTRIES = 200;
|
||||
const APP_LOOKUP_MAX_SELECTOR_LEN = 200;
|
||||
|
||||
export class AppController extends PuterController {
|
||||
get appStore() {
|
||||
return this.stores.app;
|
||||
}
|
||||
/** What `/apps/:name` reports for an app no launch gate applies to. */
|
||||
const PUBLIC_APP_ACCESS = { hasAccess: true, checkedBy: 'core/public-app' };
|
||||
|
||||
/**
|
||||
* REST endpoints for app management. CRUD goes through the apps driver; the
|
||||
* read routes use AppService for visibility and views, as the driver does.
|
||||
*/
|
||||
export class AppController extends PuterController {
|
||||
// In-flight background app-open writes. Tracked only so tests and
|
||||
// shutdown can wait for them — the request path never does.
|
||||
#pendingOpenWrites = new Set();
|
||||
@@ -130,15 +129,6 @@ export class AppController extends PuterController {
|
||||
await Promise.allSettled([...this.#pendingOpenWrites]);
|
||||
}
|
||||
|
||||
get appDriver() {
|
||||
// Drivers are wired into the shared driversContainers export by
|
||||
// PuterServer at boot. Controllers get them lazily via this getter
|
||||
// since they're instantiated before drivers in the boot order.
|
||||
const d = driversContainers.apps;
|
||||
if (!d) throw new Error('AppDriver not registered yet');
|
||||
return d;
|
||||
}
|
||||
|
||||
registerRoutes(
|
||||
/** @type {import('../../core/http/PuterRouter').PuterRouter} */ router,
|
||||
) {
|
||||
@@ -152,7 +142,7 @@ export class AppController extends PuterController {
|
||||
rateLimit: APP_READ_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const apps = await this.appDriver.select({
|
||||
const apps = await this.drivers.apps.select({
|
||||
predicate: ['user-can-edit'],
|
||||
});
|
||||
res.json(apps);
|
||||
@@ -184,7 +174,7 @@ export class AppController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const available = await this.appDriver.isNameAvailable(name);
|
||||
const available = await this.drivers.apps.isNameAvailable(name);
|
||||
res.json({ name, available });
|
||||
},
|
||||
);
|
||||
@@ -195,10 +185,10 @@ export class AppController extends PuterController {
|
||||
// before 400-ing for a missing body field.
|
||||
//
|
||||
// Authorization: only two callers are trusted to report opens —
|
||||
// 1. a root user actor (plain session, no `.app` and no access
|
||||
// 1. the account acting as itself (plain session or full-access
|
||||
// token), e.g. the GUI launching apps on behalf of the user;
|
||||
// 2. the app-under-user actor for the app being reported.
|
||||
// Everything else — access tokens (regardless of issuer), asset
|
||||
// Everything else — scoped and app-issued access tokens, asset
|
||||
// tokens, app actors reporting for a *different* app — is denied,
|
||||
// otherwise any authenticated party could inflate another app's
|
||||
// open count.
|
||||
@@ -223,14 +213,11 @@ export class AppController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// Access tokens (and any other non-user/non-app identity,
|
||||
// e.g. asset tokens) are not allowed to report opens —
|
||||
// they're shared / scoped credentials and shouldn't drive
|
||||
// analytics counters.
|
||||
if (isAccessTokenActor(actor)) {
|
||||
// Shared credentials shouldn't drive analytics counters.
|
||||
if (isAccessTokenActor(actor) && !isAccountContext(actor)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Access tokens cannot report app opens',
|
||||
'This token cannot report app opens',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
@@ -243,7 +230,7 @@ export class AppController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const app = await this.appStore.getByUid(app_uid);
|
||||
const app = await this.stores.app.getByUid(app_uid);
|
||||
if (!app)
|
||||
throw new HttpError(404, 'App not found', {
|
||||
legacyCode: 'not_found',
|
||||
@@ -279,39 +266,32 @@ export class AppController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const userUid = req.actor?.user?.uuid ?? null;
|
||||
|
||||
const results = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
if (name.length > APP_LOOKUP_MAX_SELECTOR_LEN) {
|
||||
return null;
|
||||
}
|
||||
const app = await this.appStore.getByName(name);
|
||||
if (!app) return null;
|
||||
let shaped;
|
||||
try {
|
||||
shaped = await this.appDriver.read({
|
||||
uid: app.uid,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const privateAccess = await resolvePrivateLaunchAccess({
|
||||
app: shaped,
|
||||
eventClient: this.clients.event,
|
||||
userUid,
|
||||
source: 'appsRoute',
|
||||
args: req.query ?? {},
|
||||
const actor = req.actor;
|
||||
const appsByName = await this.stores.app.getByNames(
|
||||
names.filter(
|
||||
(name) => name.length <= APP_LOOKUP_MAX_SELECTOR_LEN,
|
||||
),
|
||||
);
|
||||
const readable = await this.services.app.filterReadable(
|
||||
[...new Set(appsByName.values())],
|
||||
actor,
|
||||
);
|
||||
const views = await this.services.app.views(readable, actor, {
|
||||
source: 'appsRoute',
|
||||
});
|
||||
const shapedByUid = new Map(
|
||||
readable.map((app, i) => {
|
||||
const shaped = toAppReadView(app, views[i], {
|
||||
viewer: actor.user,
|
||||
});
|
||||
return {
|
||||
...shaped,
|
||||
privateAccess:
|
||||
shaped.privateAccess?.hasAccess === false
|
||||
? shaped.privateAccess
|
||||
: privateAccess,
|
||||
};
|
||||
shaped.privateAccess ??= PUBLIC_APP_ACCESS;
|
||||
return [app.uid, shaped];
|
||||
}),
|
||||
);
|
||||
const results = names.map(
|
||||
(name) =>
|
||||
shapedByUid.get(appsByName.get(name)?.uid) ?? null,
|
||||
);
|
||||
|
||||
// Single-name requests return the app directly; batch returns an array
|
||||
if (names.length === 1) {
|
||||
@@ -331,8 +311,8 @@ export class AppController extends PuterController {
|
||||
//
|
||||
// Access rules: only apps the caller has a legitimate reason to
|
||||
// see are returned — public (`approved_for_listing`), owned by
|
||||
// the caller, or explicitly accessible via AppDriver.read (for
|
||||
// protected apps with a granted permission). Everything else is
|
||||
// the caller, or readable per `AppService.filterReadable` (for
|
||||
// protected apps, a granted permission). Everything else is
|
||||
// silently skipped so the endpoint can't be used to enumerate
|
||||
// existence of private / unapproved apps by guessing names.
|
||||
//
|
||||
@@ -359,63 +339,54 @@ export class AppController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const actorUserId = req.actor?.user?.id ?? null;
|
||||
const results = [];
|
||||
const actor = req.actor;
|
||||
const selectors = appList.filter(
|
||||
(selector) =>
|
||||
typeof selector === 'string' &&
|
||||
selector.length > 0 &&
|
||||
selector.length <= APP_LOOKUP_MAX_SELECTOR_LEN,
|
||||
);
|
||||
const isUid = (selector) => selector.startsWith('app-');
|
||||
const [byUid, byName] = await Promise.all([
|
||||
this.stores.app.getByUids(selectors.filter(isUid)),
|
||||
this.stores.app.getByNames(
|
||||
selectors.filter((s) => !isUid(s)),
|
||||
),
|
||||
]);
|
||||
const found = selectors
|
||||
.map((s) => (isUid(s) ? byUid.get(s) : byName.get(s)))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const selector of appList) {
|
||||
if (
|
||||
typeof selector !== 'string' ||
|
||||
selector.length === 0 ||
|
||||
selector.length > APP_LOOKUP_MAX_SELECTOR_LEN
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const isUid = selector.startsWith('app-');
|
||||
const app = isUid
|
||||
? await this.appStore.getByUid(selector)
|
||||
: await this.appStore.getByName(selector);
|
||||
if (!app) continue;
|
||||
|
||||
const isOwner =
|
||||
actorUserId !== null &&
|
||||
app.owner_user_id === actorUserId;
|
||||
const isApproved = Boolean(app.approved_for_listing);
|
||||
|
||||
if (!isOwner && !isApproved) {
|
||||
// Unapproved, non-owned — only surface if the
|
||||
// caller has an explicit grant (purchased /
|
||||
// permissioned). AppDriver.read enforces that
|
||||
// via #canReadApp; a thrown 403 means "not
|
||||
// accessible" and we treat it as "not found".
|
||||
try {
|
||||
const shaped = await this.appDriver.read({
|
||||
uid: app.uid,
|
||||
});
|
||||
if (!shaped) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const assocRows = await this.clients.db.read(
|
||||
'SELECT `type` FROM `app_filetype_association` WHERE `app_id` = ?',
|
||||
[app.id],
|
||||
// Unapproved apps the caller doesn't own surface only when
|
||||
// the caller may read them (one batched grant check).
|
||||
const isListed = (app) =>
|
||||
app.owner_user_id === actor.user?.id ||
|
||||
Boolean(app.approved_for_listing);
|
||||
const readable = new Set(
|
||||
await this.services.app.filterReadable(
|
||||
[...new Set(found.filter((app) => !isListed(app)))],
|
||||
actor,
|
||||
),
|
||||
);
|
||||
const visible = found.filter(
|
||||
(app) => isListed(app) || readable.has(app),
|
||||
);
|
||||
const filetypesByAppId =
|
||||
await this.stores.app.getFiletypeAssociationsByIds(
|
||||
visible.map((app) => app.id),
|
||||
);
|
||||
|
||||
results.push({
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
description: app.description,
|
||||
metadata: app.metadata,
|
||||
tags:
|
||||
typeof app.tags === 'string'
|
||||
? app.tags.split(',')
|
||||
: [],
|
||||
created: app.timestamp,
|
||||
associations: assocRows.map((r) => r.type),
|
||||
});
|
||||
}
|
||||
const results = visible.map((app) => ({
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
description: app.description,
|
||||
metadata: app.metadata,
|
||||
tags:
|
||||
typeof app.tags === 'string' ? app.tags.split(',') : [],
|
||||
created: app.timestamp,
|
||||
associations: filetypesByAppId.get(app.id) ?? [],
|
||||
}));
|
||||
|
||||
res.json(results);
|
||||
},
|
||||
@@ -473,7 +444,7 @@ export class AppController extends PuterController {
|
||||
}
|
||||
if (!appUid.startsWith('app-')) appUid = `app-${appUid}`;
|
||||
|
||||
const app = await this.appStore.getByUid(appUid);
|
||||
const app = await this.stores.app.getByUid(appUid);
|
||||
const icon = app?.icon;
|
||||
|
||||
// If the icon isn't an inline data URL, try to serve it from the
|
||||
|
||||
@@ -618,6 +618,48 @@ describe('AppController GET /apps/:name', () => {
|
||||
expect(body.index_url).toBeUndefined();
|
||||
expect(body.privateAccess).toMatchObject({ hasAccess: false });
|
||||
});
|
||||
|
||||
it('asks the private-access hook once per private app and withholds its index_url', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const app = await createApp(owner.actor, { name: uniqueName('priv') });
|
||||
const other = await createApp(owner.actor, { name: uniqueName('pub') });
|
||||
await server.clients.db.write(
|
||||
'UPDATE `apps` SET `is_private` = 1 WHERE `uid` = ?',
|
||||
[app.uid],
|
||||
);
|
||||
await server.stores.app.invalidateByUid(app.uid as string);
|
||||
const emit = vi.spyOn(server.clients.event, 'emitAndWait');
|
||||
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(stranger.actor, () =>
|
||||
callRoute(
|
||||
'get',
|
||||
'/apps/:name',
|
||||
makeReq({
|
||||
params: { name: `${app.name}|${other.name}` },
|
||||
actor: stranger.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const [priv, pub] = captured.body as Array<Record<string, unknown>>;
|
||||
expect(priv!.index_url).toBeUndefined();
|
||||
expect(priv!.privateAccess).toMatchObject({ hasAccess: false });
|
||||
expect(pub!.privateAccess).toEqual({
|
||||
hasAccess: true,
|
||||
checkedBy: 'core/public-app',
|
||||
});
|
||||
expect(
|
||||
emit.mock.calls.filter(
|
||||
(call) => call[0] === 'app.privateAccess.resolveLaunch',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
emit.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /query/app ─────────────────────────────────────────────────
|
||||
@@ -792,6 +834,61 @@ describe('AppController POST /query/app', () => {
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]?.name).toBe(app.name);
|
||||
});
|
||||
|
||||
it('resolves every selector with batched lookups, not one per selector', async () => {
|
||||
const owner = await makeUser();
|
||||
const apps = await Promise.all(
|
||||
[0, 1, 2].map(() =>
|
||||
createApp(owner.actor, {
|
||||
name: uniqueName('batch'),
|
||||
filetype_associations: ['.batchq'],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const appStore = server.stores.app;
|
||||
const spies = {
|
||||
getByName: vi.spyOn(appStore, 'getByName'),
|
||||
getByUid: vi.spyOn(appStore, 'getByUid'),
|
||||
getFiletypeAssociations: vi.spyOn(
|
||||
appStore,
|
||||
'getFiletypeAssociations',
|
||||
),
|
||||
getByNames: vi.spyOn(appStore, 'getByNames'),
|
||||
getByUids: vi.spyOn(appStore, 'getByUids'),
|
||||
getFiletypeAssociationsByIds: vi.spyOn(
|
||||
appStore,
|
||||
'getFiletypeAssociationsByIds',
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(owner.actor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
'/query/app',
|
||||
makeReq({
|
||||
body: [apps[0]!.name, apps[1]!.name, apps[2]!.uid],
|
||||
actor: owner.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = captured.body as Array<Record<string, unknown>>;
|
||||
expect(body.map((item) => item.uuid)).toEqual(
|
||||
apps.map((app) => app.uid),
|
||||
);
|
||||
expect(body[2]!.associations).toEqual(['batchq']);
|
||||
expect(spies.getByName).not.toHaveBeenCalled();
|
||||
expect(spies.getByUid).not.toHaveBeenCalled();
|
||||
expect(spies.getFiletypeAssociations).not.toHaveBeenCalled();
|
||||
expect(spies.getByNames).toHaveBeenCalledTimes(1);
|
||||
expect(spies.getByUids).toHaveBeenCalledTimes(1);
|
||||
expect(spies.getFiletypeAssociationsByIds).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
for (const spy of Object.values(spies)) spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /app-icon/:app_uid(/:size) ──────────────────────────────────
|
||||
@@ -948,6 +1045,68 @@ describe('AppController POST /rao actor gating', () => {
|
||||
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
|
||||
});
|
||||
|
||||
it('records an open for a full-access token — the account itself', async () => {
|
||||
const owner = await makeUser();
|
||||
const app = await createApp(owner.actor);
|
||||
const patActor = makeActor({
|
||||
user: owner.actor.user,
|
||||
accessToken: {
|
||||
uid: uuidv4(),
|
||||
issuer: owner.actor,
|
||||
authorized: null,
|
||||
fullAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(patActor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
'/rao',
|
||||
makeReq({ body: { app_uid: app.uid }, actor: patActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
await server.controllers.apps.drainPendingAppOpens();
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT `app_uid` FROM `app_opens` WHERE `app_uid` = ? AND `user_id` = ?',
|
||||
[app.uid, owner.userId],
|
||||
)) as unknown[];
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses a full-access claim on an app-issued token', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const app = await createApp(actor);
|
||||
const issuer = makeActor({ ...actor, app: { uid: app.uid as string } });
|
||||
const tokenActor = makeActor({
|
||||
user: actor.user,
|
||||
accessToken: {
|
||||
uid: uuidv4(),
|
||||
issuer,
|
||||
authorized: null,
|
||||
fullAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(tokenActor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
'/rao',
|
||||
makeReq({
|
||||
body: { app_uid: app.uid },
|
||||
actor: tokenActor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
|
||||
});
|
||||
|
||||
it("refuses to let an app actor record another app's open", async () => {
|
||||
const { actor } = await makeUser();
|
||||
const own = await createApp(actor);
|
||||
|
||||
@@ -612,3 +612,71 @@ describe('2FA enrollment over HTTP', () => {
|
||||
expect(released.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A temp account re-attaches through `/signup` only from a session that ran
|
||||
* out, not from one that was revoked.
|
||||
*/
|
||||
describe('temp-account reauth over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
}, 120_000);
|
||||
|
||||
const signup = (body: Record<string, unknown>) =>
|
||||
fetch(new URL('/signup', env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it('a revoked temp session cannot be revived with its reauth_token', async () => {
|
||||
const first = await signup({ is_temp: true });
|
||||
expect(first.status).toBe(200);
|
||||
const { token } = (await first.json()) as { token: string };
|
||||
|
||||
const decoded = env.server.services.token.verify('auth', token) as {
|
||||
session_uid: string;
|
||||
};
|
||||
await env.server.services.auth.revokeSession(decoded.session_uid);
|
||||
|
||||
const who = await fetch(new URL('/whoami', env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(who.status).toBe(401);
|
||||
const { reauth_token } = (await who.json()) as {
|
||||
reauth_token?: string;
|
||||
};
|
||||
expect(typeof reauth_token).toBe('string');
|
||||
|
||||
const revive = await signup({ is_temp: true, reauth_token });
|
||||
expect(revive.status).toBe(401);
|
||||
});
|
||||
|
||||
it('an expired temp session re-attaches to the same account', async () => {
|
||||
const first = await signup({ is_temp: true });
|
||||
expect(first.status).toBe(200);
|
||||
const { user } = (await first.json()) as { user: { uuid: string } };
|
||||
|
||||
const reauth_token = env.server.services.auth.signReauthToken(
|
||||
user.uuid,
|
||||
'session_expired',
|
||||
);
|
||||
const again = await signup({ is_temp: true, reauth_token });
|
||||
expect(again.status).toBe(200);
|
||||
const body = (await again.json()) as {
|
||||
token: string;
|
||||
user: { uuid: string };
|
||||
};
|
||||
expect(body.user.uuid).toBe(user.uuid);
|
||||
const who = await fetch(new URL('/whoami', env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${body.token}` },
|
||||
});
|
||||
expect(who.status).toBe(200);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,426 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { isAccountContext } from '../../core/actor.js';
|
||||
import type { AccessTokenPayload } from '../../services/auth/types.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
// Seeded with godmode on.
|
||||
const DEV_CENTER = 'app-0b37f054-07d4-4627-8765-11bd23e889d4';
|
||||
const TWELVE_HOURS = 12 * 60 * 60;
|
||||
|
||||
/**
|
||||
* A godmode app launched from the desktop runs on a full-access token of its
|
||||
* own: tied to the desktop session that asked for it, carrying the app for
|
||||
* attribution, and expiring unless the desktop asks again.
|
||||
*/
|
||||
describe('godmode app tokens', () => {
|
||||
let env: PuterTestEnv;
|
||||
let userId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
const row = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
userId = row!.id;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const mint = async (sessionToken: string, appUid = DEV_CENTER) => {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/get-user-app-token',
|
||||
sessionToken,
|
||||
{ app_uid: appUid },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as {
|
||||
token: string | null;
|
||||
app_uid: string;
|
||||
godmode?: boolean;
|
||||
expires_at?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const decode = (token: string) =>
|
||||
env.server.services.token.verify<AccessTokenPayload>('auth', token);
|
||||
|
||||
const live = async (token: string) =>
|
||||
(await env.server.services.auth.authenticate(token)).actor !== undefined;
|
||||
|
||||
/** A read token minted the way `getReadURL()` does, by `minter`. */
|
||||
const mintScoped = async (minter: string) => {
|
||||
const res = await call('POST', '/auth/create-access-token', minter, {
|
||||
permissions: ['service:foo:ii:read'],
|
||||
expiresIn: '7d',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
/** Rewrite a session row in place, as time passing would. */
|
||||
const setRow = async (uuid: string, fields: Record<string, number>) => {
|
||||
const cols = Object.keys(fields);
|
||||
await env.server.clients.db.write(
|
||||
`UPDATE \`sessions\` SET ${cols.map((c) => `\`${c}\` = ?`).join(', ')} WHERE \`uuid\` = ?`,
|
||||
[...cols.map((c) => fields[c]), uuid],
|
||||
);
|
||||
await env.server.stores.session.publishCacheKeys({
|
||||
keys: [`sessions:v2:uuid:${uuid}`],
|
||||
});
|
||||
};
|
||||
|
||||
/** A desktop session of its own, so revoking it leaves the shared one up. */
|
||||
const newDesktopSession = async () => {
|
||||
const user = await env.server.stores.user.getById(userId);
|
||||
return env.server.services.auth.createSessionToken(user!);
|
||||
};
|
||||
|
||||
/** An app row of its own, so demoting it disturbs nothing else. */
|
||||
const makeApp = async ({
|
||||
name = `gm-${crypto.randomUUID().slice(0, 8)}`,
|
||||
godmode = true,
|
||||
} = {}) => {
|
||||
const uid = `app-${crypto.randomUUID()}`;
|
||||
await env.server.clients.db.write(
|
||||
'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`, `godmode`, `timestamp`) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
[uid, userId, name, 'Test App', `https://${name}.test/`, godmode ? 1 : 0],
|
||||
);
|
||||
return { uid, name };
|
||||
};
|
||||
|
||||
it('issues a full-access token that carries the app', async () => {
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const body = await mint(env.users.user.token);
|
||||
|
||||
expect(body).toMatchObject({ app_uid: DEV_CENTER, godmode: true });
|
||||
expect(body.expires_at).toBeGreaterThanOrEqual(before + TWELVE_HOURS);
|
||||
|
||||
const claims = decode(body.token!);
|
||||
expect(claims).toMatchObject({
|
||||
type: 'access-token',
|
||||
full_access: true,
|
||||
godmode_app_uid: DEV_CENTER,
|
||||
});
|
||||
expect(claims.app_uid).toBeUndefined();
|
||||
// The row's expiry is what lapses; the JWT itself never does.
|
||||
expect(claims.exp).toBeUndefined();
|
||||
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
body.token!,
|
||||
);
|
||||
expect(actor).toBeDefined();
|
||||
expect(isAccountContext(actor!)).toBe(true);
|
||||
expect(actor!.effectiveApp).toBeNull();
|
||||
expect(actor!.accessToken).toMatchObject({
|
||||
fullAccess: true,
|
||||
godmodeApp: { uid: DEV_CENTER },
|
||||
});
|
||||
});
|
||||
|
||||
it('reaches the account surface but not account management', async () => {
|
||||
const { token } = await mint(env.users.user.token);
|
||||
|
||||
expect((await call('GET', '/whoami', token!)).status).toBe(200);
|
||||
expect((await call('GET', '/get-dev-profile', token!)).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect((await call('GET', '/auth/list-sessions', token!)).status).toBe(
|
||||
403,
|
||||
);
|
||||
// It can't mint its own replacement; only the desktop can.
|
||||
const self = await call('POST', '/auth/get-user-app-token', token!, {
|
||||
app_uid: DEV_CENTER,
|
||||
});
|
||||
expect(self.status).toBe(403);
|
||||
});
|
||||
|
||||
it('keeps one token per desktop session across renewals', async () => {
|
||||
const desktop = await newDesktopSession();
|
||||
const first = await mint(desktop.gui_token);
|
||||
const second = await mint(desktop.gui_token);
|
||||
|
||||
expect(decode(second.token!).token_uid).toBe(
|
||||
decode(first.token!).token_uid,
|
||||
);
|
||||
// Asked again within the hour: the row keeps the expiry it has.
|
||||
expect(second.expires_at).toBe(first.expires_at);
|
||||
// A copy taken at launch keeps working after the desktop renews.
|
||||
expect(await live(first.token!)).toBe(true);
|
||||
|
||||
const row = await env.server.stores.session.getByUuidAny(
|
||||
decode(second.token!).session_uid!,
|
||||
);
|
||||
expect(row).toMatchObject({
|
||||
kind: 'access_token',
|
||||
app_uid: DEV_CENTER,
|
||||
expires_at: second.expires_at,
|
||||
});
|
||||
});
|
||||
|
||||
it('is extended once the desktop asks with under eleven hours left', async () => {
|
||||
const desktop = await newDesktopSession();
|
||||
const first = await mint(desktop.gui_token);
|
||||
const sessionUid = decode(first.token!).session_uid!;
|
||||
await setRow(sessionUid, {
|
||||
expires_at: Math.floor(Date.now() / 1000) + 2 * 60 * 60,
|
||||
});
|
||||
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const renewed = await mint(desktop.gui_token);
|
||||
expect(decode(renewed.token!).token_uid).toBe(
|
||||
decode(first.token!).token_uid,
|
||||
);
|
||||
expect(renewed.expires_at).toBeGreaterThanOrEqual(
|
||||
before + TWELVE_HOURS,
|
||||
);
|
||||
const row = await env.server.stores.session.getByUuidAny(sessionUid);
|
||||
expect(row?.expires_at).toBe(renewed.expires_at);
|
||||
});
|
||||
|
||||
it('is never extended by being used', async () => {
|
||||
const desktop = await newDesktopSession();
|
||||
const { token, expires_at } = await mint(desktop.gui_token);
|
||||
expect((await call('GET', '/whoami', token!)).status).toBe(200);
|
||||
const row = await env.server.stores.session.getByUuidAny(
|
||||
decode(token!).session_uid!,
|
||||
);
|
||||
expect(row?.expires_at).toBe(expires_at);
|
||||
});
|
||||
|
||||
it('lists the token under its app for the account', async () => {
|
||||
await mint(env.users.user.token);
|
||||
const res = await call(
|
||||
'GET',
|
||||
'/auth/list-sessions',
|
||||
env.users.user.token,
|
||||
);
|
||||
const sessions = (await res.json()) as Array<{
|
||||
kind: string;
|
||||
app_uid: string | null;
|
||||
app: { name?: string } | null;
|
||||
}>;
|
||||
const row = sessions.find(
|
||||
(s) => s.kind === 'access_token' && s.app_uid === DEV_CENTER,
|
||||
);
|
||||
expect(row?.app?.name).toBe('dev-center');
|
||||
});
|
||||
|
||||
it('installs the app like any other launch, and uninstalls the same way', async () => {
|
||||
const { uid } = await makeApp();
|
||||
const app = await env.server.stores.app.getByUid(uid);
|
||||
const installed = () =>
|
||||
env.server.stores.permission.hasUserAppPerm(
|
||||
userId,
|
||||
app!.id,
|
||||
'flag:app-is-authenticated',
|
||||
);
|
||||
|
||||
const { token } = await mint(env.users.user.token, uid);
|
||||
expect(await installed()).toBe(true);
|
||||
expect(
|
||||
await env.server.stores.fsEntry.getEntryByPath(
|
||||
`/${env.users.user.username}/AppData/${uid}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// What the desktop's uninstall does: drop the grants, then the app's sessions.
|
||||
const revoked = await call(
|
||||
'POST',
|
||||
'/auth/revoke-user-app',
|
||||
env.users.user.token,
|
||||
{ app_uid: uid, permission: '*' },
|
||||
);
|
||||
expect(revoked.status).toBe(200);
|
||||
expect(await installed()).toBe(false);
|
||||
|
||||
await env.server.services.auth.revokeSession(decode(token!).session_uid!);
|
||||
expect(await live(token!)).toBe(false);
|
||||
});
|
||||
|
||||
it('dies with the desktop session that minted it', async () => {
|
||||
const desktop = await newDesktopSession();
|
||||
const { token } = await mint(desktop.gui_token);
|
||||
expect((await call('GET', '/whoami', token!)).status).toBe(200);
|
||||
|
||||
await env.server.services.auth.revokeSession(
|
||||
desktop.session.uuid as string,
|
||||
);
|
||||
|
||||
const res = await call('GET', '/whoami', token!);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('stops working once its app is no longer godmode', async () => {
|
||||
const app = await makeApp();
|
||||
const { token } = await mint(env.users.user.token, app.uid);
|
||||
expect((await call('GET', '/whoami', token!)).status).toBe(200);
|
||||
|
||||
const row = await env.server.stores.app.getByUid(app.uid);
|
||||
await env.server.clients.db.write(
|
||||
'UPDATE `apps` SET `godmode` = 0 WHERE `uid` = ?',
|
||||
[app.uid],
|
||||
);
|
||||
await env.server.stores.app.invalidate(row);
|
||||
|
||||
expect((await call('GET', '/whoami', token!)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('still gives an ordinary app an ordinary app token', async () => {
|
||||
const app = await makeApp({ godmode: false });
|
||||
const body = await mint(env.users.user.token, app.uid);
|
||||
expect(body.godmode).toBeUndefined();
|
||||
expect(decode(body.token!).type).toBe('app-under-user');
|
||||
});
|
||||
|
||||
it('mints scoped tokens that are revoked along with it', async () => {
|
||||
const { token } = await mint(env.users.user.token);
|
||||
const child = await call(
|
||||
'POST',
|
||||
'/auth/create-access-token',
|
||||
token!,
|
||||
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
|
||||
);
|
||||
expect(child.status).toBe(200);
|
||||
const childToken = ((await child.json()) as { token: string }).token;
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(childToken)).actor,
|
||||
).toBeDefined();
|
||||
|
||||
const full = await call('POST', '/auth/create-access-token', token!, {
|
||||
permissions: ['full-api-access'],
|
||||
});
|
||||
expect(full.status).toBe(403);
|
||||
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
env.users.user.token,
|
||||
);
|
||||
await env.server.services.auth.revokeAccessToken(actor!, token!);
|
||||
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(childToken)).actor,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays dead once it lapses; the desktop asking again gets a new one', async () => {
|
||||
const desktop = await newDesktopSession();
|
||||
const { token } = await mint(desktop.gui_token);
|
||||
const child = await mintScoped(token!);
|
||||
expect(await live(child)).toBe(true);
|
||||
|
||||
// The desktop stopped renewing it: its row's expiry passes.
|
||||
await setRow(decode(token!).session_uid!, {
|
||||
expires_at: Math.floor(Date.now() / 1000) - 1,
|
||||
});
|
||||
expect(await live(token!)).toBe(false);
|
||||
expect(await live(child)).toBe(false);
|
||||
|
||||
const next = await mint(desktop.gui_token);
|
||||
expect(decode(next.token!).token_uid).not.toBe(decode(token!).token_uid);
|
||||
expect(await live(next.token!)).toBe(true);
|
||||
expect(await live(token!)).toBe(false);
|
||||
expect(await live(child)).toBe(false);
|
||||
});
|
||||
|
||||
it('is replaced and revoked once past its max age, however often renewed', async () => {
|
||||
const desktop = await newDesktopSession();
|
||||
const { token } = await mint(desktop.gui_token);
|
||||
const child = await mintScoped(token!);
|
||||
await setRow(decode(token!).session_uid!, {
|
||||
created_at: Math.floor(Date.now() / 1000) - 8 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
const next = await mint(desktop.gui_token);
|
||||
expect(decode(next.token!).token_uid).not.toBe(decode(token!).token_uid);
|
||||
expect(await live(next.token!)).toBe(true);
|
||||
expect(await live(token!)).toBe(false);
|
||||
expect(await live(child)).toBe(false);
|
||||
});
|
||||
|
||||
it('lets the tokens it minted die with the desktop session', async () => {
|
||||
const desktop = await newDesktopSession();
|
||||
const { token } = await mint(desktop.gui_token);
|
||||
const child = await mintScoped(token!);
|
||||
expect(await live(child)).toBe(true);
|
||||
|
||||
await env.server.services.auth.revokeSession(
|
||||
desktop.session.uuid as string,
|
||||
);
|
||||
|
||||
expect(await live(token!)).toBe(false);
|
||||
expect(await live(child)).toBe(false);
|
||||
});
|
||||
|
||||
it('reaches the AI wire routes, where a gate listener may waive the plan', async () => {
|
||||
const { token } = await mint(env.users.user.token);
|
||||
const route = '/puterai/openai/v1/chat/completions';
|
||||
const send = (bearer: string) =>
|
||||
fetch(new URL(route, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'fake',
|
||||
provider: 'fake-chat',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
});
|
||||
|
||||
// Past the credential gates, stopped only by the plan.
|
||||
expect((await send(token!)).status).toBe(402);
|
||||
|
||||
env.server.clients.event.on(
|
||||
'subscription.gate.route.post.puterai.openai.v1.chat.completions',
|
||||
(_key, event) => {
|
||||
const gate = event as {
|
||||
actor: { accessToken?: { godmodeApp?: { uid: string } } };
|
||||
allow: boolean;
|
||||
};
|
||||
if (gate.actor.accessToken?.godmodeApp?.uid === DEV_CENTER) {
|
||||
gate.allow = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
expect((await send(token!)).status).not.toBe(402);
|
||||
expect((await send(env.users.user.apiToken)).status).toBe(402);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { configContainer } from '../../exports.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { startWebSession } from './webSession.js';
|
||||
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
describe('startWebSession', () => {
|
||||
it('records the request on the session, sets the HTTP-only cookie and returns a GUI token', async () => {
|
||||
const username = `ws_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
const cookies: Array<{ name: string; value: string; opts: unknown }> =
|
||||
[];
|
||||
const res = {
|
||||
cookie: (name: string, value: string, opts: unknown) => {
|
||||
cookies.push({ name, value, opts });
|
||||
},
|
||||
} as unknown as Response;
|
||||
const req = {
|
||||
ip: '192.0.2.10',
|
||||
headers: { 'user-agent': 'ws-agent', origin: 'https://gui.test' },
|
||||
} as unknown as Request;
|
||||
const config = configContainer as IConfig;
|
||||
|
||||
const guiToken = await startWebSession(req, res, user, {
|
||||
config,
|
||||
auth: server.services.auth,
|
||||
});
|
||||
|
||||
expect(typeof guiToken).toBe('string');
|
||||
expect(cookies).toHaveLength(1);
|
||||
expect(cookies[0]!.name).toBe(config.cookie_name ?? 'puter_token');
|
||||
expect(cookies[0]!.value).not.toBe(guiToken);
|
||||
expect(cookies[0]!.opts).toMatchObject({ httpOnly: true });
|
||||
|
||||
const sessions = (await server.stores.session.getByUserId(
|
||||
user.id,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]).toMatchObject({
|
||||
kind: 'web',
|
||||
last_ip: '192.0.2.10',
|
||||
last_user_agent: 'ws-agent',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AuthService } from '../../services/auth/AuthService.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
|
||||
/**
|
||||
* Open a web session for a sign-in this request completed, recording where it
|
||||
* came from, and set its HTTP-only cookie. Returns the GUI token, the only
|
||||
* credential the client sees.
|
||||
*/
|
||||
export async function startWebSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
user: UserRow,
|
||||
deps: { config: IConfig; auth: AuthService },
|
||||
): Promise<string> {
|
||||
const { token, gui_token } = await deps.auth.createSessionToken(user, {
|
||||
ip: req.ip || req.socket?.remoteAddress,
|
||||
user_agent: req.headers?.['user-agent'],
|
||||
origin: req.headers?.origin,
|
||||
host: req.headers?.host,
|
||||
});
|
||||
res.cookie(deps.config.cookie_name ?? 'puter_token', token, {
|
||||
...sessionCookieFlags(deps.config),
|
||||
httpOnly: true,
|
||||
});
|
||||
return gui_token;
|
||||
}
|
||||
@@ -73,8 +73,8 @@ export class BroadcastController extends PuterController {
|
||||
/**
|
||||
* Event deliveries one region addressed at this one, on the same signed
|
||||
* channel as the webhook above but carrying socket traffic rather than bus
|
||||
* events. The answer names the pairs this region holds no socket for, which
|
||||
* is what lets the sender correct its presence row.
|
||||
* events. The answer names the anchor tokens this region holds no session
|
||||
* row for, which is what lets the sender prune its remote-watch index.
|
||||
*/
|
||||
@Post('/events', { subdomain: '*' })
|
||||
async events(req: Request, res: Response): Promise<void> {
|
||||
|
||||
@@ -46,17 +46,6 @@ const PREFERENCE_WRITE_LIMIT = {
|
||||
};
|
||||
|
||||
export class DesktopController extends PuterController {
|
||||
constructor(config, clients, stores, services) {
|
||||
super(config, clients, stores, services);
|
||||
}
|
||||
|
||||
get userStore() {
|
||||
return this.stores.user;
|
||||
}
|
||||
get db() {
|
||||
return this.clients.db;
|
||||
}
|
||||
|
||||
registerRoutes(router) {
|
||||
// -- Desktop background --------------------------------------
|
||||
|
||||
@@ -109,7 +98,7 @@ export class DesktopController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
await this.userStore.update(req.actor.user.id, patch);
|
||||
await this.stores.user.update(req.actor.user.id, patch);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
@@ -134,7 +123,7 @@ export class DesktopController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.userStore.update(req.actor.user.id, {
|
||||
await this.stores.user.update(req.actor.user.id, {
|
||||
taskbar_items: JSON.stringify(items),
|
||||
});
|
||||
res.json({});
|
||||
@@ -250,7 +239,7 @@ export class DesktopController extends PuterController {
|
||||
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
|
||||
const values = keys.map((k) => patch[k]);
|
||||
|
||||
await this.db.write(
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`fsentries\` SET ${setClause} WHERE \`id\` = ?`,
|
||||
[...values, entry.id],
|
||||
);
|
||||
|
||||
@@ -262,6 +262,22 @@ describe('DriverController — concurrent acquire/release', () => {
|
||||
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
|
||||
});
|
||||
|
||||
it('releases a slot acquired after the client has disconnected', async () => {
|
||||
const handler = captureCallHandler(buildController(makeSyntheticDriver()));
|
||||
const res1 = new StubRes();
|
||||
Object.assign(res1, { destroyed: true });
|
||||
await callInScope(handler,
|
||||
makeReq({ interface: 'test-iface', method: 'ping' }, 'already-aborted'),
|
||||
res1 as unknown as Response);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const res2 = new StubRes();
|
||||
await callInScope(handler,
|
||||
makeReq({ interface: 'test-iface', method: 'ping' }, 'already-aborted'),
|
||||
res2 as unknown as Response);
|
||||
res2.emit('finish');
|
||||
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
|
||||
});
|
||||
|
||||
it('does not attach release listeners when the driver declares no concurrent config', async () => {
|
||||
// With no spec there is nothing to release, so the gate must not
|
||||
// wire `finish`/`close` release listeners. The client-disconnect
|
||||
|
||||
@@ -40,6 +40,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DriverMethodLifecycleEvent } from '../../clients/event/types.js';
|
||||
import { Context, runWithContext } from '../../core/context.js';
|
||||
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
UpstreamResponseError,
|
||||
upstreamBodyStream,
|
||||
} from '../../drivers/util/upstreamErrors.js';
|
||||
import { DriverController } from './DriverController.js';
|
||||
|
||||
// -- Harness ---------------------------------------------------------
|
||||
@@ -266,6 +270,35 @@ describe('DriverController upstream error translation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// `fields` become the alarm's details, so this is what names the vendor.
|
||||
it.each([
|
||||
[
|
||||
'a provider API response',
|
||||
new UpstreamResponseError('Invalid API key', 401, 'xai'),
|
||||
500,
|
||||
'upstream_auth_failed',
|
||||
],
|
||||
[
|
||||
'a provider error carrying its status',
|
||||
Object.assign(new Error('insufficient credits'), {
|
||||
status: 402,
|
||||
provider: 'byteplus',
|
||||
}),
|
||||
503,
|
||||
'upstream_credits_exhausted',
|
||||
],
|
||||
])(
|
||||
'names the provider behind %s in the error fields',
|
||||
async (_shape, raw, statusCode, legacyCode) => {
|
||||
const { err } = await callWith(throwing(raw));
|
||||
expect(err).toMatchObject({
|
||||
statusCode,
|
||||
legacyCode,
|
||||
fields: { provider: raw.provider, upstreamStatus: raw.status },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('prefers the nested error.message and error.code over the top-level ones', async () => {
|
||||
const { err } = await callWith(
|
||||
throwing({
|
||||
@@ -449,6 +482,51 @@ describe('DriverController stream responses', () => {
|
||||
expect(res.headers['content-type']).toBe('audio/mpeg');
|
||||
expect('transfer-encoding' in res.headers).toBe(false);
|
||||
});
|
||||
|
||||
it('destroys the response, without crashing, when the source fails after the first chunk', async () => {
|
||||
let upstream!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
upstream = controller;
|
||||
},
|
||||
});
|
||||
const { handler } = build({
|
||||
run: () => ({
|
||||
dataType: 'stream',
|
||||
content_type: 'audio/mpeg',
|
||||
chunked: true,
|
||||
stream: upstreamBodyStream(new Response(body)),
|
||||
}),
|
||||
});
|
||||
const res = new MockRes();
|
||||
const uncaught = vi.fn();
|
||||
process.on('uncaughtException', uncaught);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
await runWithContext({}, () =>
|
||||
handler(
|
||||
makeReq({ interface: 'test-iface', method: 'run' }),
|
||||
res as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
upstream.enqueue(Buffer.from('first'));
|
||||
await vi.waitFor(() => expect(res.chunks).toHaveLength(1));
|
||||
|
||||
const closed = new Promise((resolve) => res.once('close', resolve));
|
||||
upstream.error(new Error('connection reset'));
|
||||
await closed;
|
||||
|
||||
expect(Buffer.concat(res.chunks).toString()).toBe('first');
|
||||
expect(res.destroyed).toBe(true);
|
||||
expect(res.writableFinished).toBe(false);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(uncaught).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.off('uncaughtException', uncaught);
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- Rate limiting ---------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventClient } from '../../clients/event/EventClient.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
@@ -97,8 +98,8 @@ const syntheticDriver = {
|
||||
premium: async () => 'premium-ok',
|
||||
};
|
||||
|
||||
const buildController = (plan: string | null) => {
|
||||
const clients = { alarm: { create: () => {} } };
|
||||
const buildController = (plan: string | null, events?: EventClient) => {
|
||||
const clients = { alarm: { create: () => {} }, ...(events ? { event: events } : {}) };
|
||||
const services = plan
|
||||
? {
|
||||
metering: {
|
||||
@@ -115,8 +116,13 @@ const buildController = (plan: string | null) => {
|
||||
);
|
||||
};
|
||||
|
||||
const call = (plan: string | null, method: string, tag: string) => {
|
||||
const handler = captureCallHandler(buildController(plan));
|
||||
const call = (
|
||||
plan: string | null,
|
||||
method: string,
|
||||
tag: string,
|
||||
events?: EventClient,
|
||||
) => {
|
||||
const handler = captureCallHandler(buildController(plan, events));
|
||||
const res = new StubRes();
|
||||
return runWithContext({}, () =>
|
||||
handler(makeReq(method, tag), res as unknown as Response, () => {}),
|
||||
@@ -157,6 +163,17 @@ describe('DriverController — per-method subscription requirement', () => {
|
||||
expect(res.body).toMatchObject({ success: true, result: 'premium-ok' });
|
||||
});
|
||||
|
||||
it('lets a subscription.gate listener waive one method for a free account', async () => {
|
||||
const events = new EventClient({} as any);
|
||||
events.on('subscription.gate.driver.test-iface.paid', (_key, event) => {
|
||||
(event as { allow: boolean }).allow = true;
|
||||
});
|
||||
|
||||
const res = await call('user_free', 'paid', 'waived', events);
|
||||
expect(res.body).toMatchObject({ success: true, result: 'paid-ok' });
|
||||
await expect402(call('user_free', 'premium', 'not-waived', events));
|
||||
});
|
||||
|
||||
it('enforces nothing on a deployment with no metering service', async () => {
|
||||
const res = await call(null, 'paid', 'no-metering');
|
||||
expect(res.body).toMatchObject({ success: true, result: 'paid-ok' });
|
||||
|
||||
@@ -41,11 +41,9 @@ import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
resolveDriverMeta,
|
||||
resolveDriverMethodConcurrent,
|
||||
resolveDriverMethodRateLimit,
|
||||
resolveDriverMethodRequireReputation,
|
||||
resolveDriverMethodRequireSubscription,
|
||||
resolvePerMethod,
|
||||
} from '../../drivers/meta.js';
|
||||
import { pipeStreamResult } from '../../drivers/util/pipeStreamResult.js';
|
||||
import { assertActorHasSubscription } from '../../services/metering/enforcement.js';
|
||||
import type { PermissionService } from '../../services/permission/PermissionService.js';
|
||||
import { PermissionUtil } from '../../services/permission/permissionUtil.js';
|
||||
@@ -111,7 +109,8 @@ const extractUpstreamStatus = (e: {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const translateProviderError = (err: unknown): unknown => {
|
||||
/** Maps a provider failure onto the error the `/drivers/call` caller sees. */
|
||||
export const translateProviderError = (err: unknown): unknown => {
|
||||
if (isHttpError(err)) return err;
|
||||
if (!err || typeof err !== 'object') return err;
|
||||
const e = err as {
|
||||
@@ -123,13 +122,16 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
error?: { code?: string; type?: string; message?: string };
|
||||
code?: string;
|
||||
cause?: unknown;
|
||||
provider?: unknown;
|
||||
};
|
||||
const status = extractUpstreamStatus(e);
|
||||
const msg = sanitizeUpstreamMessage(
|
||||
e.error?.message ?? e.message ?? 'Upstream provider error',
|
||||
);
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
const fields = { upstreamStatus: status, upstreamCode };
|
||||
// Provider-raised errors name their vendor so the alarm says which one.
|
||||
const provider = typeof e.provider === 'string' ? e.provider : undefined;
|
||||
const fields = { provider, upstreamStatus: status, upstreamCode };
|
||||
|
||||
if (isCreditExhaustion(status, upstreamCode, msg)) {
|
||||
return new HttpError(503, 'AI provider out of credits', {
|
||||
@@ -301,16 +303,10 @@ export class DriverController extends PuterController {
|
||||
}
|
||||
const fn = driver[method];
|
||||
|
||||
// Resolve the concrete driver name for permission keys, falling
|
||||
// back through prototype metadata → instance field → requested name.
|
||||
const resolvedDriverName =
|
||||
(driver as Record<string, unknown>).driverName ??
|
||||
(Object.getPrototypeOf(driver) as Record<string, unknown>)
|
||||
.__driverName ??
|
||||
requestedDriver ??
|
||||
'unknown';
|
||||
|
||||
const driverMeta = this.#meta.get(driver);
|
||||
// The concrete driver name, not an alias, keys the permission check.
|
||||
const resolvedDriverName =
|
||||
driverMeta?.driverName ?? requestedDriver ?? 'unknown';
|
||||
|
||||
// Drivers flagged `noUserSession` refuse the bare
|
||||
// account-session ("root") token: callers must present an app or
|
||||
@@ -326,8 +322,7 @@ export class DriverController extends PuterController {
|
||||
|
||||
if (req.actor) {
|
||||
const permService = this.services.permission as unknown as
|
||||
| PermissionService
|
||||
| undefined;
|
||||
PermissionService | undefined;
|
||||
if (permService) {
|
||||
// Build via PermissionUtil.join so any `:` in a driver or
|
||||
// interface name is escaped — raw interpolation would let a
|
||||
@@ -356,13 +351,13 @@ export class DriverController extends PuterController {
|
||||
}
|
||||
|
||||
// Methods that ask for a trusted-enough account. Declared per-driver
|
||||
// (`@Driver({ requireReputation })`) for the same reason the
|
||||
// (`requireReputation`) for the same reason the
|
||||
// subscription block is. Checked ahead of the plan and rate-limit
|
||||
// gates, matching the route chain: whether this account should be
|
||||
// reaching the method at all is settled before what it pays for or how
|
||||
// often it may ask. Inert unless the running config gives the named
|
||||
// tier a score.
|
||||
const reputationRequirement = resolveDriverMethodRequireReputation(
|
||||
const reputationRequirement = resolvePerMethod(
|
||||
driverMeta?.requireReputation,
|
||||
method,
|
||||
);
|
||||
@@ -374,13 +369,13 @@ export class DriverController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// Subscriber-only methods. Declared per-driver
|
||||
// (`@Driver({ requireSubscription })`) because `/drivers/call` is a
|
||||
// single route and a route option would apply to every driver at once.
|
||||
// Subscriber-only methods. Declared per-driver (`requireSubscription`)
|
||||
// because `/drivers/call` is a single route and a route option would
|
||||
// apply to every driver at once.
|
||||
// Checked before the rate limit — the same order the route chain uses
|
||||
// — so a caller whose plan never included the method is told that
|
||||
// rather than spending a bucket on it.
|
||||
const subscriptionRequirement = resolveDriverMethodRequireSubscription(
|
||||
const subscriptionRequirement = resolvePerMethod(
|
||||
driverMeta?.requireSubscription,
|
||||
method,
|
||||
);
|
||||
@@ -390,20 +385,21 @@ export class DriverController extends PuterController {
|
||||
req.actor,
|
||||
subscriptionRequirement,
|
||||
this.config,
|
||||
{
|
||||
events: this.clients.event,
|
||||
surface: `driver.${ifaceName}.${method}`,
|
||||
req,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Per-method rate-limit and concurrent specs both live on the
|
||||
// driver's resolved meta (set by `@Driver({ rateLimit, concurrent })`
|
||||
// or imperative fields). Rate-limit is single-shot; concurrent
|
||||
// driver's resolved meta. Rate-limit is single-shot; concurrent
|
||||
// acquires a slot that must be released when the response is done
|
||||
// — we hook `res.finish` / `res.close` for that so streamed
|
||||
// responses hold their slot until the stream drains, and aborted
|
||||
// requests still give the slot back.
|
||||
const rateLimitSpec = resolveDriverMethodRateLimit(
|
||||
driverMeta?.rateLimit,
|
||||
method,
|
||||
);
|
||||
const rateLimitSpec = resolvePerMethod(driverMeta?.rateLimit, method);
|
||||
if (
|
||||
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
|
||||
) {
|
||||
@@ -414,10 +410,7 @@ export class DriverController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const concurrentSpec = resolveDriverMethodConcurrent(
|
||||
driverMeta?.concurrent,
|
||||
method,
|
||||
);
|
||||
const concurrentSpec = resolvePerMethod(driverMeta?.concurrent, method);
|
||||
// Only acquire (and attach release listeners) when the driver
|
||||
// actually declared a concurrency cap. Skipping in the unbounded
|
||||
// case keeps the hot path free of needless event-listener churn
|
||||
@@ -448,6 +441,7 @@ export class DriverController extends PuterController {
|
||||
// so we still release. `close` covers client aborts.
|
||||
res.once('finish', release);
|
||||
res.once('close', release);
|
||||
if (res.destroyed || res.writableFinished) release();
|
||||
}
|
||||
|
||||
// Stash the requested driver name in Context so multi-provider
|
||||
@@ -579,7 +573,7 @@ export class DriverController extends PuterController {
|
||||
if (result.chunked) {
|
||||
res.setHeader('Transfer-Encoding', 'chunked');
|
||||
}
|
||||
result.stream.pipe(res);
|
||||
pipeStreamResult(result.stream, res, `${ifaceName}.${method}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import { DURABLE_LIST_LIMIT_CAP } from '../../stores/events/DurableSubscriptionStore.js';
|
||||
import { EVENTS_WORKERS_LIST_LIMIT_CAP } from '../../stores/events/EventHandlerStore.js';
|
||||
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
|
||||
@@ -75,7 +75,7 @@ export class EventsController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const { sub } = await this.services.events.subscribeDurable(
|
||||
actor,
|
||||
this.#body(req),
|
||||
bodyRecord(req),
|
||||
);
|
||||
res.json(sub);
|
||||
}
|
||||
@@ -108,7 +108,8 @@ export class EventsController extends PuterController {
|
||||
* GET /events/fetch — what the caller missed, one page at a time.
|
||||
*
|
||||
* Stateless: no subscription, no stored position, nothing written. The
|
||||
* cursor comes back to the caller and goes out again as `after`.
|
||||
* cursor comes back to the caller and goes out again as `cursor` (or
|
||||
* `after`, the name it first shipped under).
|
||||
*/
|
||||
@Get('/fetch', {
|
||||
subdomain: 'api',
|
||||
@@ -122,6 +123,7 @@ export class EventsController extends PuterController {
|
||||
|
||||
const page = await this.services.events.fetchMissed(actor, {
|
||||
subject: typeof query.subject === 'string' ? query.subject : '',
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
after: typeof query.after === 'string' ? query.after : undefined,
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: EVENTS_FETCH_LIMIT_CAP,
|
||||
@@ -145,7 +147,7 @@ export class EventsController extends PuterController {
|
||||
})
|
||||
async unsubscribe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
await this.services.events.unsubscribeDurable(actor, this.#body(req));
|
||||
await this.services.events.unsubscribeDurable(actor, bodyRecord(req));
|
||||
res.json({});
|
||||
}
|
||||
|
||||
@@ -169,7 +171,7 @@ export class EventsController extends PuterController {
|
||||
async mintKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.mintKvHandle(actor, this.#body(req)),
|
||||
await this.services.events.mintKvHandle(actor, bodyRecord(req)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,7 +236,7 @@ export class EventsController extends PuterController {
|
||||
async publishHandler(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.publishHandler(actor, this.#body(req)),
|
||||
await this.services.events.publishHandler(actor, bodyRecord(req)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,7 +251,7 @@ export class EventsController extends PuterController {
|
||||
res.json({
|
||||
handlers: await this.services.events.publishHandlers(
|
||||
actor,
|
||||
this.#body(req),
|
||||
bodyRecord(req),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -281,7 +283,7 @@ export class EventsController extends PuterController {
|
||||
async removeHandler(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.removeHandler(actor, this.#body(req)),
|
||||
await this.services.events.removeHandler(actor, bodyRecord(req)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -333,7 +335,7 @@ export class EventsController extends PuterController {
|
||||
res.json(
|
||||
await this.services.events.destroyEventsWorker(
|
||||
actor,
|
||||
this.#body(req),
|
||||
bodyRecord(req),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -346,6 +348,7 @@ export class EventsController extends PuterController {
|
||||
*/
|
||||
@Post('/worker/rehydrate', {
|
||||
subdomain: 'api',
|
||||
internalAuth: (config) => config.events?.internalSecret,
|
||||
// The shared secret is what gates this route, so the limit is only a
|
||||
// guessing-rate bound — hence per-IP and deliberately high. The caller
|
||||
// fans in from every edge location behind a small set of egress
|
||||
@@ -358,8 +361,7 @@ export class EventsController extends PuterController {
|
||||
},
|
||||
})
|
||||
async rehydrateWorker(req: Request, res: Response): Promise<void> {
|
||||
this.#requireInternalAuth(req);
|
||||
const body = this.#body(req);
|
||||
const body = bodyRecord(req);
|
||||
const script = String(body.script ?? '');
|
||||
const appUid = String(body.appUid ?? '');
|
||||
if (!EVENTS_SCRIPT_REGEX.test(script) || !APP_UID_REGEX.test(appUid))
|
||||
@@ -426,25 +428,6 @@ export class EventsController extends PuterController {
|
||||
return this.#deployer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time, so the secret cannot be recovered a byte at a time.
|
||||
* Compares Buffer byte lengths, not string lengths — a string length
|
||||
* compares UTF-16 code units, which can differ from the byte length
|
||||
* `timingSafeEqual` actually requires to match.
|
||||
*/
|
||||
#requireInternalAuth(req: Request): void {
|
||||
const expected = Buffer.from(this.config.events?.internalSecret ?? '');
|
||||
const offered = Buffer.from(
|
||||
String(req.headers['x-puter-internal-auth'] ?? ''),
|
||||
);
|
||||
const ok =
|
||||
expected.length > 0 &&
|
||||
expected.length === offered.length &&
|
||||
timingSafeEqual(expected, offered);
|
||||
if (!ok)
|
||||
throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
|
||||
}
|
||||
|
||||
#requireActor(req: Request): Actor {
|
||||
const actor = req.actor;
|
||||
if (!actor?.user)
|
||||
@@ -453,13 +436,4 @@ export class EventsController extends PuterController {
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
|
||||
#body(req: Request): Record<string, unknown> {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new HttpError(400, 'body must be an object', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import type { LegacyFSController } from './LegacyFSController.js';
|
||||
import type {
|
||||
ClientSignedWriteResponse,
|
||||
CompleteWriteRequest,
|
||||
@@ -456,6 +457,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
);
|
||||
const [secondResponse] = secondStart.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await fetch(secondResponse!.url!, { method: 'PUT', body: 'yy' });
|
||||
|
||||
const secondComplete = makeRes();
|
||||
await withActor(actor, () =>
|
||||
@@ -593,6 +595,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
);
|
||||
const [secondResponse] = secondStart.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await fetch(secondResponse!.url!, { method: 'PUT', body: 'yy' });
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let updatedCall: (typeof emitSpy.mock.calls)[number] | undefined;
|
||||
@@ -815,7 +818,10 @@ describe('FSController.statEntry', () => {
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/share-budget`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
controller.mkdirEntry(
|
||||
makeReq({ body: { path }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
// Spend the whole share:list bucket, as /share/shares' gate would.
|
||||
@@ -1514,6 +1520,330 @@ describe('FSController.copyEntry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── outer.gui.item.* events ─────────────────────────────────────────
|
||||
//
|
||||
// The desktop renders these, so the v2 routes must send what the legacy routes
|
||||
// send for the same operation.
|
||||
|
||||
describe('FSController GUI item events', () => {
|
||||
type GuiResponse = Record<string, unknown>;
|
||||
|
||||
// The v2 routes don't await their emits, so wait for each expected event
|
||||
// about `uid` to land. They must also land in the order given.
|
||||
const captureGuiEvents = async (
|
||||
expected: Array<[eventName: string, uid: string]>,
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<GuiResponse[]> => {
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
const indexOf = ([eventName, uid]: [string, string]) =>
|
||||
emitSpy.mock.calls.findIndex(
|
||||
([name, data]) =>
|
||||
name === eventName &&
|
||||
(data as { response: GuiResponse }).response.uuid === uid,
|
||||
);
|
||||
try {
|
||||
await run();
|
||||
await vi.waitFor(() => {
|
||||
for (const event of expected)
|
||||
expect(indexOf(event)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
const order = expected.map(indexOf);
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||
return order.map(
|
||||
(i) =>
|
||||
(emitSpy.mock.calls[i]![1] as { response: GuiResponse })
|
||||
.response,
|
||||
);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
const legacyController = () =>
|
||||
server.controllers.legacyFs as unknown as LegacyFSController;
|
||||
|
||||
const mkdir = async (actor: Actor, path: string) => {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({ body: { path }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
return (await server.stores.fsEntry.getEntryByPath(path))!;
|
||||
};
|
||||
|
||||
const touch = async (actor: Actor, path: string) => {
|
||||
await withActor(actor, () =>
|
||||
controller.touchEntry(
|
||||
makeReq({ body: { path }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
return (await server.stores.fsEntry.getEntryByPath(path))!;
|
||||
};
|
||||
|
||||
it('sends item.moved in the same shape as the legacy /move, with old_path', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const viaLegacy = await mkdir(actor, `/${username}/Documents/a`);
|
||||
const viaV2 = await mkdir(actor, `/${username}/Documents/b`);
|
||||
|
||||
const [legacyMoved] = await captureGuiEvents(
|
||||
[['outer.gui.item.moved', viaLegacy.uuid]],
|
||||
() =>
|
||||
withActor(actor, () =>
|
||||
legacyController().move(
|
||||
makeReq({
|
||||
body: {
|
||||
source: viaLegacy.path,
|
||||
destination: `/${username}/Pictures`,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
);
|
||||
const [v2Moved] = await captureGuiEvents(
|
||||
[['outer.gui.item.moved', viaV2.uuid]],
|
||||
() =>
|
||||
withActor(actor, () =>
|
||||
controller.moveEntry(
|
||||
makeReq({
|
||||
body: {
|
||||
source: { path: viaV2.path },
|
||||
destination: { path: `/${username}/Pictures` },
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(Object.keys(v2Moved!).sort()).toEqual(
|
||||
Object.keys(legacyMoved!).sort(),
|
||||
);
|
||||
expect(v2Moved).toMatchObject({
|
||||
uid: viaV2.uuid,
|
||||
path: `/${username}/Pictures/b`,
|
||||
old_path: `/${username}/Documents/b`,
|
||||
is_dir: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends item.removed in the same shape as the legacy /delete', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const viaLegacy = await mkdir(actor, `/${username}/Documents/a`);
|
||||
const viaV2 = await mkdir(actor, `/${username}/Documents/b`);
|
||||
|
||||
const [legacyRemoved] = await captureGuiEvents(
|
||||
[['outer.gui.item.removed', viaLegacy.uuid]],
|
||||
() =>
|
||||
withActor(actor, () =>
|
||||
legacyController().delete(
|
||||
makeReq({ body: { path: viaLegacy.path }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
);
|
||||
const [v2Removed] = await captureGuiEvents(
|
||||
[['outer.gui.item.removed', viaV2.uuid]],
|
||||
() =>
|
||||
withActor(actor, () =>
|
||||
controller.deleteEntry(
|
||||
makeReq({
|
||||
body: { path: viaV2.path, recursive: true },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(Object.keys(v2Removed!).sort()).toEqual(
|
||||
Object.keys(legacyRemoved!).sort(),
|
||||
);
|
||||
expect(v2Removed).toMatchObject({
|
||||
path: viaV2.path,
|
||||
descendants_only: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends item.removed for the entry an overwriting move replaces', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const source = await touch(actor, `/${username}/Documents/clash.txt`);
|
||||
const replaced = await touch(actor, `/${username}/Pictures/clash.txt`);
|
||||
|
||||
// Removal first: the desktop hides rows by path.
|
||||
const [removed] = await captureGuiEvents(
|
||||
[
|
||||
['outer.gui.item.removed', replaced.uuid],
|
||||
['outer.gui.item.moved', source.uuid],
|
||||
],
|
||||
() =>
|
||||
withActor(actor, () =>
|
||||
controller.moveEntry(
|
||||
makeReq({
|
||||
body: {
|
||||
source: { path: source.path },
|
||||
destination: { path: `/${username}/Pictures` },
|
||||
overwrite: true,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(removed!.path).toBe(replaced.path);
|
||||
});
|
||||
|
||||
it("sends no item.removed for an owner's Trash entry an overwriting move renamed around", async () => {
|
||||
const owner = await makeUser();
|
||||
const holder = await makeUser();
|
||||
const ownerName = owner.actor.user!.username!;
|
||||
const shared = await mkdir(
|
||||
owner.actor,
|
||||
`/${ownerName}/Documents/shared`,
|
||||
);
|
||||
const source = await touch(owner.actor, `${shared.path}/note.txt`);
|
||||
const occupant = await touch(
|
||||
owner.actor,
|
||||
`/${ownerName}/Trash/note.txt`,
|
||||
);
|
||||
await server.services.acl.setUserUser(
|
||||
owner.actor,
|
||||
holder.actor,
|
||||
{
|
||||
path: shared.path,
|
||||
resolveAncestors: () =>
|
||||
server.services.fs.getAncestorChain(shared.path),
|
||||
},
|
||||
'write',
|
||||
);
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
const announced = (eventName: string, uid: string) =>
|
||||
emitSpy.mock.calls.some(
|
||||
([name, data]) =>
|
||||
name === eventName &&
|
||||
(data as { response: GuiResponse }).response.uuid === uid,
|
||||
);
|
||||
try {
|
||||
await withActor(holder.actor, () =>
|
||||
controller.moveEntry(
|
||||
makeReq({
|
||||
body: {
|
||||
source: { path: source.path },
|
||||
destination: { path: `/${ownerName}/Trash` },
|
||||
overwrite: true,
|
||||
},
|
||||
actor: holder.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
// A replaced entry's removal goes out before the move's own event.
|
||||
await vi.waitFor(() =>
|
||||
expect(announced('outer.gui.item.moved', source.uuid)).toBe(
|
||||
true,
|
||||
),
|
||||
);
|
||||
expect(announced('outer.gui.item.removed', occupant.uuid)).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByUuid(occupant.uuid),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('sends item.removed for the entry an overwriting copy replaces, first', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const source = await touch(actor, `/${username}/Documents/dup.txt`);
|
||||
const replaced = await touch(actor, `/${username}/Pictures/dup.txt`);
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
// GUI item events about the destination path, in emit order.
|
||||
const atPath = () =>
|
||||
emitSpy.mock.calls.flatMap(([name, data]) => {
|
||||
const response = (data as { response?: GuiResponse })?.response;
|
||||
return String(name).startsWith('outer.gui.item.') &&
|
||||
response?.path === replaced.path
|
||||
? [[name, response.uuid]]
|
||||
: [];
|
||||
});
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.copyEntry(
|
||||
makeReq({
|
||||
body: {
|
||||
source: { path: source.path },
|
||||
destination: { path: `/${username}/Pictures` },
|
||||
overwrite: true,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() => expect(atPath()).toHaveLength(2));
|
||||
// Removal first: the desktop hides rows by path.
|
||||
expect(atPath()[0]).toEqual([
|
||||
'outer.gui.item.removed',
|
||||
replaced.uuid,
|
||||
]);
|
||||
expect(atPath()[1]?.[0]).toBe('outer.gui.item.added');
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies new_metadata on move, as trashing does', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const source = await mkdir(actor, `/${username}/Documents/trashed`);
|
||||
const trashMetadata = {
|
||||
original_name: 'trashed',
|
||||
original_path: source.path,
|
||||
trashed_ts: 1700000000,
|
||||
};
|
||||
|
||||
const [moved] = await captureGuiEvents(
|
||||
[['outer.gui.item.moved', source.uuid]],
|
||||
() =>
|
||||
withActor(actor, () =>
|
||||
controller.moveEntry(
|
||||
makeReq({
|
||||
body: {
|
||||
source: { path: source.path },
|
||||
destination: { path: `/${username}/Trash` },
|
||||
new_name: source.uuid,
|
||||
new_metadata: trashMetadata,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const stored = await server.stores.fsEntry.getEntryByUuidFromPrimary(
|
||||
source.uuid,
|
||||
);
|
||||
expect(JSON.parse(stored!.metadata!)).toMatchObject(trashMetadata);
|
||||
expect(JSON.parse(String(moved!.metadata))).toMatchObject(
|
||||
trashMetadata,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /read (readEntry, full read) ────────────────────────────────────
|
||||
|
||||
describe('FSController.readEntry (file streaming)', () => {
|
||||
@@ -2536,6 +2866,64 @@ describe('FSController.statEntry additional branches', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FSController.statEntry return_size disclosure', () => {
|
||||
it('answers the owner-wide size only to a caller who could list it', async () => {
|
||||
const { actor: owner, userId } = await makeUser();
|
||||
const username = owner.user!.username!;
|
||||
const dir = `/${username}/Documents/shared-sized`;
|
||||
await withActor(owner, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({ body: { path: dir }, actor: owner }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const body = Buffer.from('1234567890');
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: `${dir}/secret.txt`,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
|
||||
const { actor: viewer } = await makeUser();
|
||||
const viewerName = viewer.user!.username!;
|
||||
const statAsViewer = async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(viewer, () =>
|
||||
controller.statEntry(
|
||||
makeReq({
|
||||
body: { path: dir, return_size: true },
|
||||
actor: viewer,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
return captured.body as { size?: number };
|
||||
};
|
||||
|
||||
// `see` is the weakest level: the folder is visible, its contents are not.
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
owner,
|
||||
viewerName,
|
||||
`fs:${dir}:see`,
|
||||
);
|
||||
// The directory row's own size, never the sum of what is under it.
|
||||
expect((await statAsViewer()).size).toBe(0);
|
||||
|
||||
// `list` is where the contents become enumerable, so the sum may go out.
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
owner,
|
||||
viewerName,
|
||||
`fs:${dir}:list`,
|
||||
);
|
||||
expect((await statAsViewer()).size).toBeGreaterThanOrEqual(
|
||||
body.byteLength,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #getReportedCosts ───────────────────────────────────────────────
|
||||
|
||||
describe('FSController.getReportedCosts', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { FS_ROUTE_REFUSAL } from '../../services/acl/ACLService.js';
|
||||
import { clientParentUid } from '../../services/fs/rootListing.js';
|
||||
import {
|
||||
expandTildePath,
|
||||
@@ -68,10 +69,13 @@ import {
|
||||
} from './limits.js';
|
||||
import {
|
||||
assertAccess as assertLegacyAccess,
|
||||
emitGuiItemEvent,
|
||||
findOverwriteTarget,
|
||||
fsEntryMimeType,
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
signEntryThumbnail,
|
||||
toLegacyEntry,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
ClientCompleteWriteResponse,
|
||||
@@ -1015,7 +1019,6 @@ export class FSController extends PuterController {
|
||||
})
|
||||
async statEntry(req: Request, res: Response) {
|
||||
const actor = this.#requireActor(req);
|
||||
const userId = this.#getActorUserId(req);
|
||||
const body = this.#toObjectRecord(req.body);
|
||||
const entry = await this.#resolveEntryForRequest(body);
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
@@ -1032,10 +1035,16 @@ export class FSController extends PuterController {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
// Over the owner's rows, so only for a caller who could list them.
|
||||
const mayReadSize =
|
||||
entry.isDir &&
|
||||
wantsSize &&
|
||||
(await this.#canAccess(actor, entry.path, 'list'));
|
||||
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares, parentUid] =
|
||||
await Promise.all([
|
||||
entry.isDir && wantsSize
|
||||
? this.services.fs.getSubtreeSize(userId, entry.path)
|
||||
mayReadSize
|
||||
? this.services.fs.getSubtreeSize(entry.userId, entry.path)
|
||||
: undefined,
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
@@ -1518,7 +1527,7 @@ export class FSController extends PuterController {
|
||||
|
||||
const dedupeName =
|
||||
this.#toBoolean(body.dedupe_name ?? body.dedupeName) ?? false;
|
||||
await this.#assertCanCreate(actor, path);
|
||||
await this.services.acl.assertFsCreate(actor, path, FS_ROUTE_REFUSAL);
|
||||
if (dedupeName) await this.#assertCanDedupeCreate(actor, path);
|
||||
|
||||
const entry = await this.services.fs.mkdir(userId, {
|
||||
@@ -1531,7 +1540,7 @@ export class FSController extends PuterController {
|
||||
body.create_missing_ancestors,
|
||||
) ?? false,
|
||||
});
|
||||
this.#emitGuiItemAdded(entry);
|
||||
this.#emitGuiItemEvent('outer.gui.item.added', entry);
|
||||
res.json(this.#toClientEntry(entry));
|
||||
}
|
||||
|
||||
@@ -1594,7 +1603,7 @@ export class FSController extends PuterController {
|
||||
|
||||
const userId = this.#getActorUserId(req);
|
||||
const renamed = await this.services.fs.rename(userId, entry, newName);
|
||||
this.#emitGuiItemUpdated(renamed);
|
||||
this.#emitGuiItemEvent('outer.gui.item.updated', renamed);
|
||||
res.json(this.#toClientEntry(renamed));
|
||||
}
|
||||
|
||||
@@ -1616,7 +1625,11 @@ export class FSController extends PuterController {
|
||||
recursive: this.#toBoolean(body.recursive) ?? false,
|
||||
descendantsOnly,
|
||||
});
|
||||
this.#emitGuiItemRemoved(entry, descendantsOnly);
|
||||
// `descendants_only` lets the GUI keep the parent (e.g. Trash) and only
|
||||
// drop its children.
|
||||
this.#emitGuiItemEvent('outer.gui.item.removed', entry, {
|
||||
descendants_only: descendantsOnly,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1641,16 +1654,35 @@ export class FSController extends PuterController {
|
||||
await this.#assertAccess(actor, destinationParent.path, 'write');
|
||||
}
|
||||
|
||||
const newName =
|
||||
typeof body.new_name === 'string' ? body.new_name : undefined;
|
||||
const overwrite = this.#toBoolean(body.overwrite) ?? false;
|
||||
// Trash/restore rides on this, as on the legacy route.
|
||||
const newMetadata = (body.newMetadata ??
|
||||
body.new_metadata ??
|
||||
undefined) as Record<string, unknown> | undefined;
|
||||
const overwriteTarget = overwrite
|
||||
? await findOverwriteTarget(
|
||||
this.stores.fsEntry,
|
||||
destinationParent,
|
||||
newName ?? source.name,
|
||||
)
|
||||
: null;
|
||||
const moved = await this.services.fs.move(userId, {
|
||||
source,
|
||||
destinationParent,
|
||||
newName:
|
||||
typeof body.new_name === 'string' ? body.new_name : undefined,
|
||||
overwrite: this.#toBoolean(body.overwrite) ?? false,
|
||||
newName,
|
||||
overwrite,
|
||||
dedupeName:
|
||||
this.#toBoolean(body.dedupe_name ?? body.change_name) ?? false,
|
||||
newMetadata,
|
||||
});
|
||||
this.#emitGuiItemMoved(source, moved);
|
||||
this.#emitGuiItemEvent(
|
||||
'outer.gui.item.moved',
|
||||
moved,
|
||||
{ old_path: source.path },
|
||||
replacedByMove(overwriteTarget, moved, newName ?? source.name),
|
||||
);
|
||||
res.json(this.#toClientEntry(moved));
|
||||
}
|
||||
|
||||
@@ -1675,19 +1707,33 @@ export class FSController extends PuterController {
|
||||
await this.#assertAccess(actor, destinationParent.path, 'write');
|
||||
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
const newName =
|
||||
typeof body.new_name === 'string' ? body.new_name : undefined;
|
||||
const overwrite = this.#toBoolean(body.overwrite) ?? false;
|
||||
const replaced = overwrite
|
||||
? await findOverwriteTarget(
|
||||
this.stores.fsEntry,
|
||||
destinationParent,
|
||||
newName ?? source.name,
|
||||
)
|
||||
: null;
|
||||
const copy = await this.services.fs.copy(userId, {
|
||||
source,
|
||||
destinationParent,
|
||||
newName:
|
||||
typeof body.new_name === 'string' ? body.new_name : undefined,
|
||||
overwrite: this.#toBoolean(body.overwrite) ?? false,
|
||||
newName,
|
||||
overwrite,
|
||||
dedupeName:
|
||||
this.#toBoolean(body.dedupe_name ?? body.change_name) ?? true,
|
||||
...(storageAllowanceMax !== undefined
|
||||
? { storageAllowanceMax }
|
||||
: {}),
|
||||
});
|
||||
this.#emitGuiItemAdded(copy);
|
||||
this.#emitGuiItemEvent(
|
||||
'outer.gui.item.added',
|
||||
copy,
|
||||
undefined,
|
||||
replaced,
|
||||
);
|
||||
res.json(this.#toClientEntry(copy));
|
||||
}
|
||||
|
||||
@@ -1720,7 +1766,7 @@ export class FSController extends PuterController {
|
||||
target,
|
||||
dedupeName: this.#toBoolean(body.dedupe_name) ?? true,
|
||||
});
|
||||
this.#emitGuiItemAdded(shortcut);
|
||||
this.#emitGuiItemEvent('outer.gui.item.added', shortcut);
|
||||
res.json(this.#toClientEntry(shortcut));
|
||||
}
|
||||
|
||||
@@ -1775,52 +1821,6 @@ export class FSController extends PuterController {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize creation of a new entry at `targetPath`. The standard rule is
|
||||
* write on the parent, but we also accept write on the target itself — this
|
||||
* lets an app create its own `/<user>/AppData/<app_uid>` folder (parent
|
||||
* `AppData` is off-limits, but the target is the app's own subtree per
|
||||
* ACLService's short-circuit) and lets recipients of a direct share on a
|
||||
* not-yet-existent path materialize it.
|
||||
*/
|
||||
async #assertCanCreate(actor: Actor, targetPath: string) {
|
||||
const parent = pathPosix.dirname(targetPath);
|
||||
const parentForCheck = parent === '/' ? targetPath : parent;
|
||||
const fsService = this.services.fs;
|
||||
|
||||
const makeDescriptor = (path: string) => {
|
||||
let cache: Promise<Array<{ uid: string; path: string }>> | null =
|
||||
null;
|
||||
return {
|
||||
path,
|
||||
resolveAncestors() {
|
||||
if (!cache) cache = fsService.getAncestorChain(path);
|
||||
return cache;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (
|
||||
await this.services.acl.check(
|
||||
actor,
|
||||
makeDescriptor(parentForCheck),
|
||||
'write',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
await this.services.acl.check(
|
||||
actor,
|
||||
makeDescriptor(targetPath),
|
||||
'write',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.#assertAccess(actor, parentForCheck, 'write');
|
||||
}
|
||||
|
||||
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(targetPath);
|
||||
if (!existing) return;
|
||||
@@ -1832,53 +1832,27 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
/** As `#assertAccess`, but for what to include rather than what to serve. */
|
||||
async #canAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
mode: 'see' | 'list' | 'read' | 'write',
|
||||
): Promise<boolean> {
|
||||
const acl = this.services.acl;
|
||||
return acl.check(actor, acl.fsDescriptor(path), mode);
|
||||
}
|
||||
|
||||
async #assertAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
mode: 'see' | 'list' | 'read' | 'write',
|
||||
) {
|
||||
const fsService = this.services.fs;
|
||||
let ancestorsCache: Promise<
|
||||
Array<{ uid: string; path: string }>
|
||||
> | null = null;
|
||||
const descriptor = {
|
||||
path,
|
||||
resolveAncestors() {
|
||||
if (!ancestorsCache) {
|
||||
ancestorsCache = fsService.getAncestorChain(path);
|
||||
}
|
||||
return ancestorsCache;
|
||||
},
|
||||
};
|
||||
const allowed = await this.services.acl.check(actor, descriptor, mode);
|
||||
if (allowed) return;
|
||||
const safe = (await this.services.acl.getSafeAclError(
|
||||
await this.services.acl.assertFsAccess(
|
||||
actor,
|
||||
descriptor,
|
||||
path,
|
||||
mode,
|
||||
)) as {
|
||||
status?: unknown;
|
||||
message?: unknown;
|
||||
fields?: { code?: unknown };
|
||||
};
|
||||
const status = Number(safe?.status);
|
||||
const message =
|
||||
typeof safe?.message === 'string' && safe.message.length > 0
|
||||
? safe.message
|
||||
: 'Access denied';
|
||||
const code =
|
||||
typeof safe?.fields?.code === 'string'
|
||||
? safe.fields.code
|
||||
: undefined;
|
||||
const legacyCode = code === 'forbidden' ? 'access_denied' : code;
|
||||
if (status === 404) {
|
||||
throw new HttpError(404, message, {
|
||||
...(legacyCode ? { legacyCode } : {}),
|
||||
});
|
||||
}
|
||||
throw new HttpError(403, message, {
|
||||
legacyCode: legacyCode ?? 'access_denied',
|
||||
});
|
||||
FS_ROUTE_REFUSAL,
|
||||
);
|
||||
}
|
||||
|
||||
#toNumberOrUndefined(value: unknown): number | undefined {
|
||||
@@ -1902,60 +1876,20 @@ export class FSController extends PuterController {
|
||||
|
||||
// Fire-and-forget GUI events for single-entry mutations. These feed the
|
||||
// desktop cache invalidator and extension listeners (e.g. thumbnails).
|
||||
#emitGuiItemAdded(entry: FSEntry): void {
|
||||
void this.#emitGuiWriteEvent(
|
||||
'outer.gui.item.added',
|
||||
entry,
|
||||
undefined,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
#emitGuiItemUpdated(entry: FSEntry): void {
|
||||
void this.#emitGuiWriteEvent(
|
||||
'outer.gui.item.updated',
|
||||
entry,
|
||||
undefined,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
#emitGuiItemRemoved(entry: FSEntry, descendantsOnly = false): void {
|
||||
// GUI listens for `outer.gui.item.removed`; same envelope shape.
|
||||
// `descendants_only` lets the GUI keep the parent (e.g. Trash) and
|
||||
// only drop its children — without it the GUI removes the parent too.
|
||||
#emitGuiItemEvent(
|
||||
eventName: GuiItemEventName,
|
||||
entry: FSEntry,
|
||||
extra?: Record<string, unknown>,
|
||||
replaced?: FSEntry | null,
|
||||
): void {
|
||||
void (async () => {
|
||||
try {
|
||||
await this.clients.event.emit(
|
||||
'outer.gui.item.removed',
|
||||
{
|
||||
user_id_list: [entry.userId],
|
||||
response: {
|
||||
...entry,
|
||||
from_new_service: true,
|
||||
descendants_only: descendantsOnly,
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// ignore — non-critical.
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
#emitGuiItemMoved(source: FSEntry, moved: FSEntry): void {
|
||||
void (async () => {
|
||||
try {
|
||||
await this.clients.event.emit(
|
||||
'outer.gui.item.moved',
|
||||
{
|
||||
user_id_list: [moved.userId],
|
||||
response: {
|
||||
...moved,
|
||||
from_path: source.path,
|
||||
from_new_service: true,
|
||||
},
|
||||
},
|
||||
{},
|
||||
await emitGuiItemEvent(
|
||||
this.clients.event,
|
||||
eventName,
|
||||
entry,
|
||||
extra,
|
||||
replaced,
|
||||
);
|
||||
} catch {
|
||||
// ignore — non-critical.
|
||||
@@ -2505,70 +2439,19 @@ export class FSController extends PuterController {
|
||||
}
|
||||
|
||||
let pathToCheck = parentPath;
|
||||
if (Boolean(normalizedFileMetadata.overwrite)) {
|
||||
const destinationExists =
|
||||
await this.services.fs.entryExistsByPath(targetPath);
|
||||
if (destinationExists) {
|
||||
pathToCheck = targetPath;
|
||||
}
|
||||
if (
|
||||
Boolean(normalizedFileMetadata.overwrite) &&
|
||||
(await this.stores.fsEntry.getEntryByPath(targetPath))
|
||||
) {
|
||||
pathToCheck = targetPath;
|
||||
}
|
||||
|
||||
const fsService = this.services.fs;
|
||||
let ancestorsCache: Promise<
|
||||
Array<{ uid: string; path: string }>
|
||||
> | null = null;
|
||||
const resourceDescriptor = {
|
||||
path: pathToCheck,
|
||||
resolveAncestors() {
|
||||
if (!ancestorsCache) {
|
||||
ancestorsCache = fsService.getAncestorChain(pathToCheck);
|
||||
}
|
||||
return ancestorsCache;
|
||||
},
|
||||
};
|
||||
|
||||
const canWrite = await this.services.acl.check(
|
||||
await this.services.acl.assertFsAccess(
|
||||
actor,
|
||||
resourceDescriptor,
|
||||
pathToCheck,
|
||||
'write',
|
||||
FS_ROUTE_REFUSAL,
|
||||
);
|
||||
if (canWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeAclError = (await this.services.acl.getSafeAclError(
|
||||
actor,
|
||||
resourceDescriptor,
|
||||
'write',
|
||||
)) as {
|
||||
status?: unknown;
|
||||
message?: unknown;
|
||||
fields?: {
|
||||
code?: unknown;
|
||||
};
|
||||
};
|
||||
const safeAclStatus = Number(safeAclError?.status);
|
||||
const safeAclMessage =
|
||||
typeof safeAclError?.message === 'string' &&
|
||||
safeAclError.message.length > 0
|
||||
? safeAclError.message
|
||||
: 'Write access denied for destination';
|
||||
const safeAclCode =
|
||||
typeof safeAclError?.fields?.code === 'string'
|
||||
? safeAclError.fields.code
|
||||
: undefined;
|
||||
const legacyCode =
|
||||
safeAclCode === 'forbidden' ? 'access_denied' : safeAclCode;
|
||||
|
||||
if (safeAclStatus === 404) {
|
||||
throw new HttpError(404, safeAclMessage, {
|
||||
...(legacyCode ? { legacyCode } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
throw new HttpError(403, safeAclMessage, {
|
||||
legacyCode: legacyCode ?? 'access_denied',
|
||||
});
|
||||
}
|
||||
|
||||
async #assertBatchWriteAccess(
|
||||
@@ -2652,28 +2535,16 @@ export class FSController extends PuterController {
|
||||
};
|
||||
}
|
||||
|
||||
/** `forOwner` — these events go to the owner, not to the acting user. */
|
||||
async #toGuiFsEntry(entry: FSEntry): Promise<Record<string, unknown>> {
|
||||
return toLegacyEntry(this.clients.event, entry, { forOwner: true });
|
||||
}
|
||||
|
||||
async #emitGuiWriteEvent(
|
||||
eventName: 'outer.gui.item.added' | 'outer.gui.item.updated',
|
||||
fsEntry: FSEntry,
|
||||
guiMetadata: WriteGuiMetadata | undefined,
|
||||
): Promise<void> {
|
||||
const response = {
|
||||
...(await this.#toGuiFsEntry(fsEntry)),
|
||||
...this.#toEventGuiMetadata(guiMetadata),
|
||||
from_new_service: true,
|
||||
};
|
||||
await this.clients.event.emit(
|
||||
await emitGuiItemEvent(
|
||||
this.clients.event,
|
||||
eventName,
|
||||
{
|
||||
user_id_list: [fsEntry.userId],
|
||||
response,
|
||||
},
|
||||
{},
|
||||
fsEntry,
|
||||
this.#toEventGuiMetadata(guiMetadata),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2691,9 +2562,9 @@ export class FSController extends PuterController {
|
||||
// so the mask is built from the path the caller already named.
|
||||
const publishedPath = maskPathForRequest(normalizedPath);
|
||||
const pendingResponse = {
|
||||
id: response.objectKey,
|
||||
uid: response.objectKey,
|
||||
uuid: response.objectKey,
|
||||
id: response.entryUid,
|
||||
uid: response.entryUid,
|
||||
uuid: response.entryUid,
|
||||
path: publishedPath,
|
||||
name: pathPosix.basename(normalizedPath),
|
||||
is_dir: false,
|
||||
@@ -2855,9 +2726,8 @@ export class FSController extends PuterController {
|
||||
index: item.index,
|
||||
contentType: item.contentType,
|
||||
...(item.size !== undefined ? { size: item.size } : {}),
|
||||
// The entry the upload lands on; its thumbnail key is
|
||||
// bound to it.
|
||||
item_uid: responses[item.index]?.objectKey ?? '',
|
||||
// The entry it lands on; the thumbnail key is bound to it.
|
||||
item_uid: responses[item.index]?.entryUid ?? '',
|
||||
}) as ThumbnailUploadPrepareItem,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -535,6 +535,60 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
|
||||
expect(row?.thumbnail).toBe(thumbnail);
|
||||
});
|
||||
|
||||
it('serves the new thumbnail from the entry cache on the next read', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const path = `/${username}/Documents/cached-thumb.txt`;
|
||||
const { uuid } = await writeFileEntry(actor, path, 'body');
|
||||
const before = await server.stores.fsEntry.getEntryByUuid(uuid);
|
||||
expect(before?.thumbnail ?? null).toBeNull();
|
||||
|
||||
const thumbnail = 'data:image/png;base64,aGVsbG8=';
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.updateFsentryThumbnail(
|
||||
makeReq({ body: { uid: uuid, thumbnail }, actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await server.stores.fsEntry.getEntryByUuid(uuid))?.thumbnail,
|
||||
).toBe(thumbnail);
|
||||
expect(
|
||||
(await server.stores.fsEntry.getEntryByPath(path))?.thumbnail,
|
||||
).toBe(thumbnail);
|
||||
});
|
||||
|
||||
it('lets a caller with write access to a shared file set its thumbnail', async () => {
|
||||
const owner = await makeUser();
|
||||
const writer = await makeUser();
|
||||
const path = `/${owner.username}/Documents/shared-thumb.txt`;
|
||||
const { uuid } = await writeFileEntry(owner.actor, path, 'body');
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
owner.actor,
|
||||
writer.username,
|
||||
`fs:${uuid}:write`,
|
||||
{},
|
||||
);
|
||||
|
||||
const thumbnail = 'data:image/png;base64,aGVsbG8=';
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(writer.actor, () =>
|
||||
controller.updateFsentryThumbnail(
|
||||
makeReq({
|
||||
body: { uid: uuid, thumbnail },
|
||||
actor: writer.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual({ thumbnail });
|
||||
expect(
|
||||
(await server.stores.fsEntry.getEntryByUuid(uuid))?.thumbnail,
|
||||
).toBe(thumbnail);
|
||||
});
|
||||
|
||||
it('tells thumbnail listeners which entry the thumbnail is for', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const path = `/${username}/Documents/bound-thumb.txt`;
|
||||
|
||||
@@ -34,7 +34,11 @@ import { signFile } from '../../util/fileSigning.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import type { LegacyFSController } from './LegacyFSController.js';
|
||||
import { FS_MUTATE_LIMIT, FS_SIGN_MAX_ITEMS } from './limits.js';
|
||||
import {
|
||||
FS_BATCH_WRITE_MAX_ITEMS,
|
||||
FS_MUTATE_LIMIT,
|
||||
FS_SIGN_MAX_ITEMS,
|
||||
} from './limits.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -693,6 +697,36 @@ describe('LegacyFSController.delete', () => {
|
||||
expect(await server.stores.fsEntry.getEntryByPath(a)).toBeNull();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(b)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses more `paths` than one batch may hold, before deleting any', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const keep = `/${username}/Documents/keep`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({ body: { path: keep }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.delete(
|
||||
makeReq({
|
||||
body: {
|
||||
paths: [
|
||||
keep,
|
||||
...Array(FS_BATCH_WRITE_MAX_ITEMS).fill(keep),
|
||||
],
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(await server.stores.fsEntry.getEntryByPath(keep)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LegacyFSController.rename', () => {
|
||||
@@ -1057,8 +1091,7 @@ describe('LegacyFSController.batch (per-op mutation budget)', () => {
|
||||
const freeLimit =
|
||||
(
|
||||
FS_MUTATE_LIMIT[0]!.bySubscription as
|
||||
| Record<string, number>
|
||||
| undefined
|
||||
Record<string, number> | undefined
|
||||
)?.[DEFAULT_FREE_SUBSCRIPTION] ?? FS_MUTATE_LIMIT[0]!.limit;
|
||||
const chargeReq = makeReq({ body: {}, actor });
|
||||
for (let i = 0; i < freeLimit; i++) {
|
||||
@@ -1808,7 +1841,8 @@ describe('LegacyFSController.move', () => {
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const shared = (await server.stores.fsEntry.getEntryByPath(sharedPath))!;
|
||||
const shared =
|
||||
(await server.stores.fsEntry.getEntryByPath(sharedPath))!;
|
||||
await server.services.acl.setUserUser(
|
||||
owner.actor,
|
||||
holder.actor,
|
||||
@@ -1837,13 +1871,82 @@ describe('LegacyFSController.move', () => {
|
||||
// The move landed in the owner's trash, but the recipient is told so
|
||||
// in masked form — the owner's real layout stays theirs.
|
||||
const body = captured.body as { moved: { uid: string; path: string } };
|
||||
const suffix = ` (${body.moved.uid.slice(0, 8)})`;
|
||||
// Always renamed in, so the name cannot report on the Trash.
|
||||
expect(body.moved.path).toBe(
|
||||
`/${ownerName}/${body.moved.uid}/note.txt`,
|
||||
`/${ownerName}/${body.moved.uid}/note${suffix}.txt`,
|
||||
);
|
||||
const moved = await server.stores.fsEntry.getEntryByUuid(
|
||||
body.moved.uid,
|
||||
);
|
||||
expect(moved!.path).toBe(`/${ownerName}/Trash/note.txt`);
|
||||
expect(moved!.path).toBe(`/${ownerName}/Trash/note${suffix}.txt`);
|
||||
});
|
||||
|
||||
it("neither reports nor announces replacing an owner's Trash entry an overwriting move renamed around", async () => {
|
||||
const owner = await makeUser();
|
||||
const holder = await makeUser();
|
||||
const ownerName = owner.actor.user!.username!;
|
||||
const sharedPath = `/${ownerName}/Documents/Overwrites`;
|
||||
const occupantPath = `/${ownerName}/Trash/note.txt`;
|
||||
await withActor(owner.actor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({ body: { path: sharedPath }, actor: owner.actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
for (const path of [`${sharedPath}/note.txt`, occupantPath]) {
|
||||
await withActor(owner.actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path }, actor: owner.actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
const occupant =
|
||||
(await server.stores.fsEntry.getEntryByPath(occupantPath))!;
|
||||
await server.services.acl.setUserUser(
|
||||
owner.actor,
|
||||
holder.actor,
|
||||
{
|
||||
path: sharedPath,
|
||||
resolveAncestors: () =>
|
||||
server.services.fs.getAncestorChain(sharedPath),
|
||||
},
|
||||
'write',
|
||||
);
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
const { res, captured } = makeRes();
|
||||
let removedOccupant: boolean;
|
||||
try {
|
||||
await withActor(holder.actor, () =>
|
||||
controller.move(
|
||||
makeReq({
|
||||
body: {
|
||||
source: `${sharedPath}/note.txt`,
|
||||
destination: `/${ownerName}/Trash`,
|
||||
overwrite: true,
|
||||
},
|
||||
actor: holder.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
removedOccupant = emitSpy.mock.calls.some(
|
||||
([eventName, data]) =>
|
||||
eventName === 'outer.gui.item.removed' &&
|
||||
(data as { response: { uuid?: string } }).response.uuid ===
|
||||
occupant.uuid,
|
||||
);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(removedOccupant).toBe(false);
|
||||
expect(captured.body).not.toHaveProperty('overwritten');
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByUuid(occupant.uuid),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a share recipient moving an item into their own trash', async () => {
|
||||
@@ -1868,7 +1971,8 @@ describe('LegacyFSController.move', () => {
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const shared = (await server.stores.fsEntry.getEntryByPath(sharedPath))!;
|
||||
const shared =
|
||||
(await server.stores.fsEntry.getEntryByPath(sharedPath))!;
|
||||
await server.services.acl.setUserUser(
|
||||
owner.actor,
|
||||
holder.actor,
|
||||
@@ -2544,10 +2648,58 @@ describe('LegacyFSController.openItem (grant scope)', () => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'post' && r.path === '/open_item',
|
||||
);
|
||||
expect(route?.options).toMatchObject({
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
expect(route?.options).toMatchObject({ requireUserActor: true });
|
||||
expect(route?.options).not.toHaveProperty('allowFullAccessToken');
|
||||
});
|
||||
|
||||
it('rejects a full-access token, leaving no grant behind', async () => {
|
||||
const { actor: userActor, userId } = await makeUser();
|
||||
const target = `/${userActor.user!.username}/Documents/token-opened.txt`;
|
||||
await withActor(userActor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: target }, actor: userActor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(target);
|
||||
const app = await makeApp(userId);
|
||||
const tokenActor = makeActor({
|
||||
user: userActor.user,
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer: userActor,
|
||||
fullAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
const spy = vi
|
||||
.spyOn(server.services.suggestedApps, 'getSuggestedApps')
|
||||
.mockResolvedValue([{ uuid: app.uid }] as never);
|
||||
try {
|
||||
await expect(
|
||||
withActor(tokenActor, () =>
|
||||
controller.openItem(
|
||||
makeReq({
|
||||
body: { uid: entry!.uuid },
|
||||
actor: tokenActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
for (const mode of ['read', 'write']) {
|
||||
expect(
|
||||
await server.stores.permission.hasUserAppPerm(
|
||||
userId,
|
||||
app.id,
|
||||
`fs:${entry!.uuid}:${mode}`,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an app actor, leaving no grant behind', async () => {
|
||||
@@ -4666,6 +4818,33 @@ describe('LegacyFSController.readdirSubdomains', () => {
|
||||
const rows = captured.body as Array<{ subdomain: string }>;
|
||||
expect(rows.some((r) => r.subdomain === sd)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns every site past one store page, with only the listing columns', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const names = Array.from(
|
||||
{ length: 501 },
|
||||
(_, i) => `sdp-${i}-${uuidv4().slice(0, 8)}`,
|
||||
);
|
||||
for (const name of names) {
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`) VALUES (?, ?, ?)',
|
||||
[uuidv4(), name, userId],
|
||||
);
|
||||
}
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdirSubdomains(makeReq({ body: {}, actor }), res),
|
||||
);
|
||||
const rows = captured.body as Array<Record<string, unknown>>;
|
||||
expect(rows.map((r) => r.subdomain)).toEqual(names);
|
||||
expect(Object.keys(rows[0]!).sort()).toEqual([
|
||||
'root_dir_id',
|
||||
'subdomain',
|
||||
'ts',
|
||||
'uuid',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateFsentryThumbnail ──────────────────────────────────────────
|
||||
@@ -4924,7 +5103,7 @@ describe('LegacyFSController GET /get-launch-apps', () => {
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
const spy = vi
|
||||
.spyOn(server.stores.subdomain, 'getBySubdomain')
|
||||
.spyOn(server.stores.subdomain, 'getBySubdomains')
|
||||
.mockRejectedValue(new Error('db down'));
|
||||
try {
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
|
||||
@@ -49,7 +49,8 @@ import {
|
||||
parseSignedQuery,
|
||||
verifySignature,
|
||||
} from '../../util/fileSigning.js';
|
||||
import { APP_ICON_SIZES, getAppIconCdnUrl } from '../../util/appIcon.js';
|
||||
import { APP_ICON_SIZES } from '../../util/appIcon.js';
|
||||
import { toAppSummary } from '../../util/appView.js';
|
||||
import {
|
||||
joinChildPath,
|
||||
normalizeAbsolutePath,
|
||||
@@ -59,7 +60,7 @@ import { maskEntryPath } from '../../services/fs/sharePathMask.js';
|
||||
import { clientParentUid } from '../../services/fs/rootListing.js';
|
||||
import {
|
||||
buildHostedBackingDenial,
|
||||
hostedIndexUrlBackingIsUnavailable,
|
||||
hostedIndexUrlBackingsAreUnavailable,
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
@@ -69,6 +70,7 @@ import { PuterController } from '../types.js';
|
||||
import {
|
||||
FS_BATCH_CONCURRENT,
|
||||
FS_BATCH_LIMIT,
|
||||
FS_BATCH_WRITE_MAX_ITEMS,
|
||||
FS_DF_LIMIT,
|
||||
FS_HELPER_LIMIT,
|
||||
FS_MUTATE_LIMIT,
|
||||
@@ -90,10 +92,14 @@ import {
|
||||
assertAccess,
|
||||
assertCanCreate,
|
||||
assertCanMoveInto,
|
||||
emitGuiItemEvent,
|
||||
expandClientPath,
|
||||
findOverwriteTarget,
|
||||
getBoolean,
|
||||
getString,
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveV1Selector,
|
||||
signEntry,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -123,6 +129,9 @@ const BATCH_MAX_FIELD_SIZE = 1 * 1024 * 1024; // 1 MiB per operation/fileinfo JS
|
||||
const BATCH_MAX_OPS = BATCH_MAX_PARTS;
|
||||
const BATCH_MUTATING_OPS = new Set(['mkdir', 'shortcut', 'move', 'delete']);
|
||||
|
||||
/** Rows per `readdir-subdomains` read; the response pages until done. */
|
||||
const SUBDOMAIN_PAGE_SIZE = 500;
|
||||
|
||||
// An op's `as` names the path it produced; later ops in the same batch refer
|
||||
// to it as `$name` or `$name/rest`, since a deduped mkdir can land elsewhere.
|
||||
function resolveBatchRef(raw: string, refs: Map<string, string>): string {
|
||||
@@ -296,8 +305,8 @@ export class LegacyFSController extends PuterController {
|
||||
// Opening an item grants an app access to a file on the user's
|
||||
// behalf, so only the user's own credential may drive it — an
|
||||
// app calling it for itself would be widening its own ACL.
|
||||
// Session-only: the user-app token it returns is a session mint.
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: FS_HELPER_LIMIT,
|
||||
},
|
||||
this.openItem,
|
||||
@@ -346,13 +355,8 @@ export class LegacyFSController extends PuterController {
|
||||
'/get-launch-apps',
|
||||
{ ...apiOptions, rateLimit: FS_HELPER_LIMIT },
|
||||
async (req, res) => {
|
||||
const recommendedSvc = this.services
|
||||
.recommendedApps as unknown as
|
||||
| { getRecommendedApps?: () => Promise<unknown[]> }
|
||||
| undefined;
|
||||
const recommended = recommendedSvc?.getRecommendedApps
|
||||
? await recommendedSvc.getRecommendedApps()
|
||||
: [];
|
||||
const recommended =
|
||||
await this.services.recommendedApps.getRecommendedApps();
|
||||
|
||||
// The direct icon URL names a size; honour the one the
|
||||
// caller asked for so the client isn't handed a 256px PNG for
|
||||
@@ -365,32 +369,18 @@ export class LegacyFSController extends PuterController {
|
||||
let recent: unknown[] = [];
|
||||
const userId = req.actor?.user?.id;
|
||||
if (userId) {
|
||||
const recentUids =
|
||||
(await (
|
||||
this.stores.app as unknown as {
|
||||
getRecentAppOpens?: (
|
||||
id: number,
|
||||
opts?: { limit?: number },
|
||||
) => Promise<string[]>;
|
||||
}
|
||||
).getRecentAppOpens?.(userId, { limit: 10 })) ?? [];
|
||||
// One batched read for the rows, then the backing checks
|
||||
// concurrently. Serially awaiting a lookup per uid put ~2
|
||||
// round trips of latency on every desktop boot.
|
||||
const appsByUid = await (
|
||||
this.stores.app as unknown as {
|
||||
getByUids: (
|
||||
uids: string[],
|
||||
) => Promise<Map<string, Record<string, unknown>>>;
|
||||
}
|
||||
).getByUids(recentUids);
|
||||
const recentUids = await this.stores.app.getRecentAppOpens(
|
||||
userId,
|
||||
{ limit: 10 },
|
||||
);
|
||||
const appsByUid =
|
||||
await this.stores.app.getByUids(recentUids);
|
||||
|
||||
// `recentUids` is ordered most-recent-first; preserve it.
|
||||
const orderedApps = recentUids
|
||||
.map((uid) => appsByUid.get(uid))
|
||||
.filter((app): app is Record<string, unknown> =>
|
||||
Boolean(app),
|
||||
);
|
||||
const orderedApps: Array<Record<string, unknown>> =
|
||||
recentUids
|
||||
.map((uid: string) => appsByUid.get(uid))
|
||||
.filter(Boolean);
|
||||
|
||||
// Don't hand out an index_url whose puter-hosted backing is
|
||||
// gone or reclaimed. The taskbar launches recents by name (so
|
||||
@@ -398,41 +388,30 @@ export class LegacyFSController extends PuterController {
|
||||
// launch-metadata producer like any other — a future consumer
|
||||
// reading index_url straight off it shouldn't inherit a stale
|
||||
// origin.
|
||||
const backingGoneFlags = await Promise.all(
|
||||
orderedApps.map((app) =>
|
||||
hostedIndexUrlBackingIsUnavailable({
|
||||
app,
|
||||
let backingGoneFlags: boolean[];
|
||||
try {
|
||||
backingGoneFlags =
|
||||
await hostedIndexUrlBackingsAreUnavailable({
|
||||
apps: orderedApps,
|
||||
subdomainStore: this.stores.subdomain,
|
||||
config: this.config,
|
||||
}).catch(() => true),
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
backingGoneFlags = orderedApps.map(() => true);
|
||||
}
|
||||
|
||||
recent = orderedApps.map((app, index) => {
|
||||
const backingGone = backingGoneFlags[index];
|
||||
return {
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: app.icon ?? null,
|
||||
// Direct subdomain URL for the client to try
|
||||
// before `icon`.
|
||||
iconCdnUrl: getAppIconCdnUrl(
|
||||
app,
|
||||
this.config,
|
||||
...toAppSummary(app, {
|
||||
config: this.config,
|
||||
iconSize,
|
||||
),
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
}),
|
||||
icon: app.icon ?? null,
|
||||
index_url: backingGone ? null : app.index_url,
|
||||
...(backingGone
|
||||
? { privateAccess: buildHostedBackingDenial() }
|
||||
: {}),
|
||||
// An app with no owner isn't owned by a Puter user —
|
||||
// it's an "external" (origin-bootstrapped) app.
|
||||
external:
|
||||
app.owner_user_id == null ||
|
||||
app.owner_user_id === '',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -828,16 +807,13 @@ export class LegacyFSController extends PuterController {
|
||||
// (`overwritten`) so clients can drop its stale row/icon. The copy
|
||||
// deletes that entry, so resolve it beforehand.
|
||||
const overwriteRequested = getBoolean(body, 'overwrite') ?? false;
|
||||
let overwrittenEntry = null;
|
||||
if (overwriteRequested) {
|
||||
const targetName = getString(body, 'new_name') ?? source.name;
|
||||
const targetPath =
|
||||
destinationParent.path === '/'
|
||||
? `/${targetName}`
|
||||
: `${destinationParent.path}/${targetName}`;
|
||||
overwrittenEntry =
|
||||
await this.stores.fsEntry.getEntryByPath(targetPath);
|
||||
}
|
||||
const overwrittenEntry = overwriteRequested
|
||||
? await findOverwriteTarget(
|
||||
this.stores.fsEntry,
|
||||
destinationParent,
|
||||
getString(body, 'new_name') ?? source.name,
|
||||
)
|
||||
: null;
|
||||
|
||||
const copy = await this.services.fs.copy(userId, {
|
||||
source,
|
||||
@@ -846,15 +822,14 @@ export class LegacyFSController extends PuterController {
|
||||
overwrite: overwriteRequested,
|
||||
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
|
||||
});
|
||||
await this.#emitGuiEvent('outer.gui.item.added', copy);
|
||||
// Without this, every other client keeps a ghost row for the
|
||||
// replaced entry until the directory is re-listed.
|
||||
if (overwrittenEntry) {
|
||||
await this.#emitGuiEvent(
|
||||
'outer.gui.item.removed',
|
||||
overwrittenEntry,
|
||||
);
|
||||
}
|
||||
// Announcing the replaced entry's removal keeps other clients from
|
||||
// showing a ghost row for it until the directory is re-listed.
|
||||
await this.#emitGuiEvent(
|
||||
'outer.gui.item.added',
|
||||
copy,
|
||||
undefined,
|
||||
overwrittenEntry,
|
||||
);
|
||||
|
||||
// Legacy response shape: `[{copied: fsentry, overwritten?}]`.
|
||||
// Array is historical — originally supported bulk copy.
|
||||
@@ -914,18 +889,17 @@ export class LegacyFSController extends PuterController {
|
||||
// (`overwritten`) so clients can drop its stale row/icon. The move
|
||||
// deletes that entry, so resolve it beforehand.
|
||||
const overwriteRequested = getBoolean(body, 'overwrite') ?? false;
|
||||
let overwrittenEntry = null;
|
||||
const name = getString(body, 'new_name') ?? source.name;
|
||||
let overwriteTarget = null;
|
||||
if (overwriteRequested) {
|
||||
const targetName = getString(body, 'new_name') ?? source.name;
|
||||
const targetPath =
|
||||
destinationParent.path === '/'
|
||||
? `/${targetName}`
|
||||
: `${destinationParent.path}/${targetName}`;
|
||||
const existing =
|
||||
await this.stores.fsEntry.getEntryByPath(targetPath);
|
||||
const existing = await findOverwriteTarget(
|
||||
this.stores.fsEntry,
|
||||
destinationParent,
|
||||
name,
|
||||
);
|
||||
// Moving an entry onto its own path is not an overwrite.
|
||||
if (existing && existing.uuid !== source.uuid) {
|
||||
overwrittenEntry = existing;
|
||||
overwriteTarget = existing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -942,18 +916,16 @@ export class LegacyFSController extends PuterController {
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const overwrittenEntry = replacedByMove(overwriteTarget, moved, name);
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
old_path: oldPath,
|
||||
});
|
||||
// Without this, every other client keeps a ghost row for the
|
||||
// replaced entry until the directory is re-listed.
|
||||
if (overwrittenEntry) {
|
||||
await this.#emitGuiEvent(
|
||||
'outer.gui.item.removed',
|
||||
overwrittenEntry,
|
||||
);
|
||||
}
|
||||
// Announcing the replaced entry's removal keeps other clients from
|
||||
// showing a ghost row for it until the directory is re-listed.
|
||||
await this.#emitGuiEvent(
|
||||
'outer.gui.item.moved',
|
||||
moved,
|
||||
{ old_path: oldPath },
|
||||
overwrittenEntry,
|
||||
);
|
||||
|
||||
// Legacy response shape: `{moved: fsentry, old_path, overwritten?}`.
|
||||
const legacyEntryOpts = {
|
||||
@@ -992,6 +964,13 @@ export class LegacyFSController extends PuterController {
|
||||
const descendantsOnly = getBoolean(body, 'descendants_only') ?? false;
|
||||
const pathsArray = Array.isArray(body.paths) ? body.paths : null;
|
||||
if (pathsArray) {
|
||||
if (pathsArray.length > FS_BATCH_WRITE_MAX_ITEMS) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const removedEntries: unknown[] = [];
|
||||
for (const raw of pathsArray) {
|
||||
const entry = await resolveV1Selector(this.stores.fsEntry, raw);
|
||||
@@ -1154,11 +1133,25 @@ export class LegacyFSController extends PuterController {
|
||||
return;
|
||||
}
|
||||
const userId = this.#getActorUserId(req);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT `subdomain`, `root_dir_id`, `uuid`, `ts` FROM `subdomains` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
);
|
||||
res.json(rows);
|
||||
const sites: Array<Record<string, unknown>> = [];
|
||||
let afterId: number | undefined;
|
||||
for (;;) {
|
||||
const page = (await this.stores.subdomain.listByUserId(userId, {
|
||||
afterId,
|
||||
limit: SUBDOMAIN_PAGE_SIZE,
|
||||
})) as Array<Record<string, unknown>>;
|
||||
for (const row of page) {
|
||||
sites.push({
|
||||
subdomain: row.subdomain,
|
||||
root_dir_id: row.root_dir_id,
|
||||
uuid: row.uuid,
|
||||
ts: row.ts,
|
||||
});
|
||||
}
|
||||
if (page.length < SUBDOMAIN_PAGE_SIZE) break;
|
||||
afterId = Number(page[page.length - 1]!.id);
|
||||
}
|
||||
res.json(sites);
|
||||
};
|
||||
|
||||
updateFsentryThumbnail = async (
|
||||
@@ -1206,9 +1199,10 @@ export class LegacyFSController extends PuterController {
|
||||
const event = { url: thumbnail, uuid: entry.uuid };
|
||||
await this.clients.event.emitAndWait('thumbnail.created', event, {});
|
||||
|
||||
await this.clients.db.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[event.url, uid],
|
||||
// Not the owner-scoped update: a shared writer passed the check above.
|
||||
await this.stores.fsEntry.updateEntryThumbnailByUuid(
|
||||
entry.uuid,
|
||||
event.url,
|
||||
);
|
||||
res.json({ thumbnail: event.url });
|
||||
};
|
||||
@@ -1461,11 +1455,7 @@ export class LegacyFSController extends PuterController {
|
||||
// publishes the descriptor, so there is nothing to hide.
|
||||
const writeOk = await this.services.acl.check(
|
||||
actor,
|
||||
{
|
||||
path: entry.path,
|
||||
resolveAncestors: () =>
|
||||
this.services.fs.getAncestorChain(entry.path),
|
||||
},
|
||||
this.services.acl.fsDescriptor(entry.path),
|
||||
'write',
|
||||
);
|
||||
finalAction = writeOk ? 'write' : 'read';
|
||||
@@ -1860,7 +1850,8 @@ export class LegacyFSController extends PuterController {
|
||||
*/
|
||||
openItem = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
if (actor.effectiveApp) {
|
||||
// Refused before the grant, so a token never leaves an orphaned ACL row.
|
||||
if (actor.effectiveApp || actor.accessToken) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is only available to user sessions',
|
||||
@@ -1885,11 +1876,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Real path — see the note on the other `acl.check` above.
|
||||
const writeOk = await this.services.acl.check(
|
||||
actor,
|
||||
{
|
||||
path: entry.path,
|
||||
resolveAncestors: () =>
|
||||
this.services.fs.getAncestorChain(entry.path),
|
||||
},
|
||||
this.services.acl.fsDescriptor(entry.path),
|
||||
'write',
|
||||
);
|
||||
|
||||
@@ -1958,10 +1945,12 @@ export class LegacyFSController extends PuterController {
|
||||
// claiming a root dir is the app itself asking, and a token is not it.
|
||||
const callerApp = isAppActor(actor) ? actor.effectiveApp : null;
|
||||
if (callerApp?.uid !== appUid) {
|
||||
// `reason` separates this from a refusal a grant could lift, so a
|
||||
// client does not spend a consent prompt on a grant that cannot work.
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Only the app itself may request its root dir',
|
||||
{ legacyCode: 'forbidden' },
|
||||
{ legacyCode: 'forbidden', fields: { reason: 'app_mismatch' } },
|
||||
);
|
||||
}
|
||||
const userId = this.#getActorUserId(req);
|
||||
@@ -2012,14 +2001,8 @@ export class LegacyFSController extends PuterController {
|
||||
if (!(err instanceof HttpError && err.statusCode === 404))
|
||||
throw err;
|
||||
}
|
||||
let ancestors: Promise<Array<{ uid: string; path: string }>> | null =
|
||||
null;
|
||||
const descriptor = subject && {
|
||||
path: subject.path,
|
||||
resolveAncestors: () =>
|
||||
(ancestors ??= this.services.fs.getAncestorChain(subject.path)),
|
||||
};
|
||||
const acl = this.services.acl as ACLService;
|
||||
const descriptor = subject && acl.fsDescriptor(subject.path);
|
||||
if (!descriptor || !(await acl.check(actor, descriptor, 'see')))
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
@@ -2083,6 +2066,10 @@ export class LegacyFSController extends PuterController {
|
||||
'read',
|
||||
);
|
||||
|
||||
// The GUI calls this on the root origin, which egress metering skips
|
||||
// by default, so opt in by naming the downloader as who pays.
|
||||
req.egressActor = actor;
|
||||
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
@@ -2112,35 +2099,18 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
async #emitGuiEvent(
|
||||
eventName:
|
||||
| 'outer.gui.item.added'
|
||||
| 'outer.gui.item.updated'
|
||||
| 'outer.gui.item.removed'
|
||||
| 'outer.gui.item.moved',
|
||||
eventName: GuiItemEventName,
|
||||
entry: import('../../stores/fs/FSEntry.js').FSEntry,
|
||||
extra?: Record<string, unknown>,
|
||||
replaced?: import('../../stores/fs/FSEntry.js').FSEntry | null,
|
||||
): Promise<void> {
|
||||
// GUI consumes snake_case fields (`user_id`, `parent_uid`, `is_dir`,
|
||||
// …) — spreading the raw FSEntry ships camelCase, which the client
|
||||
// silently ignores. Run the entry through `toLegacyEntry` first so
|
||||
// the event payload matches what /stat et al. return, then overlay
|
||||
// per-op extras (e.g. `old_path` for moves).
|
||||
// `forOwner` — the audience is the owner, who can't read the actor's mask.
|
||||
try {
|
||||
const response = {
|
||||
...(await toLegacyEntry(this.clients.event, entry, {
|
||||
forOwner: true,
|
||||
})),
|
||||
...extra,
|
||||
from_new_service: true,
|
||||
};
|
||||
await this.clients.event.emit(
|
||||
await emitGuiItemEvent(
|
||||
this.clients.event,
|
||||
eventName,
|
||||
{
|
||||
user_id_list: [entry.userId],
|
||||
response,
|
||||
},
|
||||
{},
|
||||
entry,
|
||||
extra,
|
||||
replaced,
|
||||
);
|
||||
} catch {
|
||||
// Non-critical — GUI event failure must never break the HTTP response.
|
||||
|
||||
@@ -23,8 +23,13 @@ import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
|
||||
import type { FSService } from '../../services/fs/FSService.js';
|
||||
import type { NodeRef } from '../../services/fs/resolveNode.js';
|
||||
import type { ACLService, AclMode } from '../../services/acl/ACLService.js';
|
||||
import { isAppActor, type Actor } from '../../core/actor.js';
|
||||
import {
|
||||
FS_ROUTE_REFUSAL,
|
||||
type ACLService,
|
||||
type AclMode,
|
||||
type AclRefusalOptions,
|
||||
} from '../../services/acl/ACLService.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import type { EventClient } from '../../clients/event/EventClient.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
@@ -209,103 +214,35 @@ export async function resolveV1Selector(
|
||||
|
||||
// -- ACL --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The filesystem refusal, except that an app is told a path it can't reach
|
||||
* doesn't exist, so a sibling user's or another app's files don't show up as
|
||||
* merely forbidden.
|
||||
*/
|
||||
const LEGACY_REFUSAL: AclRefusalOptions = {
|
||||
...FS_ROUTE_REFUSAL,
|
||||
hideFromApps: true,
|
||||
};
|
||||
|
||||
/** `_fsService` is unused; the call sites still pass it. */
|
||||
export async function assertAccess(
|
||||
aclService: ACLService,
|
||||
fsService: FSService,
|
||||
_fsService: FSService,
|
||||
actor: Actor,
|
||||
path: string,
|
||||
mode: AclMode,
|
||||
): Promise<void> {
|
||||
let ancestors: Promise<Array<{ uid: string; path: string }>> | null = null;
|
||||
const descriptor = {
|
||||
path,
|
||||
resolveAncestors() {
|
||||
if (!ancestors) {
|
||||
ancestors = fsService.getAncestorChain(path);
|
||||
}
|
||||
return ancestors;
|
||||
},
|
||||
};
|
||||
const allowed = await aclService.check(actor, descriptor, mode);
|
||||
if (allowed) return;
|
||||
const safe = (await aclService.getSafeAclError(
|
||||
actor,
|
||||
descriptor,
|
||||
mode,
|
||||
)) as {
|
||||
status?: unknown;
|
||||
message?: unknown;
|
||||
fields?: { code?: unknown };
|
||||
};
|
||||
const status = Number(safe?.status);
|
||||
const message =
|
||||
typeof safe?.message === 'string' && safe.message.length > 0
|
||||
? safe.message
|
||||
: 'Access denied';
|
||||
const code =
|
||||
typeof safe?.fields?.code === 'string' ? safe.fields.code : undefined;
|
||||
const legacyCode = code === 'forbidden' ? 'access_denied' : code;
|
||||
|
||||
// App-under-user actors see denials as 404 "subject_does_not_exist"
|
||||
// so existence of a sibling user's / other-app's files isn't leaked
|
||||
// through the error code. User-actor denials keep the real 403.
|
||||
|
||||
if (isAppActor(actor)) {
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
throw new HttpError(404, message, {
|
||||
...(legacyCode ? { legacyCode } : {}),
|
||||
});
|
||||
}
|
||||
throw new HttpError(403, message, {
|
||||
legacyCode: legacyCode ?? 'access_denied',
|
||||
});
|
||||
await aclService.assertFsAccess(actor, path, mode, LEGACY_REFUSAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize creation of a new entry at `targetPath`. The standard rule is write
|
||||
* on the parent, but we also allow it when the actor has explicit write on the
|
||||
* target itself — this covers an app creating its own
|
||||
* `/<user>/AppData/<app_uid>` folder (parent `AppData` is off-limits, but the
|
||||
* target is the app's own subtree per ACLService's short-circuit) and shares
|
||||
* granted directly on a not-yet-created path.
|
||||
*
|
||||
* On failure, delegates to `assertAccess` on the parent so the error shape
|
||||
* stays identical to the previous parent-only check.
|
||||
*/
|
||||
/** `ACLService.assertFsCreate`, refused the legacy way. */
|
||||
export async function assertCanCreate(
|
||||
aclService: ACLService,
|
||||
fsService: FSService,
|
||||
_fsService: FSService,
|
||||
actor: Actor,
|
||||
targetPath: string,
|
||||
): Promise<void> {
|
||||
const parent = pathPosix.dirname(targetPath);
|
||||
const parentForCheck = parent === '/' ? targetPath : parent;
|
||||
|
||||
const makeDescriptor = (path: string) => {
|
||||
let cache: Promise<Array<{ uid: string; path: string }>> | null = null;
|
||||
return {
|
||||
path,
|
||||
resolveAncestors() {
|
||||
if (!cache) cache = fsService.getAncestorChain(path);
|
||||
return cache;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (
|
||||
await aclService.check(actor, makeDescriptor(parentForCheck), 'write')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (await aclService.check(actor, makeDescriptor(targetPath), 'write')) {
|
||||
return;
|
||||
}
|
||||
await assertAccess(aclService, fsService, actor, parentForCheck, 'write');
|
||||
await aclService.assertFsCreate(actor, targetPath, LEGACY_REFUSAL);
|
||||
}
|
||||
|
||||
/** `write` on the destination parent, unless it is the entry's own Trash. */
|
||||
@@ -574,6 +511,71 @@ export async function toLegacyEntry(
|
||||
return response;
|
||||
}
|
||||
|
||||
// -- GUI item events --------------------------------------------------
|
||||
|
||||
export type GuiItemEventName =
|
||||
| 'outer.gui.item.added'
|
||||
| 'outer.gui.item.updated'
|
||||
| 'outer.gui.item.moved'
|
||||
| 'outer.gui.item.removed';
|
||||
|
||||
/**
|
||||
* Tell the entry's owner's open clients about a change. The desktop reads the
|
||||
* legacy entry shape plus per-event `extra` fields: `old_path` on moves,
|
||||
* `descendants_only` on removes.
|
||||
*
|
||||
* `replaced` is what an overwriting copy or move took the place of. Its
|
||||
* `removed` goes out first: the desktop hides rows by path, so arriving second
|
||||
* it would hide the new row too.
|
||||
*/
|
||||
export async function emitGuiItemEvent(
|
||||
eventClient: EventClient,
|
||||
eventName: GuiItemEventName,
|
||||
entry: FSEntry,
|
||||
extra?: Record<string, unknown>,
|
||||
replaced?: FSEntry | null,
|
||||
): Promise<void> {
|
||||
// Moving an entry onto its own path replaces nothing.
|
||||
if (replaced && replaced.uuid !== entry.uuid) {
|
||||
await emitGuiItemEvent(eventClient, 'outer.gui.item.removed', replaced);
|
||||
}
|
||||
// `forOwner`: the audience is the owner, who can't read the actor's mask.
|
||||
const response = {
|
||||
...(await toLegacyEntry(eventClient, entry, { forOwner: true })),
|
||||
...extra,
|
||||
from_new_service: true,
|
||||
};
|
||||
eventClient.emit(eventName, { user_id_list: [entry.userId], response }, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry at `name` under `destinationParent`. An overwriting copy or move
|
||||
* deletes it, so callers look it up first to announce its removal.
|
||||
*/
|
||||
export async function findOverwriteTarget(
|
||||
fsEntryStore: FSEntryStore,
|
||||
destinationParent: FSEntry,
|
||||
name: string,
|
||||
): Promise<FSEntry | null> {
|
||||
return fsEntryStore.getEntryByPath(
|
||||
destinationParent.path === '/'
|
||||
? `/${name}`
|
||||
: `${destinationParent.path}/${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `target` if the overwriting move kept the name it was looked up by, so took
|
||||
* its place. A move into someone else's Trash renames instead and leaves it.
|
||||
*/
|
||||
export function replacedByMove(
|
||||
target: FSEntry | null,
|
||||
moved: FSEntry,
|
||||
name: string,
|
||||
): FSEntry | null {
|
||||
return target && moved.name === name ? target : null;
|
||||
}
|
||||
|
||||
export { normalizeAbsolutePath };
|
||||
|
||||
// -- Signing ---------------------------------------------------------
|
||||
|
||||
@@ -192,8 +192,8 @@ export const FS_POLL_LIMIT = userWindow('fs:poll', 240, 240, 120);
|
||||
// also a pending session and presigned URLs. Checked before any per-item work.
|
||||
|
||||
/**
|
||||
* `startBatchWrite` / `completeBatchWrite` / `batchWrite`. Puter.js chunks
|
||||
* uploads at this size.
|
||||
* `startBatchWrite` / `completeBatchWrite` / `batchWrite`, and the `paths` of
|
||||
* one legacy `/delete`. Puter.js chunks uploads at this size.
|
||||
*/
|
||||
export const FS_BATCH_WRITE_MAX_ITEMS = 500;
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface SignedWriteResponse {
|
||||
sessionId: string;
|
||||
uploadMode: UploadMode;
|
||||
objectKey: string;
|
||||
/** The uid the entry will carry, which an overwrite already has. */
|
||||
entryUid: string;
|
||||
bucket: string;
|
||||
bucketRegion: string;
|
||||
contentType: string;
|
||||
|
||||
@@ -32,14 +32,6 @@ import {
|
||||
* exposed it as a top-level POST.
|
||||
*/
|
||||
export class HostingController extends PuterController {
|
||||
constructor(config, clients, stores, services) {
|
||||
super(config, clients, stores, services);
|
||||
}
|
||||
|
||||
get subdomainStore() {
|
||||
return this.stores.subdomain;
|
||||
}
|
||||
|
||||
registerRoutes(router) {
|
||||
// -- Delete site ---------------------------------------------
|
||||
|
||||
@@ -71,7 +63,8 @@ export class HostingController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const row = await this.subdomainStore.getByUuid(site_uuid, {
|
||||
// Owner-scoped, so another user's site reads as missing.
|
||||
const row = await this.stores.subdomain.getByUuid(site_uuid, {
|
||||
userId: req.actor.user.id,
|
||||
});
|
||||
if (!row) {
|
||||
@@ -81,33 +74,7 @@ export class HostingController extends PuterController {
|
||||
{ legacyCode: 'not_found' },
|
||||
);
|
||||
}
|
||||
if (row.protected) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Cannot delete a protected subdomain',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
await this.subdomainStore.deleteByUuid(site_uuid, {
|
||||
userId: req.actor.user.id,
|
||||
});
|
||||
|
||||
// Same event the `puter-subdomains` driver fires on delete —
|
||||
// listeners (CDN purge, abuse tracking, worker cleanup)
|
||||
// depend on it.
|
||||
try {
|
||||
this.clients.event.emit(
|
||||
'subdomain.delete',
|
||||
{
|
||||
subdomain: row.subdomain,
|
||||
uid: String(row.uuid),
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// Non-critical.
|
||||
}
|
||||
await this.drivers.subdomains.delete({ uid: site_uuid });
|
||||
|
||||
res.json({});
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
|
||||
@@ -34,9 +35,12 @@ beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
const router = new PuterRouter();
|
||||
server.controllers.hosting.registerRoutes(router);
|
||||
deleteSite = router.routes.find(
|
||||
const handler = router.routes.find(
|
||||
(route) => route.path === '/delete-site',
|
||||
).handler;
|
||||
// Handlers run inside the request context, which carries the actor.
|
||||
deleteSite = (req, res) =>
|
||||
runWithContext({ actor: req.actor }, () => handler(req, res));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -632,6 +632,40 @@ describe('OIDCController login callback', () => {
|
||||
expect(linkedUser?.email).toBe(email);
|
||||
});
|
||||
|
||||
it('records where the sign-in came from on its session, as password login does', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: 'http://test.local/',
|
||||
});
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email: `${sub}@test.local`,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
|
||||
const req = makeReq({
|
||||
query: { code: 'authcode', state },
|
||||
headers: { 'user-agent': 'oidc-agent', host: 'test.local' },
|
||||
});
|
||||
Object.assign(req, { ip: '192.0.2.44' });
|
||||
const { res } = makeRes();
|
||||
await callRoute('get', '/auth/oidc/callback/login', req, res);
|
||||
|
||||
const user = await oidc().findUserByProviderSub('custom', sub);
|
||||
const sessions = (await server.stores.session.getByUserId(
|
||||
user!.id,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]).toMatchObject({
|
||||
last_ip: '192.0.2.44',
|
||||
last_user_agent: 'oidc-agent',
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps redirect_uri to the configured origin (rejects external)', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
@@ -1180,6 +1214,97 @@ describe('OIDCController login callback', () => {
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
});
|
||||
|
||||
/** A team seat holding `email` unconfirmed, as provisioning leaves it. */
|
||||
const seatHolding = async (email: string) => {
|
||||
const tag = Math.random().toString(36).slice(2, 8);
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner-${tag}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashed',
|
||||
email: `owner-${tag}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
});
|
||||
const seat = await server.stores.user.create({
|
||||
username: `seat-${tag}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
});
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
// Provisioning issues a temporary password once the seat exists.
|
||||
await server.stores.user.update(seat.id, { password: 'hashed' });
|
||||
return seat;
|
||||
};
|
||||
|
||||
const callbackWith = async (
|
||||
sub: string,
|
||||
claims: Record<string, unknown>,
|
||||
) => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: 'http://test.local/',
|
||||
});
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
...claims,
|
||||
} as never);
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
it("signs up a provider-verified inbox a seat holds unconfirmed, taking the seat's address", async () => {
|
||||
const email = `seat-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const seat = await seatHolding(email);
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const captured = await callbackWith(sub, {
|
||||
email,
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
const created = await oidc().findUserByProviderSub('custom', sub);
|
||||
expect(created?.id).not.toBe(seat.id);
|
||||
expect(created?.email).toBe(email);
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after?.email ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the seat its address when the provider did not verify the email', async () => {
|
||||
const email = `seat-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const seat = await seatHolding(email);
|
||||
|
||||
const captured = await callbackWith(
|
||||
`sub-${Math.random().toString(36).slice(2, 8)}`,
|
||||
{ email },
|
||||
);
|
||||
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after?.email).toBe(email);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Browser binding / login-CSRF --------------------------------------
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { Request, Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { startWebSession } from '../auth/webSession.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { normalizeBonusCode } from '../../util/signupBonus.js';
|
||||
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
|
||||
@@ -623,7 +625,7 @@ export class OIDCController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.#finishLogin(res, user, stateDecoded);
|
||||
await this.#finishLogin(req, res, user, stateDecoded);
|
||||
};
|
||||
router.get('/auth/oidc/callback/login', cbOpts, loginCb);
|
||||
router.post('/auth/oidc/callback/login', cbOpts, loginCb);
|
||||
@@ -694,7 +696,7 @@ export class OIDCController extends PuterController {
|
||||
resolved.origin === 'created'
|
||||
? undefined
|
||||
: { oidc_switched: 'login' };
|
||||
await this.#finishLogin(res, user, stateDecoded, extra);
|
||||
await this.#finishLogin(req, res, user, stateDecoded, extra);
|
||||
};
|
||||
router.get('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
router.post('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
@@ -824,7 +826,9 @@ if (window.opener) {
|
||||
* (provider, sub) to that user.
|
||||
* 3. Email matches an account whose email is UNCONFIRMED → refuse. We don't
|
||||
* know who owns an unconfirmed address, so linking would let whoever
|
||||
* controls the OIDC identity hijack a pending signup.
|
||||
* controls the OIDC identity hijack a pending signup. The exception is a
|
||||
* team seat's address with a provider-verified email: the seat gives it
|
||||
* up and step 4 runs.
|
||||
* 4. Otherwise create a new user and link.
|
||||
*
|
||||
* Step 2 also requires `email_verified !== false` on the OIDC side,
|
||||
@@ -862,7 +866,16 @@ if (window.opener) {
|
||||
claimedEmail,
|
||||
{ force: attempt > 0 },
|
||||
);
|
||||
if (byEmail) {
|
||||
// A seat's unconfirmed address was typed by its team; an inbox the
|
||||
// provider verified takes it back and signs up fresh.
|
||||
const releasedFromSeat =
|
||||
byEmail !== null &&
|
||||
!byEmail.email_confirmed &&
|
||||
userinfo.email_verified === true &&
|
||||
(await this.services.team.releaseUnconfirmedSeatEmail(
|
||||
byEmail.id,
|
||||
));
|
||||
if (byEmail && !releasedFromSeat) {
|
||||
if (!byEmail.email_confirmed) {
|
||||
return {
|
||||
error: 'An account with this email exists but the email is not yet confirmed. Please sign in with your password to confirm it first.',
|
||||
@@ -993,26 +1006,15 @@ if (window.opener) {
|
||||
}
|
||||
|
||||
async #finishLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
user: {
|
||||
id: number;
|
||||
uuid: string;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
[k: string]: unknown;
|
||||
},
|
||||
user: UserRow,
|
||||
stateDecoded: Record<string, unknown>,
|
||||
extraQueryParams?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const { token: sessionToken } =
|
||||
await this.services.auth.createSessionToken(
|
||||
user as import('../../stores/user/UserStore.js').UserRow,
|
||||
);
|
||||
|
||||
const cookieName = this.config.cookie_name ?? 'puter_token';
|
||||
res.cookie(cookieName, sessionToken, {
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
await startWebSession(req, res, user, {
|
||||
config: this.config,
|
||||
auth: this.services.auth,
|
||||
});
|
||||
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
|
||||
@@ -172,21 +172,17 @@ describe('PeerController', () => {
|
||||
ingestHandler = route!.handler;
|
||||
});
|
||||
|
||||
it('rejects requests without valid internal auth secret', async () => {
|
||||
const req = makeReq({
|
||||
body: { records: [] },
|
||||
headers: { 'x-puter-internal-auth': 'wrong-secret' },
|
||||
});
|
||||
await expect(
|
||||
ingestHandler(req, makeRes().res),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rejects requests with missing auth header', async () => {
|
||||
const req = makeReq({ body: { records: [] } });
|
||||
await expect(
|
||||
ingestHandler(req, makeRes().res),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
it('is gated on the configured internal auth secret', () => {
|
||||
const router = new PuterRouter();
|
||||
controller.registerRoutes(router);
|
||||
const route = router.routes.find(
|
||||
(r) => r.path === '/turn/ingest-usage',
|
||||
)!;
|
||||
expect(
|
||||
route.options.internalAuth?.({
|
||||
peers: { internal_auth_secret: 'test-secret' },
|
||||
} as never),
|
||||
).toBe('test-secret');
|
||||
});
|
||||
|
||||
it('rejects when records is not an array', async () => {
|
||||
@@ -537,30 +533,6 @@ describe('PeerController TURN', () => {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects usage ingest when no internal secret is configured', async () => {
|
||||
const openServer = await setupTestServer();
|
||||
try {
|
||||
const router = new PuterRouter();
|
||||
(
|
||||
openServer.controllers.peer as unknown as PeerController
|
||||
).registerRoutes(router);
|
||||
const handler = router.routes.find(
|
||||
(r) => r.path === '/turn/ingest-usage',
|
||||
)!.handler;
|
||||
await expect(
|
||||
handler(
|
||||
makeReq({
|
||||
body: { records: [] },
|
||||
headers: { 'x-puter-internal-auth': 'anything' },
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
} finally {
|
||||
await openServer.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- Guest TURN access -------------------------------------------------
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
@@ -46,21 +45,6 @@ const DEFAULT_GRANT_TTL = 3600;
|
||||
*/
|
||||
const DEFAULT_GUEST_CREDENTIAL_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Constant-time secret comparison for the internal-auth header. HMAC both sides
|
||||
* under a random per-process key to a fixed 32-byte digest first: this avoids
|
||||
* leaking length via an early-return and sidesteps `timingSafeEqual`'s
|
||||
* equal-length requirement for arbitrary-length inputs. The key need not
|
||||
* persist — it only has to be unknown to the attacker for the duration of the
|
||||
* comparison.
|
||||
*/
|
||||
const COMPARE_KEY = randomBytes(32);
|
||||
const secretsEqual = (a: string, b: string): boolean => {
|
||||
const ha = createHmac('sha256', COMPARE_KEY).update(a).digest();
|
||||
const hb = createHmac('sha256', COMPARE_KEY).update(b).digest();
|
||||
return timingSafeEqual(ha, hb);
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode a UUID (or `app-<uuid>` UID) as base64url with no padding. Strips an
|
||||
* `app-` prefix and dashes, then reinterprets the hex bytes.
|
||||
@@ -230,6 +214,7 @@ export class PeerController extends PuterController {
|
||||
'/turn/ingest-usage',
|
||||
{
|
||||
subdomain: 'api',
|
||||
internalAuth: (config) => config.peers?.internal_auth_secret,
|
||||
// Shared-secret authenticated, so this only bounds how
|
||||
// fast someone can guess the secret.
|
||||
rateLimit: {
|
||||
@@ -415,20 +400,6 @@ export class PeerController extends PuterController {
|
||||
* `services.metering.incrementUsage` multiplied by turn:egress-bytes cost.
|
||||
*/
|
||||
#ingestUsage = async (req: Request, res: Response): Promise<void> => {
|
||||
const cfg = this.config.peers;
|
||||
if (!cfg || !cfg.internal_auth_secret) {
|
||||
throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
|
||||
}
|
||||
const expectedSecret = cfg.internal_auth_secret;
|
||||
const header = req.headers['x-puter-internal-auth'];
|
||||
if (
|
||||
!expectedSecret ||
|
||||
typeof header !== 'string' ||
|
||||
!secretsEqual(header, expectedSecret)
|
||||
) {
|
||||
throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
|
||||
}
|
||||
|
||||
const { records } = req.body ?? {};
|
||||
if (!Array.isArray(records)) {
|
||||
throw new HttpError(400, 'Missing `records` array', {
|
||||
|
||||
@@ -87,6 +87,7 @@ export class ProfileController extends PuterController {
|
||||
@Post('/', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: {
|
||||
scope: 'profile-write',
|
||||
limit: 30,
|
||||
|
||||
@@ -197,15 +197,11 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
text: { verbosity: 'low' },
|
||||
include: ['file_search_call.results'],
|
||||
metadata: { trace: 'abc' },
|
||||
conversation: 'conv_1',
|
||||
context_management: [{ type: 'compaction' }],
|
||||
previous_response_id: 'resp_prev',
|
||||
prompt: { id: 'p_1' },
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
store: true,
|
||||
truncation: 'auto',
|
||||
background: false,
|
||||
service_tier: 'default',
|
||||
},
|
||||
}),
|
||||
@@ -215,7 +211,9 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
const args = completeSpy.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(args).toMatchObject({
|
||||
model: 'gpt-test',
|
||||
tool_choice: 'auto',
|
||||
// Normalized by toolChoiceFromBody (the wire-shape mapper), not
|
||||
// forwarded raw.
|
||||
tool_choice: { type: 'auto' },
|
||||
parallel_tool_calls: true,
|
||||
temperature: 0.2,
|
||||
max_tokens: 512,
|
||||
@@ -224,15 +222,12 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
text: { verbosity: 'low' },
|
||||
include: ['file_search_call.results'],
|
||||
metadata: { trace: 'abc' },
|
||||
conversation: 'conv_1',
|
||||
context_management: [{ type: 'compaction' }],
|
||||
previous_response_id: 'resp_prev',
|
||||
prompt: { id: 'p_1' },
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
store: true,
|
||||
// forced false regardless of the client's request.
|
||||
store: false,
|
||||
truncation: 'auto',
|
||||
background: false,
|
||||
service_tier: 'default',
|
||||
provider: 'openai-responses',
|
||||
});
|
||||
@@ -260,7 +255,6 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
tool_choice: 'required',
|
||||
parallel_tool_calls: true,
|
||||
max_output_tokens: 512,
|
||||
previous_response_id: 'resp_prev',
|
||||
store: false,
|
||||
text: { verbosity: 'low' },
|
||||
truncation: 'disabled',
|
||||
@@ -287,7 +281,6 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
tool_choice: 'required',
|
||||
parallel_tool_calls: true,
|
||||
max_output_tokens: 512,
|
||||
previous_response_id: 'resp_prev',
|
||||
store: false,
|
||||
text: { verbosity: 'low' },
|
||||
truncation: 'disabled',
|
||||
@@ -315,10 +308,12 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
expect(body.temperature).toBeNull();
|
||||
expect(body.top_p).toBeNull();
|
||||
expect(body.tool_choice).toBe('auto');
|
||||
expect(body.parallel_tool_calls).toBe(false);
|
||||
// The OpenAI routes default parallel tool use on.
|
||||
expect(body.parallel_tool_calls).toBe(true);
|
||||
expect(body.tools).toEqual([]);
|
||||
expect('max_output_tokens' in body).toBe(false);
|
||||
expect('store' in body).toBe(false);
|
||||
// always present and false, regardless of the request.
|
||||
expect(body.store).toBe(false);
|
||||
expect(body.output).toEqual([]);
|
||||
expect(body.output_text).toBe('');
|
||||
});
|
||||
@@ -531,6 +526,16 @@ describe('PuterAIController.openaiResponses output items', () => {
|
||||
cached_tokens: 3,
|
||||
output_tokens_details: { reasoning_tokens: 2 },
|
||||
},
|
||||
// `UsageDetails.inputTokens` excludes cache reads (Anthropic
|
||||
// convention); `responsesUsage` adds them back for OpenAI's
|
||||
// `input_tokens`, which includes the cached slice — so this
|
||||
// nets out to the expected input_tokens: 10 below.
|
||||
usageDetails: {
|
||||
inputTokens: 7,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 3,
|
||||
reasoningTokens: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
@@ -585,6 +590,12 @@ describe('PuterAIController.openaiResponses output items', () => {
|
||||
output_tokens: 2,
|
||||
input_tokens_details: { cached_tokens: 5 },
|
||||
},
|
||||
// See the `inputTokens` note in the test above.
|
||||
usageDetails: {
|
||||
inputTokens: 3,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
@@ -683,6 +694,37 @@ describe('PuterAIController.openaiChatCompletions translation edges', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a native tool_use stop to finish_reason=tool_calls, not the wire-level stop', async () => {
|
||||
stubChatComplete({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tu_1', name: 'lookup', input: {} },
|
||||
],
|
||||
stop_reason: 'tool_use',
|
||||
},
|
||||
// A native Claude result always reports `stop` here — the real
|
||||
// reason is read off `message.stop_reason` instead.
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiChatCompletions(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const choice = (
|
||||
captured.body as { choices: Array<Record<string, unknown>> }
|
||||
).choices[0]!;
|
||||
expect(choice.finish_reason).toBe('tool_calls');
|
||||
});
|
||||
|
||||
it('omits tool_calls entirely when no content part is a tool_use', async () => {
|
||||
stubChatComplete({
|
||||
message: {
|
||||
@@ -712,6 +754,7 @@ describe('PuterAIController.openaiChatCompletions translation edges', () => {
|
||||
stubChatComplete({
|
||||
message: { content: { text: 'object content' } },
|
||||
usage: { input_tokens: 11, output_tokens: 5 },
|
||||
usageDetails: { inputTokens: 11, outputTokens: 5 },
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
@@ -731,6 +774,7 @@ describe('PuterAIController.openaiChatCompletions translation edges', () => {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 16,
|
||||
prompt_tokens_details: { cached_tokens: 0 },
|
||||
});
|
||||
const choice = (body.choices as Array<Record<string, unknown>>)[0]!;
|
||||
// A missing role defaults to assistant; `{ text }` content is read.
|
||||
@@ -844,6 +888,7 @@ describe('PuterAIController.openaiChatCompletions translation edges', () => {
|
||||
{
|
||||
type: 'usage',
|
||||
usage: { prompt_tokens: 3, completion_tokens: 1 },
|
||||
usageDetails: { inputTokens: 3, outputTokens: 1 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -893,10 +938,50 @@ describe('PuterAIController.openaiChatCompletions translation edges', () => {
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 4,
|
||||
prompt_tokens_details: { cached_tokens: 0 },
|
||||
});
|
||||
expect(captured.ended).toBe(true);
|
||||
});
|
||||
|
||||
it('promotes an explicit finish_reason=stop to tool_calls when the run emitted tool calls', async () => {
|
||||
stubChatComplete(
|
||||
streamResult([
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tu_1',
|
||||
name: 'lookup',
|
||||
input: { q: 1 },
|
||||
},
|
||||
{
|
||||
type: 'usage',
|
||||
usage: { prompt_tokens: 3, completion_tokens: 1 },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiChatCompletions(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
await settleStream();
|
||||
|
||||
const frames = captured.written
|
||||
.join('')
|
||||
.split('\n\n')
|
||||
.filter((f) => f.startsWith('data: ') && !f.includes('[DONE]'))
|
||||
.map((f) => JSON.parse(f.slice(6)));
|
||||
const last = frames[frames.length - 1]!;
|
||||
expect(last.choices[0].finish_reason).toBe('tool_calls');
|
||||
});
|
||||
|
||||
it('emits a stream_error frame then [DONE] when the source stream fails', async () => {
|
||||
const stream = new Readable({ read() {} });
|
||||
stubChatComplete({
|
||||
@@ -1030,6 +1115,28 @@ describe('PuterAIController.openaiCompletions translation edges', () => {
|
||||
.choices[0],
|
||||
).toMatchObject({ text: 'trimmed', finish_reason: 'length' });
|
||||
});
|
||||
|
||||
it('maps a native max_tokens stop to finish_reason=length', async () => {
|
||||
stubChatComplete({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'trimmed',
|
||||
stop_reason: 'max_tokens',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiCompletions(
|
||||
makeReq({ body: { model: 'gpt-test', prompt: 'hi' } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(
|
||||
(captured.body as { choices: Array<Record<string, unknown>> })
|
||||
.choices[0],
|
||||
).toMatchObject({ finish_reason: 'length' });
|
||||
});
|
||||
});
|
||||
|
||||
// -- /anthropic/v1/messages edges ------------------------------------
|
||||
@@ -1076,32 +1183,23 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
compaction: true,
|
||||
provider: 'claude-alt',
|
||||
});
|
||||
// Shape only at the controller — tools pass through exactly as given;
|
||||
// wrapping/policy is ClaudeProvider's job (`normalize_tools_object`).
|
||||
expect(args.tools).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
function: { name: 'already', parameters: {} },
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'shorthand',
|
||||
description: '',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bare',
|
||||
description: '',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
name: 'shorthand',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
{ name: 'bare' },
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits tools entirely for an empty tools array', async () => {
|
||||
it('forwards an empty tools array unchanged', async () => {
|
||||
const completeSpy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
});
|
||||
@@ -1118,10 +1216,29 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
res,
|
||||
);
|
||||
|
||||
expect('tools' in completeSpy.mock.calls[0]![0]).toBe(false);
|
||||
expect(completeSpy.mock.calls[0]![0].tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a system array that yields no text and skips non-object messages', async () => {
|
||||
it('rejects a system block with no recognized shape (400)', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'claude-test',
|
||||
system: [{ notText: true }],
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'system.0: unsupported block type',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips non-object top-level messages', async () => {
|
||||
const completeSpy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
});
|
||||
@@ -1131,7 +1248,6 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'claude-test',
|
||||
system: [{ notText: true }],
|
||||
messages: [null, 'nope', { role: 'user', content: 'hi' }],
|
||||
},
|
||||
}),
|
||||
@@ -1165,38 +1281,36 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
).toEqual([{ role: 'user', content: 'hi' }]);
|
||||
});
|
||||
|
||||
it('keeps non-tool_result parts alongside hoisted tool results', async () => {
|
||||
it('passes a user message mixing text and tool_result parts through unchanged', async () => {
|
||||
const completeSpy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
});
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'and also' },
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu_1',
|
||||
content: [
|
||||
{ type: 'text', text: 'part-a' },
|
||||
'part-b',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu_2',
|
||||
content: 42,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { res } = makeRes();
|
||||
await controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'claude-test',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'and also' },
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu_1',
|
||||
content: [
|
||||
{ type: 'text', text: 'part-a' },
|
||||
'part-b',
|
||||
{ notText: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu_2',
|
||||
content: 42,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
messages: [userMessage],
|
||||
},
|
||||
}),
|
||||
res,
|
||||
@@ -1204,11 +1318,33 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
|
||||
expect(
|
||||
(completeSpy.mock.calls[0]![0] as { messages: unknown[] }).messages,
|
||||
).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'and also' }] },
|
||||
{ role: 'tool', tool_call_id: 'tu_1', content: 'part-apart-b' },
|
||||
{ role: 'tool', tool_call_id: 'tu_2', content: '' },
|
||||
]);
|
||||
).toEqual([userMessage]);
|
||||
});
|
||||
|
||||
it('rejects an unrecognized block nested in a tool_result content array', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'claude-test',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu_1',
|
||||
content: [{ notText: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('leaves an assistant array message untouched', async () => {
|
||||
@@ -1270,7 +1406,7 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
{ type: 'tool_use', id: 'c4', name: '', input: {} },
|
||||
]);
|
||||
expect(body.stop_reason).toBe('tool_use');
|
||||
expect(body.usage).toEqual({ input_tokens: 3, output_tokens: 1 });
|
||||
expect(body.usage).toMatchObject({ input_tokens: 3, output_tokens: 1 });
|
||||
});
|
||||
|
||||
it('parses a string `input` on a tool_use content block', async () => {
|
||||
@@ -1301,8 +1437,9 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
res,
|
||||
);
|
||||
|
||||
// Native block-array content preserves original order — unlike the
|
||||
// old OpenAI-shaped reordering, text does not move to the front.
|
||||
expect((captured.body as { content: unknown[] }).content).toEqual([
|
||||
{ type: 'text', text: 'trailing' },
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tu_1',
|
||||
@@ -1310,6 +1447,7 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
input: { q: 'puter' },
|
||||
},
|
||||
{ type: 'tool_use', id: 'tu_2', name: 'bad', input: {} },
|
||||
{ type: 'text', text: 'trailing' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1330,7 +1468,7 @@ describe('PuterAIController.anthropicMessages translation edges', () => {
|
||||
res,
|
||||
);
|
||||
|
||||
expect((captured.body as { usage: unknown }).usage).toEqual({
|
||||
expect((captured.body as { usage: unknown }).usage).toMatchObject({
|
||||
input_tokens: 9,
|
||||
output_tokens: 4,
|
||||
});
|
||||
|
||||
@@ -33,11 +33,12 @@ import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
describe('AI wire routes require a subscription', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
const ANTHROPIC_ROUTE = '/puterai/anthropic/v1/messages';
|
||||
const WIRE_ROUTES = [
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/openai/v1/completions',
|
||||
'/puterai/openai/v1/responses',
|
||||
'/puterai/anthropic/v1/messages',
|
||||
ANTHROPIC_ROUTE,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -105,13 +106,24 @@ describe('AI wire routes require a subscription', () => {
|
||||
}
|
||||
};
|
||||
|
||||
it('turns a free account away with 402 subscription_required', async () => {
|
||||
it('turns a free account away with 402, in each vendor route\'s own envelope', async () => {
|
||||
for (const route of WIRE_ROUTES) {
|
||||
const res = await call(route, env.users.user.apiToken);
|
||||
expect(res.status, route).toBe(402);
|
||||
expect(await res.json(), route).toMatchObject({
|
||||
code: 'subscription_required',
|
||||
});
|
||||
const body = await res.json();
|
||||
if (route === ANTHROPIC_ROUTE) {
|
||||
// The vendor-compatible routes render every error — gate
|
||||
// failures included — in their vendor's own envelope.
|
||||
expect(body, route).toMatchObject({
|
||||
type: 'error',
|
||||
error: { type: 'billing_error' },
|
||||
});
|
||||
expect(res.headers.get('request-id'), route).toBeTruthy();
|
||||
} else {
|
||||
expect(body, route).toMatchObject({
|
||||
error: { code: 'subscription_required' },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -132,8 +144,10 @@ describe('AI wire routes require a subscription', () => {
|
||||
// never learns anything about the account's plan.
|
||||
const res = await call(WIRE_ROUTES[0]!, env.users.user.token);
|
||||
expect(res.status).toBe(403);
|
||||
// Still rendered in the OpenAI envelope — the renderer is wired
|
||||
// before any gate runs, not just around the handler.
|
||||
expect(await res.json()).toMatchObject({
|
||||
code: 'app_or_api_token_required',
|
||||
error: { code: 'app_or_api_token_required' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,638 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import type { IChatMessageResult } from '../../drivers/ai-chat/types.js';
|
||||
import {
|
||||
anthropicUsage,
|
||||
parseAnthropicRequest,
|
||||
parseBetaHeader,
|
||||
systemToMessages,
|
||||
toAnthropicMessage,
|
||||
validateMessages,
|
||||
} from './anthropicWire.js';
|
||||
|
||||
describe('systemToMessages', () => {
|
||||
it('wraps a string system prompt as a single text block', () => {
|
||||
expect(systemToMessages('be helpful')).toEqual([
|
||||
{ role: 'system', content: [{ type: 'text', text: 'be helpful' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty string', () => {
|
||||
expect(systemToMessages('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps every array block in order on one leading system message', () => {
|
||||
expect(
|
||||
systemToMessages([
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'text', text: 'second' },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'text', text: 'second' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('sanitizes cache_control and keeps citations on a system block', () => {
|
||||
const out = systemToMessages([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'cached',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h', scope: 'global' },
|
||||
citations: [{ type: 'char_location' }],
|
||||
},
|
||||
]);
|
||||
expect(out[0]!.content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'cached',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
citations: [{ type: 'char_location' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('never merges the first block with later ones — an isolated attribution header stays isolated', () => {
|
||||
const header =
|
||||
'x-anthropic-billing-header: cc_version=2.1.289; cc_entrypoint=cli;';
|
||||
const out = systemToMessages([
|
||||
{ type: 'text', text: header },
|
||||
{ type: 'text', text: 'You are a coding agent.' },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{ type: 'text', text: header },
|
||||
{ type: 'text', text: 'You are a coding agent.' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a non-text block with the exact message, including its index', () => {
|
||||
expect(() =>
|
||||
systemToMessages([{ type: 'text', text: 'ok' }, { type: 'image' }]),
|
||||
).toThrowError('system.1: unsupported block type');
|
||||
});
|
||||
|
||||
it('ignores a non-string, non-array system value', () => {
|
||||
expect(systemToMessages({ unexpected: 'shape' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty array', () => {
|
||||
expect(systemToMessages([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMessages', () => {
|
||||
it('drops non-object top-level entries, keeps the rest', () => {
|
||||
expect(
|
||||
validateMessages([null, 'nope', { role: 'user', content: 'hi' }]),
|
||||
).toEqual([{ role: 'user', content: 'hi' }]);
|
||||
});
|
||||
|
||||
it('passes tool_result blocks through unchanged — no hoisting', () => {
|
||||
const message = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu_1',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(validateMessages([message])).toEqual([message]);
|
||||
});
|
||||
|
||||
it('rejects an unknown block type', () => {
|
||||
expect(() =>
|
||||
validateMessages([
|
||||
{ role: 'user', content: [{ type: 'bogus' }] },
|
||||
]),
|
||||
).toThrowError('messages.0.content.0: unsupported block type');
|
||||
});
|
||||
|
||||
it('rejects an empty text block', () => {
|
||||
expect(() =>
|
||||
validateMessages([
|
||||
{ role: 'user', content: [{ type: 'text', text: '' }] },
|
||||
]),
|
||||
).toThrowError('text content blocks must be non-empty');
|
||||
});
|
||||
|
||||
it('rejects a file-source image block', () => {
|
||||
expect(() =>
|
||||
validateMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'file', file_id: 'f1' } },
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toThrowError('file-source image blocks are not supported');
|
||||
});
|
||||
|
||||
it('rejects a file-source document block', () => {
|
||||
expect(() =>
|
||||
validateMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'document',
|
||||
source: { type: 'file', file_id: 'f1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toThrowError('file-source document blocks are not supported');
|
||||
});
|
||||
|
||||
it('rejects a container_upload block', () => {
|
||||
expect(() =>
|
||||
validateMessages([
|
||||
{ role: 'user', content: [{ type: 'container_upload' }] },
|
||||
]),
|
||||
).toThrowError('unsupported block type');
|
||||
});
|
||||
|
||||
it('allows a base64/url-sourced image block', () => {
|
||||
const message = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'AA' },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(validateMessages([message])).toEqual([message]);
|
||||
});
|
||||
|
||||
it('validates nested blocks inside a tool_result content array', () => {
|
||||
expect(() =>
|
||||
validateMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu_1',
|
||||
content: [{ bogus: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toThrowError(
|
||||
'messages.0.content.0.content.0: unsupported block type',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows an OpenAI Responses input_image block — rewritten downstream', () => {
|
||||
const message = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_image', image_url: 'https://x/img.png' },
|
||||
],
|
||||
};
|
||||
expect(validateMessages([message])).toEqual([message]);
|
||||
});
|
||||
|
||||
it('allows a video_url block, typed or untyped — rewritten downstream', () => {
|
||||
const typed = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'video_url', video_url: { url: 'https://x/v.mp4' } },
|
||||
],
|
||||
};
|
||||
const untyped = {
|
||||
role: 'user',
|
||||
content: [{ video_url: 'https://x/v.mp4' }],
|
||||
};
|
||||
expect(validateMessages([typed])).toEqual([typed]);
|
||||
expect(validateMessages([untyped])).toEqual([untyped]);
|
||||
});
|
||||
|
||||
it('allows an untyped image_url block — OpenAI Chat\'s untyped shape', () => {
|
||||
const message = {
|
||||
role: 'user',
|
||||
content: [{ image_url: 'https://x/img.png' }],
|
||||
};
|
||||
expect(validateMessages([message])).toEqual([message]);
|
||||
});
|
||||
|
||||
it('still rejects a block with no type and no recognized media shape', () => {
|
||||
expect(() =>
|
||||
validateMessages([
|
||||
{ role: 'user', content: [{ bogus: true }] },
|
||||
]),
|
||||
).toThrowError('messages.0.content.0: unsupported block type');
|
||||
});
|
||||
|
||||
it('allows an internal puter_path part with no type', () => {
|
||||
const message = {
|
||||
role: 'user',
|
||||
content: [{ puter_path: '/a/b.png' }],
|
||||
};
|
||||
expect(validateMessages([message])).toEqual([message]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBetaHeader', () => {
|
||||
it('comma-splits, trims and dedupes', () => {
|
||||
expect(parseBetaHeader(' a , b,a ,c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('joins a repeated header value before splitting', () => {
|
||||
expect(parseBetaHeader(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns [] for an absent or empty header', () => {
|
||||
expect(parseBetaHeader(undefined)).toEqual([]);
|
||||
expect(parseBetaHeader('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAnthropicRequest', () => {
|
||||
const headers = {};
|
||||
|
||||
it('rejects a body with no messages array', () => {
|
||||
expect(() => parseAnthropicRequest({}, headers)).toThrowError(
|
||||
'messages: Field required',
|
||||
);
|
||||
});
|
||||
|
||||
it('parses successfully with mcp_servers present, and drops it from the result', () => {
|
||||
const result = parseAnthropicRequest(
|
||||
{ messages: [], mcp_servers: [{ type: 'url', url: 'x' }] },
|
||||
headers,
|
||||
);
|
||||
expect('mcp_servers' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a body.thread with the exact Unexpected-value(s) message', () => {
|
||||
expect(() =>
|
||||
parseAnthropicRequest(
|
||||
{ messages: [], thread: { type: 'create' } },
|
||||
headers,
|
||||
),
|
||||
).toThrowError(
|
||||
'Unexpected value(s) `message-threads-2026-08-12` for the `anthropic-beta` header. Please try again without the header.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a message-threads anthropic-beta header, naming the header value', () => {
|
||||
expect(() =>
|
||||
parseAnthropicRequest(
|
||||
{ messages: [] },
|
||||
{ 'anthropic-beta': 'message-threads-2099-01-01' },
|
||||
),
|
||||
).toThrowError(
|
||||
'Unexpected value(s) `message-threads-2099-01-01` for the `anthropic-beta` header',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults temperature to 1 and provider to claude', () => {
|
||||
const args = parseAnthropicRequest({ messages: [] }, headers);
|
||||
expect(args.temperature).toBe(1);
|
||||
expect(args.provider).toBe('claude');
|
||||
expect(args.normalize).toBe(false);
|
||||
expect(args.streamToolInput).toBe(true);
|
||||
});
|
||||
|
||||
it('maps thinking, tool_choice, stop_sequences, top_p/top_k', () => {
|
||||
const args = parseAnthropicRequest(
|
||||
{
|
||||
messages: [],
|
||||
thinking: { type: 'enabled', budget_tokens: 2048 },
|
||||
tool_choice: { type: 'tool', name: 'Bash', disable_parallel_tool_use: true },
|
||||
stop_sequences: ['STOP'],
|
||||
top_p: 0.5,
|
||||
top_k: 10,
|
||||
},
|
||||
headers,
|
||||
);
|
||||
expect(args.thinking).toEqual({ type: 'enabled', budgetTokens: 2048 });
|
||||
expect(args.tool_choice).toEqual({ type: 'tool', name: 'Bash' });
|
||||
expect(args.parallel_tool_calls).toBe(false);
|
||||
expect(args.stopSequences).toEqual(['STOP']);
|
||||
expect(args.top_p).toBe(0.5);
|
||||
expect(args.topK).toBe(10);
|
||||
});
|
||||
|
||||
it('defaults parallel_tool_calls to true when disable_parallel_tool_use is absent', () => {
|
||||
const args = parseAnthropicRequest(
|
||||
{ messages: [], tool_choice: { type: 'auto' } },
|
||||
headers,
|
||||
);
|
||||
expect(args.parallel_tool_calls).toBe(true);
|
||||
});
|
||||
|
||||
it('thinking:null → no thinking field is sent (absent thinking stays null, not legacy-derived)', () => {
|
||||
const args = parseAnthropicRequest({ messages: [] }, headers);
|
||||
expect(args.thinking).toBeNull();
|
||||
});
|
||||
|
||||
it('maps output_config to reasoning_effort/outputFormat/taskBudget', () => {
|
||||
const args = parseAnthropicRequest(
|
||||
{
|
||||
messages: [],
|
||||
output_config: {
|
||||
effort: 'high',
|
||||
format: { type: 'json_schema', schema: { type: 'object' } },
|
||||
task_budget: { total: 100, remaining: 40 },
|
||||
},
|
||||
},
|
||||
headers,
|
||||
);
|
||||
expect(args.reasoning_effort).toBe('high');
|
||||
expect(args.outputFormat).toEqual({
|
||||
type: 'json_schema',
|
||||
schema: { type: 'object' },
|
||||
});
|
||||
expect(args.taskBudget).toEqual({ total: 100, remaining: 40 });
|
||||
});
|
||||
|
||||
it('rejects a malformed compaction shape', () => {
|
||||
expect(() =>
|
||||
parseAnthropicRequest({ messages: [], compaction: 'yes' }, headers),
|
||||
).toThrowError('compaction: must be a boolean or { trigger_tokens }');
|
||||
});
|
||||
|
||||
it('forwards safeguards, speed, cache_control and anthropicBetas', () => {
|
||||
const args = parseAnthropicRequest(
|
||||
{
|
||||
messages: [],
|
||||
safeguards: [{ type: 'dangerous_tool_use', classifier_context: {} }],
|
||||
speed: 'fast',
|
||||
cache_control: { type: 'ephemeral', ttl: '5m' },
|
||||
},
|
||||
{ 'anthropic-beta': 'interleaved-thinking-2025-05-14' },
|
||||
);
|
||||
expect(args.safeguards).toHaveLength(1);
|
||||
expect(args.speed).toBe('fast');
|
||||
expect(args.cacheControl).toEqual({ type: 'ephemeral', ttl: '5m' });
|
||||
expect(args.anthropicBetas).toEqual(['interleaved-thinking-2025-05-14']);
|
||||
});
|
||||
|
||||
it('count_tokens mode never streams, even if the body asks for it', () => {
|
||||
const args = parseAnthropicRequest(
|
||||
{ messages: [], stream: true },
|
||||
headers,
|
||||
{ countTokens: true },
|
||||
);
|
||||
expect(args.stream).toBe(false);
|
||||
});
|
||||
|
||||
it('prepends the system message, in order, ahead of validated messages', () => {
|
||||
const args = parseAnthropicRequest(
|
||||
{
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
system: 'be helpful',
|
||||
},
|
||||
headers,
|
||||
);
|
||||
expect(args.messages).toEqual([
|
||||
{ role: 'system', content: [{ type: 'text', text: 'be helpful' }] },
|
||||
{ role: 'user', content: 'hi' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anthropicUsage', () => {
|
||||
it('builds the full usage shape from UsageDetails', () => {
|
||||
expect(
|
||||
anthropicUsage({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 2,
|
||||
cacheWrite5mTokens: 3,
|
||||
cacheWrite1hTokens: 4,
|
||||
reasoningTokens: 1,
|
||||
webSearchRequests: 1,
|
||||
speed: 'fast',
|
||||
serviceTier: 'standard',
|
||||
}),
|
||||
).toEqual({
|
||||
input_tokens: 10,
|
||||
cache_creation_input_tokens: 7,
|
||||
cache_read_input_tokens: 2,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 3,
|
||||
ephemeral_1h_input_tokens: 4,
|
||||
},
|
||||
output_tokens: 5,
|
||||
output_tokens_details: { thinking_tokens: 1 },
|
||||
server_tool_use: { web_search_requests: 1, web_fetch_requests: 0 },
|
||||
speed: 'fast',
|
||||
service_tier: 'standard',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits optional keys when the detail is absent', () => {
|
||||
expect(anthropicUsage({ inputTokens: 1, outputTokens: 2 })).toEqual({
|
||||
input_tokens: 1,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 0,
|
||||
ephemeral_1h_input_tokens: 0,
|
||||
},
|
||||
output_tokens: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes every iteration with all four token counts (what Claude Code trusts)', () => {
|
||||
const usage = anthropicUsage({
|
||||
inputTokens: 30,
|
||||
outputTokens: 6,
|
||||
iterations: [
|
||||
{ type: 'message', inputTokens: 10, outputTokens: 2 },
|
||||
{
|
||||
type: 'advisor_message',
|
||||
model: 'claude-opus-4-8',
|
||||
inputTokens: 20,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 1,
|
||||
cacheWrite1hTokens: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(usage.iterations).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 0,
|
||||
ephemeral_1h_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'advisor_message',
|
||||
model: 'claude-opus-4-8',
|
||||
input_tokens: 20,
|
||||
output_tokens: 4,
|
||||
cache_read_input_tokens: 1,
|
||||
cache_creation_input_tokens: 3,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 0,
|
||||
ephemeral_1h_input_tokens: 3,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAnthropicMessage', () => {
|
||||
const ids = { id: 'msg_1', model: 'claude-test' };
|
||||
|
||||
it('keeps native blocks verbatim, in order (no reordering)', () => {
|
||||
const result = {
|
||||
message: {
|
||||
type: 'message',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 't1', name: 'a', input: {} },
|
||||
{ type: 'text', text: 'trailing' },
|
||||
],
|
||||
},
|
||||
stopReason: 'tool_use',
|
||||
stopSequence: null,
|
||||
usageDetails: { inputTokens: 1, outputTokens: 2 },
|
||||
} as unknown as IChatMessageResult;
|
||||
const out = toAnthropicMessage(result, ids);
|
||||
expect(out.content).toEqual([
|
||||
{ type: 'tool_use', id: 't1', name: 'a', input: {} },
|
||||
{ type: 'text', text: 'trailing' },
|
||||
]);
|
||||
expect(out.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('drops an empty native text block and returns [] when nothing survives', () => {
|
||||
const result = {
|
||||
message: { type: 'message', content: [{ type: 'text', text: '' }] },
|
||||
} as unknown as IChatMessageResult;
|
||||
expect(toAnthropicMessage(result, ids).content).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops an unrecognized native block type with a warn, not a throw', () => {
|
||||
const result = {
|
||||
message: {
|
||||
type: 'message',
|
||||
content: [
|
||||
{ type: 'container_upload' },
|
||||
{ type: 'text', text: 'hi' },
|
||||
],
|
||||
},
|
||||
} as unknown as IChatMessageResult;
|
||||
expect(toAnthropicMessage(result, ids).content).toEqual([
|
||||
{ type: 'text', text: 'hi' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses a stringified tool_use input on a native block', () => {
|
||||
const result = {
|
||||
message: {
|
||||
type: 'message',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 't1', name: 'a', input: '{"x":1}' },
|
||||
],
|
||||
},
|
||||
} as unknown as IChatMessageResult;
|
||||
expect(toAnthropicMessage(result, ids).content).toEqual([
|
||||
{ type: 'tool_use', id: 't1', name: 'a', input: { x: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a native compaction block, renaming content to encrypted_content', () => {
|
||||
const result = {
|
||||
message: {
|
||||
type: 'message',
|
||||
content: [{ type: 'compaction', id: 'c1', content: 'ENC' }],
|
||||
},
|
||||
} as unknown as IChatMessageResult;
|
||||
expect(toAnthropicMessage(result, ids).content).toEqual([
|
||||
{ type: 'compaction', id: 'c1', encrypted_content: 'ENC' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('OpenAI-shaped: orders reasoning details, then text, then tool_calls, then compaction', () => {
|
||||
const result = {
|
||||
message: {
|
||||
content: 'hello',
|
||||
reasoning_details: [
|
||||
{ type: 'thinking', thinking: 't', signature: 's' },
|
||||
{ type: 'redacted_thinking', data: 'd' },
|
||||
],
|
||||
tool_calls: [
|
||||
{ id: 'c1', function: { name: 'f', arguments: '{"a":1}' } },
|
||||
],
|
||||
},
|
||||
compaction: { id: 'cm1', encrypted_content: 'ENC' },
|
||||
} as unknown as IChatMessageResult;
|
||||
expect(toAnthropicMessage(result, ids).content).toEqual([
|
||||
{ type: 'thinking', thinking: 't', signature: 's' },
|
||||
{ type: 'redacted_thinking', data: 'd' },
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'tool_use', id: 'c1', name: 'f', input: { a: 1 } },
|
||||
{ type: 'compaction', id: 'cm1', encrypted_content: 'ENC' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes safeguard_results and context_management only when present', () => {
|
||||
const withBoth = toAnthropicMessage(
|
||||
{
|
||||
message: { type: 'message', content: [] },
|
||||
safeguardResults: [{ type: 'dangerous_tool_use' }],
|
||||
contextManagement: { applied_edits: [] },
|
||||
} as unknown as IChatMessageResult,
|
||||
ids,
|
||||
);
|
||||
expect(withBoth.safeguard_results).toEqual([
|
||||
{ type: 'dangerous_tool_use' },
|
||||
]);
|
||||
expect(withBoth.context_management).toEqual({ applied_edits: [] });
|
||||
|
||||
const withNeither = toAnthropicMessage(
|
||||
{ message: { type: 'message', content: [] } } as unknown as IChatMessageResult,
|
||||
ids,
|
||||
);
|
||||
expect(withNeither).not.toHaveProperty('safeguard_results');
|
||||
expect(withNeither).not.toHaveProperty('context_management');
|
||||
});
|
||||
|
||||
it('never includes container', () => {
|
||||
const result = {
|
||||
message: { type: 'message', content: [], container: { id: 'x' } },
|
||||
} as unknown as IChatMessageResult;
|
||||
expect(toAnthropicMessage(result, ids)).not.toHaveProperty('container');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,680 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Anthropic `/v1/messages` wire translation.
|
||||
*
|
||||
* `parseAnthropicRequest` maps the Anthropic request body (plus the
|
||||
* `anthropic-beta` header) to a normalized `ICompleteArguments`, doing only
|
||||
* shape-level validation and field renaming — tool/beta _policy_ (clamps,
|
||||
* allowlists, rejects) lives on `ClaudeProvider`/`anthropicPolicy.ts`, since
|
||||
* `/drivers/call` can reach the provider directly with arbitrary args and must
|
||||
* see the same rules. `toAnthropicMessage` is the inverse for a non-streaming
|
||||
* result, and `anthropicUsage` is the usage-shape builder both the
|
||||
* non-streaming path and the SSE writer (`sse.ts`) share.
|
||||
*/
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { sanitizeCacheControl } from '../../drivers/ai-chat/providers/claude/anthropicPolicy.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
IChatMessageResult,
|
||||
OutputFormat,
|
||||
ReasoningEffort,
|
||||
SafeguardRequest,
|
||||
ThinkingConfig,
|
||||
ToolChoice,
|
||||
UsageDetails,
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import { needsOpenAICoercion } from '../../drivers/ai-chat/utils/normalizeToOpenAI.js';
|
||||
import { fromFinishReason } from '../../drivers/ai-chat/utils/stopReason.js';
|
||||
|
||||
const toStringOrEmpty = (v: unknown): string =>
|
||||
typeof v === 'string' ? v : '';
|
||||
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||
!!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
const num = (key: string, v: unknown): Record<string, number> => {
|
||||
if (v === undefined) return {};
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? { [key]: n } : {};
|
||||
};
|
||||
|
||||
const finiteMaxTokens = (v: unknown): { max_tokens: number } | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? { max_tokens: n } : undefined;
|
||||
};
|
||||
|
||||
const badRequest = (message: string): HttpError =>
|
||||
new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
|
||||
// -- anthropic-beta header --------------------------------------------------
|
||||
|
||||
/** Comma-split, trimmed, deduped. A repeated header line is joined first. */
|
||||
export const parseBetaHeader = (header: unknown): string[] => {
|
||||
const raw = Array.isArray(header) ? header.join(',') : header;
|
||||
if (typeof raw !== 'string' || !raw) return [];
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const MESSAGE_THREADS_BETA = 'message-threads-2026-08-12';
|
||||
|
||||
/**
|
||||
* `thread`/the message-threads beta are server-held conversation state this
|
||||
* backend doesn't hold. Rejected up front, before any other work, with the
|
||||
* exact message Claude Code's own `AMe` regex recognizes — it then drops the
|
||||
* named beta for the session and retries once without threading.
|
||||
*/
|
||||
const rejectThreads = (
|
||||
body: Record<string, unknown>,
|
||||
betas: readonly string[],
|
||||
): void => {
|
||||
const named = betas.find((b) => b.startsWith('message-threads-'));
|
||||
const beta =
|
||||
named ??
|
||||
(body.thread !== undefined && body.thread !== null
|
||||
? MESSAGE_THREADS_BETA
|
||||
: undefined);
|
||||
if (beta === undefined) return;
|
||||
throw badRequest(
|
||||
`Unexpected value(s) \`${beta}\` for the \`anthropic-beta\` header. Please try again without the header.`,
|
||||
);
|
||||
};
|
||||
|
||||
// -- system → messages -------------------------------------------------
|
||||
|
||||
const sanitizeSystemBlock = (
|
||||
block: unknown,
|
||||
index: number,
|
||||
): Record<string, unknown> => {
|
||||
if (typeof block === 'string') return { type: 'text', text: block };
|
||||
if (
|
||||
!isPlainObject(block) ||
|
||||
block.type !== 'text' ||
|
||||
typeof block.text !== 'string'
|
||||
) {
|
||||
throw badRequest(`system.${index}: unsupported block type`);
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
text: block.text,
|
||||
...(block.cache_control
|
||||
? { cache_control: sanitizeCacheControl(block.cache_control) }
|
||||
: {}),
|
||||
...(block.citations !== undefined
|
||||
? { citations: block.citations }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* `body.system` → zero or one leading `{role:'system', content: blocks}`
|
||||
* message. A string becomes a single text block; an array keeps every block in
|
||||
* its original order and position — never merged, reordered, or split. That
|
||||
* matters beyond fidelity: Claude Code's first system block is its own
|
||||
* attribution header, recognized by Anthropic only when it is the sole content
|
||||
* of an isolated block. Concatenating it with anything else silently discards
|
||||
* the rest of that block (verified live; see the integration test).
|
||||
*/
|
||||
export const systemToMessages = (
|
||||
system: unknown,
|
||||
): Record<string, unknown>[] => {
|
||||
if (typeof system === 'string') {
|
||||
return system
|
||||
? [{ role: 'system', content: [{ type: 'text', text: system }] }]
|
||||
: [];
|
||||
}
|
||||
if (!Array.isArray(system) || system.length === 0) return [];
|
||||
return [{ role: 'system', content: system.map(sanitizeSystemBlock) }];
|
||||
};
|
||||
|
||||
// -- messages ----------------------------------------------------
|
||||
|
||||
const ALLOWED_INPUT_BLOCK_TYPES = new Set([
|
||||
'text',
|
||||
'image',
|
||||
'document',
|
||||
'search_result',
|
||||
'tool_use',
|
||||
'tool_result',
|
||||
'tool_reference',
|
||||
'thinking',
|
||||
'redacted_thinking',
|
||||
'server_tool_use',
|
||||
'web_search_tool_result',
|
||||
'web_fetch_tool_result',
|
||||
'advisor_tool_result',
|
||||
'tool_search_tool_result',
|
||||
'compaction',
|
||||
// Internal, provider-resolved parts.
|
||||
'image_url',
|
||||
]);
|
||||
|
||||
const FILE_SOURCED_BLOCK_TYPES = new Set(['image', 'document']);
|
||||
|
||||
const validateContentBlock = (block: unknown, path: string): void => {
|
||||
if (typeof block === 'string') return;
|
||||
if (isPlainObject(block) && block.puter_path !== undefined) return;
|
||||
if (!isPlainObject(block)) {
|
||||
throw badRequest(`${path}: unsupported block type`);
|
||||
}
|
||||
// The OpenAI Responses `input_image` type, and an `image_url`/`video_url`
|
||||
// part with no `type` at all (OpenAI Chat's untyped shape), are
|
||||
// rewritten downstream by `normalizeMediaParts` rather than validated
|
||||
// here — main's driver accepted both. A `video_url`-typed block is the
|
||||
// same part post-rewrite. Anything else with no `type` is still rejected.
|
||||
if (
|
||||
block.type === 'input_image' ||
|
||||
block.type === 'video_url' ||
|
||||
(block.type === undefined &&
|
||||
(block.image_url !== undefined || block.video_url !== undefined))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof block.type !== 'string' ||
|
||||
!ALLOWED_INPUT_BLOCK_TYPES.has(block.type)
|
||||
) {
|
||||
throw badRequest(`${path}: unsupported block type`);
|
||||
}
|
||||
if (block.type === 'text' && block.text === '') {
|
||||
throw badRequest('text content blocks must be non-empty');
|
||||
}
|
||||
if (FILE_SOURCED_BLOCK_TYPES.has(block.type)) {
|
||||
const source = block.source;
|
||||
if (isPlainObject(source) && source.type === 'file') {
|
||||
throw badRequest(
|
||||
`${path}: file-source ${block.type} blocks are not supported`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (block.type === 'tool_result' && Array.isArray(block.content)) {
|
||||
block.content.forEach((part, i) =>
|
||||
validateContentBlock(part, `${path}.content.${i}`),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shape-level validation only — tool_result ordering, `is_error`, caching and
|
||||
* every other block field pass through unchanged (no hoisting). Non-object
|
||||
* top-level entries are dropped defensively rather than rejected; a malformed
|
||||
* content block within a surviving message is a 400.
|
||||
*/
|
||||
export const validateMessages = (
|
||||
messages: readonly unknown[],
|
||||
): Record<string, unknown>[] => {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
if (!isPlainObject(m)) return;
|
||||
if (Array.isArray(m.content)) {
|
||||
m.content.forEach((part, j) =>
|
||||
validateContentBlock(part, `messages.${i}.content.${j}`),
|
||||
);
|
||||
}
|
||||
out.push(m);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const validateTools = (tools: unknown): unknown[] => {
|
||||
if (!Array.isArray(tools))
|
||||
throw badRequest('tools: Input should be a valid list');
|
||||
return tools;
|
||||
};
|
||||
|
||||
// -- tool_choice / parallel_tool_calls ----------------------------------
|
||||
|
||||
const toolChoiceFromAnthropic = (
|
||||
tc: unknown,
|
||||
): { tool_choice?: ToolChoice; parallel_tool_calls: boolean } => {
|
||||
let toolChoice: ToolChoice | undefined;
|
||||
let disableParallel = false;
|
||||
if (isPlainObject(tc)) {
|
||||
if (tc.type === 'auto' || tc.type === 'any' || tc.type === 'none') {
|
||||
toolChoice = { type: tc.type };
|
||||
} else if (tc.type === 'tool' && typeof tc.name === 'string') {
|
||||
toolChoice = { type: 'tool', name: tc.name };
|
||||
}
|
||||
disableParallel = tc.disable_parallel_tool_use === true;
|
||||
}
|
||||
return {
|
||||
...(toolChoice ? { tool_choice: toolChoice } : {}),
|
||||
parallel_tool_calls: !disableParallel,
|
||||
};
|
||||
};
|
||||
|
||||
// -- thinking -----------------------------------------------------------
|
||||
|
||||
const THINKING_TYPES = new Set([
|
||||
'adaptive',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'between_tools',
|
||||
]);
|
||||
|
||||
const thinkingFromAnthropic = (t: unknown): ThinkingConfig | null => {
|
||||
if (
|
||||
!isPlainObject(t) ||
|
||||
typeof t.type !== 'string' ||
|
||||
!THINKING_TYPES.has(t.type)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const blockBinding = isPlainObject(t.block_binding)
|
||||
? t.block_binding
|
||||
: undefined;
|
||||
const prefixMismatchBehavior = blockBinding?.prefix_mismatch_behavior;
|
||||
return {
|
||||
type: t.type as ThinkingConfig['type'],
|
||||
...(t.type === 'enabled' && typeof t.budget_tokens === 'number'
|
||||
? { budgetTokens: t.budget_tokens }
|
||||
: {}),
|
||||
...(typeof t.display === 'string'
|
||||
? { display: t.display as ThinkingConfig['display'] }
|
||||
: {}),
|
||||
...(prefixMismatchBehavior === 'error' ||
|
||||
prefixMismatchBehavior === 'drop_block'
|
||||
? { blockBinding: { prefixMismatchBehavior } }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
// -- output_config -------------------------------------------------------
|
||||
|
||||
interface OutputConfigFields {
|
||||
reasoning_effort?: ReasoningEffort;
|
||||
outputFormat?: OutputFormat;
|
||||
taskBudget?: { total: number; remaining?: number };
|
||||
}
|
||||
|
||||
const outputConfigFromAnthropic = (oc: unknown): OutputConfigFields => {
|
||||
if (!isPlainObject(oc)) return {};
|
||||
const out: OutputConfigFields = {};
|
||||
if (typeof oc.effort === 'string') {
|
||||
out.reasoning_effort = oc.effort as ReasoningEffort;
|
||||
}
|
||||
const format = oc.format;
|
||||
if (
|
||||
isPlainObject(format) &&
|
||||
format.type === 'json_schema' &&
|
||||
isPlainObject(format.schema)
|
||||
) {
|
||||
out.outputFormat = {
|
||||
type: 'json_schema',
|
||||
schema: format.schema,
|
||||
...(typeof format.name === 'string' ? { name: format.name } : {}),
|
||||
...(typeof format.strict === 'boolean'
|
||||
? { strict: format.strict }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const taskBudget = oc.task_budget;
|
||||
if (isPlainObject(taskBudget) && typeof taskBudget.total === 'number') {
|
||||
out.taskBudget = {
|
||||
total: taskBudget.total,
|
||||
...(typeof taskBudget.remaining === 'number'
|
||||
? { remaining: taskBudget.remaining }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// -- compaction ---------------------------------------------------------------
|
||||
|
||||
const neutralCompaction = (
|
||||
c: unknown,
|
||||
): { compaction?: boolean | { trigger_tokens?: number } } => {
|
||||
if (c === undefined) return {};
|
||||
if (typeof c === 'boolean') return { compaction: c };
|
||||
if (
|
||||
isPlainObject(c) &&
|
||||
(c.trigger_tokens === undefined || typeof c.trigger_tokens === 'number')
|
||||
) {
|
||||
return {
|
||||
compaction:
|
||||
c.trigger_tokens !== undefined
|
||||
? { trigger_tokens: c.trigger_tokens as number }
|
||||
: true,
|
||||
};
|
||||
}
|
||||
throw badRequest('compaction: must be a boolean or { trigger_tokens }');
|
||||
};
|
||||
|
||||
// -- request parser (§2.R) ----------------------------------------------------
|
||||
|
||||
export interface ParseAnthropicRequestOptions {
|
||||
/**
|
||||
* `/v1/messages/count_tokens`: never streams, drops fields Anthropic's own
|
||||
* endpoint rejects.
|
||||
*/
|
||||
countTokens?: boolean;
|
||||
}
|
||||
|
||||
export const parseAnthropicRequest = (
|
||||
body: Record<string, unknown>,
|
||||
headers: IncomingHttpHeaders,
|
||||
opts: ParseAnthropicRequestOptions = {},
|
||||
): ICompleteArguments => {
|
||||
if (!Array.isArray(body.messages))
|
||||
throw badRequest('messages: Field required');
|
||||
// `mcp_servers` is accepted but never forwarded: the result below picks
|
||||
// named fields rather than spreading `body`, so it's silently dropped.
|
||||
const betas = parseBetaHeader(headers['anthropic-beta']);
|
||||
rejectThreads(body, betas);
|
||||
|
||||
const messages = [
|
||||
...systemToMessages(body.system),
|
||||
...validateMessages(body.messages),
|
||||
];
|
||||
|
||||
return {
|
||||
messages,
|
||||
model: toStringOrEmpty(body.model),
|
||||
stream: !opts.countTokens && !!body.stream,
|
||||
normalize: false,
|
||||
streamToolInput: true,
|
||||
...(finiteMaxTokens(body.max_tokens) ?? {}),
|
||||
temperature:
|
||||
body.temperature === undefined ? 1 : Number(body.temperature),
|
||||
...num('top_p', body.top_p),
|
||||
...num('topK', body.top_k),
|
||||
...(Array.isArray(body.stop_sequences)
|
||||
? { stopSequences: body.stop_sequences.map(String) }
|
||||
: {}),
|
||||
...(Array.isArray(body.tools)
|
||||
? { tools: validateTools(body.tools) }
|
||||
: {}),
|
||||
...toolChoiceFromAnthropic(body.tool_choice),
|
||||
thinking:
|
||||
body.thinking === undefined
|
||||
? null
|
||||
: thinkingFromAnthropic(body.thinking),
|
||||
...outputConfigFromAnthropic(body.output_config),
|
||||
...(body.context_management !== undefined
|
||||
? { context_management: body.context_management }
|
||||
: {}),
|
||||
...neutralCompaction(body.compaction),
|
||||
...(isPlainObject(body.cache_control)
|
||||
? { cacheControl: sanitizeCacheControl(body.cache_control) }
|
||||
: {}),
|
||||
...(body.speed === 'fast' ? { speed: 'fast' as const } : {}),
|
||||
...(Array.isArray(body.safeguards)
|
||||
? { safeguards: body.safeguards as SafeguardRequest[] }
|
||||
: {}),
|
||||
...(betas.length ? { anthropicBetas: betas } : {}),
|
||||
provider: toStringOrEmpty(body.provider) || 'claude',
|
||||
};
|
||||
// Silently ignored: metadata, service_tier, inference_geo, container,
|
||||
// fallbacks, diagnostics.
|
||||
};
|
||||
|
||||
// -- usage ---------------------------------------------------------------
|
||||
|
||||
// Every count is always present: Claude Code only trusts an iteration that
|
||||
// carries all four token fields.
|
||||
const toAnthropicIteration = (
|
||||
it: NonNullable<UsageDetails['iterations']>[number],
|
||||
): Record<string, unknown> => ({
|
||||
type: it.type,
|
||||
...(it.model ? { model: it.model } : {}),
|
||||
input_tokens: it.inputTokens,
|
||||
output_tokens: it.outputTokens,
|
||||
cache_read_input_tokens: it.cacheReadTokens ?? 0,
|
||||
cache_creation_input_tokens:
|
||||
(it.cacheWrite5mTokens ?? 0) + (it.cacheWrite1hTokens ?? 0),
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: it.cacheWrite5mTokens ?? 0,
|
||||
ephemeral_1h_input_tokens: it.cacheWrite1hTokens ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const anthropicUsage = (d: UsageDetails): Record<string, unknown> => ({
|
||||
input_tokens: d.inputTokens,
|
||||
cache_creation_input_tokens:
|
||||
(d.cacheWrite5mTokens ?? 0) + (d.cacheWrite1hTokens ?? 0),
|
||||
cache_read_input_tokens: d.cacheReadTokens ?? 0,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: d.cacheWrite5mTokens ?? 0,
|
||||
ephemeral_1h_input_tokens: d.cacheWrite1hTokens ?? 0,
|
||||
},
|
||||
output_tokens: d.outputTokens,
|
||||
...(d.reasoningTokens !== undefined
|
||||
? { output_tokens_details: { thinking_tokens: d.reasoningTokens } }
|
||||
: {}),
|
||||
...(d.webSearchRequests || d.webFetchRequests
|
||||
? {
|
||||
server_tool_use: {
|
||||
web_search_requests: d.webSearchRequests ?? 0,
|
||||
web_fetch_requests: d.webFetchRequests ?? 0,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(d.speed ? { speed: d.speed } : {}),
|
||||
...(d.serviceTier ? { service_tier: d.serviceTier } : {}),
|
||||
...(d.iterations
|
||||
? { iterations: d.iterations.map(toAnthropicIteration) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A crude `UsageDetails` from a raw numeric usage dict — only used when a
|
||||
* result carries no `usageDetails` of its own (test fixtures, hand-rolled
|
||||
* driver stubs). Production results always have `usageDetails` set by the
|
||||
* driver before the controller sees them.
|
||||
*/
|
||||
const usageDetailsFromRawUsage = (
|
||||
usage: Record<string, unknown> | undefined,
|
||||
): UsageDetails => {
|
||||
const u = usage ?? {};
|
||||
const cacheRead = Number(u.cache_read_input_tokens ?? u.cached_tokens ?? 0);
|
||||
return {
|
||||
inputTokens: Number(u.input_tokens ?? u.prompt_tokens ?? 0),
|
||||
outputTokens: Number(u.output_tokens ?? u.completion_tokens ?? 0),
|
||||
...(cacheRead ? { cacheReadTokens: cacheRead } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
// -- non-stream response --------------------------------------------------
|
||||
|
||||
/** Server-executed tool result blocks — forwarded as-is, never field-filtered. */
|
||||
const SERVER_RESULT_BLOCKS = new Set([
|
||||
'web_search_tool_result',
|
||||
'web_fetch_tool_result',
|
||||
'advisor_tool_result',
|
||||
'tool_search_tool_result',
|
||||
]);
|
||||
|
||||
const OUTPUT_ALLOWED_NATIVE_BLOCKS = new Set([
|
||||
'text',
|
||||
'thinking',
|
||||
'redacted_thinking',
|
||||
'tool_use',
|
||||
'server_tool_use',
|
||||
'compaction',
|
||||
...SERVER_RESULT_BLOCKS,
|
||||
]);
|
||||
|
||||
const warnedUnknownOutputBlocks = new Set<string>();
|
||||
const warnUnknownOutputBlockOnce = (type: string): void => {
|
||||
if (warnedUnknownOutputBlocks.has(type)) return;
|
||||
warnedUnknownOutputBlocks.add(type);
|
||||
console.warn(
|
||||
`[ai-chat] anthropic route: unknown output block type '${type}', dropping`,
|
||||
);
|
||||
};
|
||||
|
||||
const safeParseJson = (s: string): unknown => {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const nativeMessageContent = (content: unknown): Record<string, unknown>[] => {
|
||||
if (!Array.isArray(content)) return [];
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const block of content) {
|
||||
if (!isPlainObject(block) || typeof block.type !== 'string') continue;
|
||||
const type = block.type;
|
||||
if (!OUTPUT_ALLOWED_NATIVE_BLOCKS.has(type)) {
|
||||
warnUnknownOutputBlockOnce(type);
|
||||
continue;
|
||||
}
|
||||
if (type === 'text' && block.text === '') continue; // never emit an empty text block
|
||||
if (type === 'compaction') {
|
||||
out.push({
|
||||
type: 'compaction',
|
||||
...(block.id !== undefined ? { id: block.id } : {}),
|
||||
encrypted_content:
|
||||
block.content ?? block.encrypted_content ?? '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (type === 'tool_use' && typeof block.input === 'string') {
|
||||
out.push({ ...block, input: safeParseJson(block.input) });
|
||||
continue;
|
||||
}
|
||||
out.push(block);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const extractTextContent = (content: unknown): string => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(p): p is Record<string, unknown> =>
|
||||
isPlainObject(p) && p.type === 'text',
|
||||
)
|
||||
.map((p) => String(p.text ?? ''))
|
||||
.join('');
|
||||
};
|
||||
|
||||
const openAiShapedContent = (
|
||||
result: Record<string, unknown>,
|
||||
message: Record<string, unknown>,
|
||||
): Record<string, unknown>[] => {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
if (Array.isArray(message.reasoning_details)) {
|
||||
for (const detail of message.reasoning_details) {
|
||||
if (!isPlainObject(detail)) continue;
|
||||
if (detail.type === 'thinking') {
|
||||
out.push({
|
||||
type: 'thinking',
|
||||
thinking: detail.thinking ?? '',
|
||||
signature: detail.signature ?? '',
|
||||
});
|
||||
} else if (detail.type === 'redacted_thinking') {
|
||||
out.push({ type: 'redacted_thinking', data: detail.data });
|
||||
}
|
||||
}
|
||||
}
|
||||
const text = extractTextContent(message.content);
|
||||
if (text) out.push({ type: 'text', text });
|
||||
if (Array.isArray(message.tool_calls)) {
|
||||
for (const tc of message.tool_calls) {
|
||||
if (!isPlainObject(tc)) continue;
|
||||
const fn = isPlainObject(tc.function) ? tc.function : {};
|
||||
out.push({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
name: fn.name ?? '',
|
||||
input:
|
||||
typeof fn.arguments === 'string'
|
||||
? safeParseJson(fn.arguments)
|
||||
: (fn.arguments ?? {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const compaction = result.compaction;
|
||||
if (isPlainObject(compaction)) {
|
||||
out.push({
|
||||
type: 'compaction',
|
||||
...(compaction.id !== undefined ? { id: compaction.id } : {}),
|
||||
encrypted_content:
|
||||
compaction.encrypted_content ?? compaction.content ?? '',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-stream `IChatMessageResult` → the Anthropic message envelope.
|
||||
*
|
||||
* A native (Claude) result keeps its blocks verbatim, in order, filtered by the
|
||||
* output allowlist. A result shaped by an OpenAI-family fallback is rebuilt in
|
||||
* a fixed order: reasoning artifacts, then text, then tool calls, then a
|
||||
* round-tripped compaction block. Content is `[]`, never a single empty text
|
||||
* block, when the assistant produced nothing — a replayed empty text block 400s
|
||||
* on Anthropic's own API.
|
||||
*/
|
||||
export const toAnthropicMessage = (
|
||||
result: IChatMessageResult,
|
||||
ids: { id: string; model: string },
|
||||
): Record<string, unknown> => {
|
||||
const message = (result.message ?? {}) as Record<string, unknown>;
|
||||
const resultRecord = result as unknown as Record<string, unknown>;
|
||||
const content = needsOpenAICoercion(message)
|
||||
? nativeMessageContent(message.content)
|
||||
: openAiShapedContent(resultRecord, message);
|
||||
|
||||
const sawToolUse = content.some((b) => b.type === 'tool_use');
|
||||
const stopReason =
|
||||
result.stopReason ??
|
||||
fromFinishReason(result.finish_reason) ??
|
||||
(sawToolUse ? 'tool_use' : 'end_turn');
|
||||
|
||||
const usageDetails =
|
||||
result.usageDetails ??
|
||||
usageDetailsFromRawUsage(
|
||||
result.usage as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
id: ids.id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content,
|
||||
model: ids.model,
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: result.stopSequence ?? null,
|
||||
...(result.stopDetails ? { stop_details: result.stopDetails } : {}),
|
||||
usage: anthropicUsage(usageDetails),
|
||||
...(result.safeguardResults
|
||||
? { safeguard_results: result.safeguardResults }
|
||||
: {}),
|
||||
...(result.contextManagement
|
||||
? { context_management: result.contextManagement }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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 { PassThrough } from 'node:stream';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { pipeNdjsonStream } from './ndjsonStream.js';
|
||||
|
||||
const capture = () => {
|
||||
const stream = new PassThrough();
|
||||
const events: Record<string, unknown>[] = [];
|
||||
const onEnd = vi.fn();
|
||||
const onError = vi.fn();
|
||||
pipeNdjsonStream(stream, (event) => events.push(event), {
|
||||
onEnd,
|
||||
onError,
|
||||
});
|
||||
return { stream, events, onEnd, onError };
|
||||
};
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
describe('chat NDJSON termination', () => {
|
||||
it('accepts a terminal event without a final newline', async () => {
|
||||
const h = capture();
|
||||
h.stream.end('{"type":"usage"}');
|
||||
await settle();
|
||||
expect(h.events).toEqual([{ type: 'usage' }]);
|
||||
expect(h.onEnd).toHaveBeenCalledOnce();
|
||||
expect(h.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
it('decodes split UTF-8 characters without corruption', async () => {
|
||||
const h = capture();
|
||||
const bytes = Buffer.from('{"type":"text","text":"😀"}\n');
|
||||
const split = bytes.indexOf(Buffer.from('😀')) + 2;
|
||||
h.stream.write(bytes.subarray(0, split));
|
||||
h.stream.write(bytes.subarray(split));
|
||||
h.stream.end('{"type":"usage"}\n');
|
||||
await settle();
|
||||
expect(h.events[0].text).toBe('😀');
|
||||
expect(h.onEnd).toHaveBeenCalledOnce();
|
||||
});
|
||||
it.each(['{bad json}\n', 'null\n', '{"type":"text","text":"partial"}\n'])(
|
||||
'fails an invalid or incomplete stream: %s',
|
||||
async (body) => {
|
||||
const h = capture();
|
||||
h.stream.end(body);
|
||||
await settle();
|
||||
expect(h.onError).toHaveBeenCalledOnce();
|
||||
expect(h.onEnd).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it('reports a close without an end event', async () => {
|
||||
const h = capture();
|
||||
h.stream.destroy();
|
||||
await settle();
|
||||
expect(h.onError).toHaveBeenCalledOnce();
|
||||
expect(h.onEnd).not.toHaveBeenCalled();
|
||||
});
|
||||
it('does not report successful completion after an error event', async () => {
|
||||
const h = capture();
|
||||
h.stream.emit('error', new Error('broken'));
|
||||
h.stream.end('{"type":"usage"}\n');
|
||||
await settle();
|
||||
expect(h.onError).toHaveBeenCalledOnce();
|
||||
expect(h.onEnd).not.toHaveBeenCalled();
|
||||
});
|
||||
it('ignores events after an in-band error', async () => {
|
||||
const h = capture();
|
||||
h.stream.end(
|
||||
'{"type":"error","message":"failed"}\n{"type":"text","text":"late"}\n',
|
||||
);
|
||||
await settle();
|
||||
expect(h.events).toHaveLength(1);
|
||||
expect(h.onEnd).not.toHaveBeenCalled();
|
||||
expect(h.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 { StringDecoder } from 'node:string_decoder';
|
||||
|
||||
interface NdjsonPipeOptions {
|
||||
onEnd?: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
/** A usage or error event terminates the chat driver's NDJSON contract. */
|
||||
export const pipeNdjsonStream = (
|
||||
stream: NodeJS.ReadableStream,
|
||||
onEvent: (event: Record<string, unknown>) => void,
|
||||
opts: NdjsonPipeOptions,
|
||||
): void => {
|
||||
const decoder = new StringDecoder('utf8');
|
||||
let buffer = '';
|
||||
let settled = false;
|
||||
let terminal = false;
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
opts.onError(error);
|
||||
};
|
||||
const consumeLine = (line: string) => {
|
||||
if (settled || terminal || !line.trim()) return;
|
||||
let event: Record<string, unknown>;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
||||
throw new Error();
|
||||
}
|
||||
} catch {
|
||||
fail(new Error('Invalid chat stream event'));
|
||||
return;
|
||||
}
|
||||
onEvent(event);
|
||||
if (event.type === 'error') settled = true;
|
||||
if (event.type === 'usage') terminal = true;
|
||||
};
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
if (settled) return;
|
||||
buffer += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
|
||||
consumeLine(buffer.slice(0, newlineIndex));
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (settled) return;
|
||||
consumeLine(buffer + decoder.end());
|
||||
buffer = '';
|
||||
if (settled) return;
|
||||
if (!terminal) {
|
||||
fail(new Error('Stream ended before completion'));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
opts.onEnd?.();
|
||||
});
|
||||
stream.on('error', fail);
|
||||
stream.on('close', () =>
|
||||
fail(new Error('Stream closed before completion')),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AnthropicSseWriter, startSse } from './sse.js';
|
||||
|
||||
const makeRes = () => {
|
||||
const written: string[] = [];
|
||||
let closeHandler: (() => void) | undefined;
|
||||
const ended = { value: false };
|
||||
const res = {
|
||||
write: vi.fn((chunk: string) => {
|
||||
written.push(chunk);
|
||||
return true;
|
||||
}),
|
||||
end: vi.fn(() => {
|
||||
ended.value = true;
|
||||
}),
|
||||
once: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'close') closeHandler = handler;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return {
|
||||
res: res as unknown as Response,
|
||||
written,
|
||||
ended,
|
||||
triggerClose: () => closeHandler?.(),
|
||||
};
|
||||
};
|
||||
|
||||
// -- startSse ----------------------------------------------------------
|
||||
|
||||
describe('startSse', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('writes event: <type>\\ndata: <json>\\n\\n frames', () => {
|
||||
const { res, written } = makeRes();
|
||||
const sse = startSse(res);
|
||||
sse.write('message_start', { type: 'message_start' });
|
||||
expect(written[0]).toBe(
|
||||
'event: message_start\ndata: {"type":"message_start"}\n\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a ping frame after 15s of silence, not before', () => {
|
||||
const { res, written } = makeRes();
|
||||
startSse(res, { ping: true });
|
||||
vi.advanceTimersByTime(14_000);
|
||||
expect(written.some((w) => w.includes('event: ping'))).toBe(false);
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(written.some((w) => w.includes('event: ping'))).toBe(true);
|
||||
expect(written.at(-1)).toBe('event: ping\ndata: {"type": "ping"}\n\n');
|
||||
});
|
||||
|
||||
it('resets the idle clock on every write — no ping while active', () => {
|
||||
const { res, written } = makeRes();
|
||||
const sse = startSse(res, { ping: true });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
sse.write('content_block_delta', { type: 'content_block_delta' });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(written.some((w) => w.includes('event: ping'))).toBe(false);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(written.some((w) => w.includes('event: ping'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not ping without the option', () => {
|
||||
const { res, written } = makeRes();
|
||||
startSse(res);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(written.some((w) => w.includes('event: ping'))).toBe(false);
|
||||
});
|
||||
|
||||
it('end() stops further writes and clears the ping timer', () => {
|
||||
const { res, written, ended } = makeRes();
|
||||
const sse = startSse(res, { ping: true });
|
||||
sse.end();
|
||||
expect(ended.value).toBe(true);
|
||||
expect(sse.ended).toBe(true);
|
||||
const before = written.length;
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(written.length).toBe(before);
|
||||
sse.write('x', {});
|
||||
expect(written.length).toBe(before);
|
||||
});
|
||||
|
||||
it('marks ended and clears the timer when the client disconnects', () => {
|
||||
const { res, triggerClose } = makeRes();
|
||||
const sse = startSse(res, { ping: true });
|
||||
triggerClose();
|
||||
expect(sse.ended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -- AnthropicSseWriter ------------------------------------------------
|
||||
|
||||
const events = (written: string[]): Array<{ event: string; data: unknown }> =>
|
||||
written.map((frame) => {
|
||||
const [eventLine, dataLine] = frame.split('\n');
|
||||
return {
|
||||
event: eventLine!.replace('event: ', ''),
|
||||
data: JSON.parse(dataLine!.replace('data: ', '')),
|
||||
};
|
||||
});
|
||||
|
||||
const newWriter = () => {
|
||||
const { res, written } = makeRes();
|
||||
const sse = startSse(res);
|
||||
const writer = new AnthropicSseWriter(sse, {
|
||||
id: 'msg_1',
|
||||
model: 'claude-test',
|
||||
requestId: 'req_1',
|
||||
});
|
||||
return { writer, written };
|
||||
};
|
||||
|
||||
describe('AnthropicSseWriter', () => {
|
||||
it('start() writes message_start with zero usage and null stop fields', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
const [first] = events(written);
|
||||
expect(first!.event).toBe('message_start');
|
||||
expect(first!.data).toMatchObject({
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: 'claude-test',
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('text: content_block_start → delta(text_delta) → stop, then message_delta/message_stop on usage', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'text', text: 'hi' });
|
||||
writer.onChunk({
|
||||
type: 'usage',
|
||||
usageDetails: { inputTokens: 1, outputTokens: 1 },
|
||||
});
|
||||
const seq = events(written).map((e) => e.event);
|
||||
expect(seq).toEqual([
|
||||
'message_start',
|
||||
'content_block_start',
|
||||
'content_block_delta',
|
||||
'content_block_stop',
|
||||
'message_delta',
|
||||
'message_stop',
|
||||
]);
|
||||
const delta = events(written)[2]!.data as {
|
||||
delta: { type: string; text: string };
|
||||
};
|
||||
expect(delta.delta).toEqual({ type: 'text_delta', text: 'hi' });
|
||||
});
|
||||
|
||||
it('thinking: reasoning_start(anthropic) opens a thinking block; signature arrives via reasoning_detail', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'reasoning_start', format: 'anthropic' });
|
||||
writer.onChunk({ type: 'reasoning', reasoning: 'because' });
|
||||
writer.onChunk({
|
||||
type: 'reasoning_detail',
|
||||
detail: { type: 'thinking', thinking: 'because', signature: 'sig' },
|
||||
});
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
|
||||
const evs = events(written);
|
||||
const start = evs.find((e) => e.event === 'content_block_start');
|
||||
expect((start!.data as { content_block: { type: string } }).content_block)
|
||||
.toMatchObject({ type: 'thinking' });
|
||||
const deltas = evs.filter((e) => e.event === 'content_block_delta');
|
||||
expect(deltas[0]!.data).toMatchObject({
|
||||
delta: { type: 'thinking_delta', thinking: 'because' },
|
||||
});
|
||||
expect(deltas[1]!.data).toMatchObject({
|
||||
delta: { type: 'signature_delta', signature: 'sig' },
|
||||
});
|
||||
});
|
||||
|
||||
it('bare reasoning deltas with no reasoning_start (unsigned) never open an Anthropic thinking block', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'reasoning', reasoning: 'stray' });
|
||||
writer.onChunk({ type: 'text', text: 'hi' });
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const kinds = events(written)
|
||||
.filter((e) => e.event === 'content_block_start')
|
||||
.map((e) => (e.data as { content_block: { type: string } }).content_block.type);
|
||||
expect(kinds).toEqual(['text']);
|
||||
});
|
||||
|
||||
it('redacted_thinking opens and closes immediately with no preceding reasoning_start', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({
|
||||
type: 'reasoning_detail',
|
||||
detail: { type: 'redacted_thinking', data: 'opaque' },
|
||||
});
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const evs = events(written);
|
||||
const start = evs.find((e) => e.event === 'content_block_start')!;
|
||||
expect(start.data).toMatchObject({
|
||||
content_block: { type: 'redacted_thinking', data: 'opaque' },
|
||||
});
|
||||
const stopIndex = evs.findIndex((e) => e.event === 'content_block_stop');
|
||||
expect(stopIndex).toBeGreaterThan(evs.indexOf(start));
|
||||
});
|
||||
|
||||
it('tool_use incremental: tool_use_start → input_json_delta× → stop (no extra delta on the closing tool_use)', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'tool_use_start', id: 'tu_1', name: 'Bash' });
|
||||
writer.onChunk({ type: 'tool_input_delta', id: 'tu_1', partialJson: '{"c' });
|
||||
writer.onChunk({ type: 'tool_input_delta', id: 'tu_1', partialJson: 'md":1}' });
|
||||
writer.onChunk({ type: 'tool_use', id: 'tu_1', name: 'Bash', input: { cmd: 1 } });
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
|
||||
const evs = events(written);
|
||||
const deltas = evs.filter((e) => e.event === 'content_block_delta');
|
||||
expect(deltas).toHaveLength(2);
|
||||
expect(deltas.map((d) => (d.data as { delta: { partial_json: string } }).delta.partial_json)).toEqual([
|
||||
'{"c',
|
||||
'md":1}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parallel calls whose tool_use chunks land after the next start are not emitted twice', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'tool_use_start', id: 'tu_a', name: 'Read' });
|
||||
writer.onChunk({ type: 'tool_input_delta', id: 'tu_a', partialJson: '{}' });
|
||||
writer.onChunk({ type: 'tool_use_start', id: 'tu_b', name: 'Read' });
|
||||
writer.onChunk({ type: 'tool_input_delta', id: 'tu_b', partialJson: '{}' });
|
||||
writer.onChunk({ type: 'tool_use', id: 'tu_a', name: 'Read', input: {} });
|
||||
writer.onChunk({ type: 'tool_use', id: 'tu_b', name: 'Read', input: {} });
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
|
||||
const starts = events(written).filter(
|
||||
(e) => e.event === 'content_block_start',
|
||||
);
|
||||
expect(
|
||||
starts.map((e) => (e.data as { content_block: { id: string } }).content_block.id),
|
||||
).toEqual(['tu_a', 'tu_b']);
|
||||
const stops = events(written).filter((e) => e.event === 'content_block_stop');
|
||||
expect(stops.map((e) => (e.data as { index: number }).index)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('tool_use single-shot (no prior start): one content_block_start/delta/stop', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'tool_use', id: 'tu_2', name: 'Grep', input: { q: 'x' } });
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const evs = events(written);
|
||||
expect(evs.filter((e) => e.event === 'content_block_start')).toHaveLength(1);
|
||||
const delta = evs.find((e) => e.event === 'content_block_delta')!;
|
||||
expect(delta.data).toMatchObject({
|
||||
delta: { type: 'input_json_delta', partial_json: '{"q":"x"}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('server_tool_use streams one input_json_delta then stops', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({
|
||||
type: 'server_tool',
|
||||
block: {
|
||||
type: 'server_tool_use',
|
||||
id: 'srvtoolu_1',
|
||||
name: 'web_search',
|
||||
input: { query: 'puter' },
|
||||
},
|
||||
});
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const evs = events(written);
|
||||
const start = evs.find((e) => e.event === 'content_block_start')!;
|
||||
expect(start.data).toMatchObject({
|
||||
content_block: { type: 'server_tool_use', id: 'srvtoolu_1', name: 'web_search' },
|
||||
});
|
||||
const delta = evs.find((e) => e.event === 'content_block_delta')!;
|
||||
expect(delta.data).toMatchObject({
|
||||
delta: { partial_json: '{"query":"puter"}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a server-tool result block arrives whole — no deltas', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({
|
||||
type: 'server_tool',
|
||||
block: {
|
||||
type: 'web_search_tool_result',
|
||||
tool_use_id: 'srvtoolu_1',
|
||||
content: [{ type: 'web_search_result', title: 'x', url: 'y' }],
|
||||
},
|
||||
});
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const evs = events(written);
|
||||
expect(evs.filter((e) => e.event === 'content_block_delta')).toHaveLength(0);
|
||||
const start = evs.find((e) => e.event === 'content_block_start')!;
|
||||
expect(start.data).toMatchObject({
|
||||
content_block: { type: 'web_search_tool_result' },
|
||||
});
|
||||
});
|
||||
|
||||
it('message_delta carries real stop_reason/stop_sequence/usage, and stop_details only when non-null', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({
|
||||
type: 'usage',
|
||||
stopReason: 'max_tokens',
|
||||
stopSequence: 'END',
|
||||
stopDetails: { reason: 'budget' },
|
||||
usageDetails: { inputTokens: 5, outputTokens: 7, cacheReadTokens: 2 },
|
||||
});
|
||||
const delta = events(written).find((e) => e.event === 'message_delta')!;
|
||||
expect(delta.data).toMatchObject({
|
||||
delta: {
|
||||
stop_reason: 'max_tokens',
|
||||
stop_sequence: 'END',
|
||||
stop_details: { reason: 'budget' },
|
||||
},
|
||||
usage: { input_tokens: 5, output_tokens: 7, cache_read_input_tokens: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits stop_details when null/absent', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'usage', stopReason: 'end_turn', usageDetails: {} });
|
||||
const delta = events(written).find((e) => e.event === 'message_delta')!;
|
||||
expect((delta.data as { delta: Record<string, unknown> }).delta).not.toHaveProperty(
|
||||
'stop_details',
|
||||
);
|
||||
});
|
||||
|
||||
it('safeguard_results in message_delta.delta ONLY when the upstream sent them', () => {
|
||||
const withSafeguards = newWriter();
|
||||
withSafeguards.writer.start();
|
||||
withSafeguards.writer.onChunk({
|
||||
type: 'safeguard_results',
|
||||
results: [{ type: 'dangerous_tool_use', status: { tool_uses: {} } }],
|
||||
});
|
||||
withSafeguards.writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const deltaWith = events(withSafeguards.written).find(
|
||||
(e) => e.event === 'message_delta',
|
||||
)!;
|
||||
expect((deltaWith.data as { delta: Record<string, unknown> }).delta).toHaveProperty(
|
||||
'safeguard_results',
|
||||
);
|
||||
|
||||
const withoutSafeguards = newWriter();
|
||||
withoutSafeguards.writer.start();
|
||||
withoutSafeguards.writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const deltaWithout = events(withoutSafeguards.written).find(
|
||||
(e) => e.event === 'message_delta',
|
||||
)!;
|
||||
expect((deltaWithout.data as { delta: Record<string, unknown> }).delta).not.toHaveProperty(
|
||||
'safeguard_results',
|
||||
);
|
||||
});
|
||||
|
||||
it('context_management on message_delta only when present', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({
|
||||
type: 'usage',
|
||||
usageDetails: {},
|
||||
contextManagement: { applied_edits: [{ type: 'compact_20260112' }] },
|
||||
});
|
||||
const delta = events(written).find((e) => e.event === 'message_delta')!;
|
||||
expect(delta.data).toMatchObject({
|
||||
context_management: { applied_edits: [{ type: 'compact_20260112' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('stop_reason defaults to tool_use when a tool_use was seen and the upstream gave none', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'tool_use', id: 't1', name: 'a', input: {} });
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const delta = events(written).find((e) => e.event === 'message_delta')!;
|
||||
expect((delta.data as { delta: { stop_reason: string } }).delta.stop_reason).toBe(
|
||||
'tool_use',
|
||||
);
|
||||
});
|
||||
|
||||
it('stop_reason defaults to end_turn with no tool_use and no upstream reason', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'text', text: 'hi' });
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const delta = events(written).find((e) => e.event === 'message_delta')!;
|
||||
expect((delta.data as { delta: { stop_reason: string } }).delta.stop_reason).toBe(
|
||||
'end_turn',
|
||||
);
|
||||
});
|
||||
|
||||
it('promotes an explicit stopReason of end_turn to tool_use when a tool_use was seen', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'tool_use', id: 't1', name: 'a', input: {} });
|
||||
writer.onChunk({
|
||||
type: 'usage',
|
||||
stopReason: 'end_turn',
|
||||
usageDetails: {},
|
||||
});
|
||||
const delta = events(written).find((e) => e.event === 'message_delta')!;
|
||||
expect(
|
||||
(delta.data as { delta: { stop_reason: string } }).delta
|
||||
.stop_reason,
|
||||
).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('an in-band error chunk writes event: error with no message_delta/message_stop, then ends', () => {
|
||||
const { res, written, ended } = makeRes();
|
||||
const sse = startSse(res);
|
||||
const writer = new AnthropicSseWriter(sse, {
|
||||
id: 'msg_1',
|
||||
model: 'claude-test',
|
||||
requestId: 'req_1',
|
||||
});
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'text', text: 'partial' });
|
||||
writer.onChunk({ type: 'error', message: 'upstream overloaded', status: 529 });
|
||||
|
||||
const evs = events(written);
|
||||
expect(evs.map((e) => e.event)).not.toContain('message_delta');
|
||||
expect(evs.map((e) => e.event)).not.toContain('message_stop');
|
||||
const errorEvent = evs.find((e) => e.event === 'error')!;
|
||||
expect(errorEvent.data).toEqual({
|
||||
type: 'error',
|
||||
error: { type: 'overloaded_error', message: 'upstream overloaded' },
|
||||
request_id: 'req_1',
|
||||
});
|
||||
expect(writer.ended).toBe(true);
|
||||
expect(ended.value).toBe(true);
|
||||
});
|
||||
|
||||
it('maps known statuses to Anthropic error types, falling back to api_error', () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[400, 'invalid_request_error'],
|
||||
[401, 'authentication_error'],
|
||||
[403, 'permission_error'],
|
||||
[404, 'not_found_error'],
|
||||
[413, 'request_too_large'],
|
||||
[429, 'rate_limit_error'],
|
||||
[504, 'timeout_error'],
|
||||
[529, 'overloaded_error'],
|
||||
[502, 'api_error'],
|
||||
];
|
||||
for (const [status, type] of cases) {
|
||||
const { writer, written } = newWriter();
|
||||
writer.onChunk({ type: 'error', message: 'x', status });
|
||||
const errorEvent = events(written).find((e) => e.event === 'error')!;
|
||||
expect((errorEvent.data as { error: { type: string } }).error.type).toBe(
|
||||
type,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores further chunks once ended', () => {
|
||||
const { writer, written } = newWriter();
|
||||
writer.start();
|
||||
writer.onChunk({ type: 'usage', usageDetails: {} });
|
||||
const before = written.length;
|
||||
writer.onChunk({ type: 'text', text: 'too late' });
|
||||
expect(written.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* SSE transport (`startSse`) plus the Anthropic `/v1/messages` streaming writer
|
||||
* (`AnthropicSseWriter`), which turns the driver's NDJSON chunk protocol
|
||||
* (`drivers/ai-chat/utils/Streaming.js`) into the exact Anthropic event
|
||||
* sequence: `message_start` → `content_block_start/delta/stop`* →
|
||||
* `message_delta` → `message_stop`.
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import type { UsageDetails } from '../../drivers/ai-chat/types.js';
|
||||
import { promoteStopForToolCalls } from '../../drivers/ai-chat/utils/stopReason.js';
|
||||
import { anthropicUsage } from './anthropicWire.js';
|
||||
|
||||
// -- SSE transport ------------------------------------------------------------
|
||||
|
||||
/** Idle time before a keepalive ping is due. */
|
||||
const PING_IDLE_MS = 15_000;
|
||||
/** How often the idle clock is checked. */
|
||||
const PING_CHECK_MS = 5_000;
|
||||
|
||||
export interface Sse {
|
||||
write(eventType: string, data: unknown): void;
|
||||
end(): void;
|
||||
readonly ended: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal `event: <type>\ndata: <json>\n\n` writer over an Express response.
|
||||
* With `ping: true`, a 5 s interval writes an Anthropic keepalive frame
|
||||
* whenever nothing has been written for 15 s — matching Claude Code's own
|
||||
* gateway behavior, so a long reasoning idle doesn't look like a dead
|
||||
* connection to a client that expects one.
|
||||
*/
|
||||
export const startSse = (res: Response, opts: { ping?: boolean } = {}): Sse => {
|
||||
let ended = false;
|
||||
let lastActivity = Date.now();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const write = (eventType: string, data: unknown): void => {
|
||||
if (ended) return;
|
||||
lastActivity = Date.now();
|
||||
res.write(`event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
const end = (): void => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
if (timer) clearInterval(timer);
|
||||
res.end();
|
||||
};
|
||||
|
||||
if (opts.ping) {
|
||||
timer = setInterval(() => {
|
||||
if (ended) return;
|
||||
if (Date.now() - lastActivity >= PING_IDLE_MS) {
|
||||
lastActivity = Date.now();
|
||||
res.write('event: ping\ndata: {"type": "ping"}\n\n');
|
||||
}
|
||||
}, PING_CHECK_MS);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
res.once('close', () => {
|
||||
ended = true;
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
return {
|
||||
write,
|
||||
end,
|
||||
get ended() {
|
||||
return ended;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// -- Anthropic error-type table (mid-stream `event: error`) -------------------
|
||||
|
||||
const STREAM_ERROR_TYPES: Record<number, string> = {
|
||||
400: 'invalid_request_error',
|
||||
401: 'authentication_error',
|
||||
403: 'permission_error',
|
||||
404: 'not_found_error',
|
||||
413: 'request_too_large',
|
||||
429: 'rate_limit_error',
|
||||
504: 'timeout_error',
|
||||
529: 'overloaded_error',
|
||||
};
|
||||
|
||||
const jsonOf = (value: unknown): string =>
|
||||
typeof value === 'string' ? value : JSON.stringify(value ?? {});
|
||||
|
||||
type OpenBlockKind =
|
||||
| 'text'
|
||||
| 'thinking'
|
||||
| 'tool_use'
|
||||
| 'server_tool_use'
|
||||
| 'compaction'
|
||||
| 'other';
|
||||
|
||||
interface OpenBlock {
|
||||
kind: OpenBlockKind;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic `/v1/messages` SSE writer: one instance per streaming request.
|
||||
* Consumes the driver's NDJSON chunks (`onChunk`) and writes the matching
|
||||
* Anthropic event(s); `start()` opens the message, `onChunk({type:'usage'|
|
||||
* 'error', ...})` closes it.
|
||||
*/
|
||||
export class AnthropicSseWriter {
|
||||
#sse: Sse;
|
||||
#id: string;
|
||||
#model: string;
|
||||
#requestId: string;
|
||||
#index = -1;
|
||||
#open: OpenBlock | null = null;
|
||||
#signedReasoning = false;
|
||||
#safeguards: unknown[] | undefined;
|
||||
#sawToolUse = false;
|
||||
/** Tool calls already opened by `tool_use_start`. */
|
||||
#startedToolIds = new Set<unknown>();
|
||||
#ended = false;
|
||||
|
||||
constructor(
|
||||
sse: Sse,
|
||||
ids: { id: string; model: string; requestId: string },
|
||||
) {
|
||||
this.#sse = sse;
|
||||
this.#id = ids.id;
|
||||
this.#model = ids.model;
|
||||
this.#requestId = ids.requestId;
|
||||
}
|
||||
|
||||
get ended(): boolean {
|
||||
return this.#ended;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.#sse.write('message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: this.#id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: this.#model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: anthropicUsage({ inputTokens: 0, outputTokens: 0 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#openBlock(
|
||||
kind: OpenBlockKind,
|
||||
contentBlock: Record<string, unknown>,
|
||||
id?: string,
|
||||
): void {
|
||||
this.#index++;
|
||||
this.#open = { kind, id };
|
||||
this.#sse.write('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: this.#index,
|
||||
content_block: contentBlock,
|
||||
});
|
||||
}
|
||||
|
||||
#delta(delta: Record<string, unknown>): void {
|
||||
this.#sse.write('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: this.#index,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
|
||||
#close(): void {
|
||||
if (!this.#open) return;
|
||||
this.#sse.write('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.#index,
|
||||
});
|
||||
this.#open = null;
|
||||
}
|
||||
|
||||
#ensureText(): void {
|
||||
if (this.#open?.kind === 'text') return;
|
||||
this.#close();
|
||||
this.#openBlock('text', { type: 'text', text: '' });
|
||||
}
|
||||
|
||||
#reasoningDetail(detail: Record<string, unknown> | undefined): void {
|
||||
if (!detail) return;
|
||||
if (detail.type === 'thinking') {
|
||||
if (this.#open?.kind === 'thinking') {
|
||||
this.#delta({
|
||||
type: 'signature_delta',
|
||||
signature: detail.signature ?? '',
|
||||
});
|
||||
}
|
||||
this.#close();
|
||||
} else if (detail.type === 'redacted_thinking') {
|
||||
// No preceding `reasoning_start` for a redacted block — open and
|
||||
// close it here, same as the provider's own emission order.
|
||||
this.#close();
|
||||
this.#openBlock('other', {
|
||||
type: 'redacted_thinking',
|
||||
data: detail.data,
|
||||
});
|
||||
this.#close();
|
||||
}
|
||||
// An OpenAI Responses `reasoning` detail has no Anthropic stream
|
||||
// analog — dropped here; the non-stream route keeps it verbatim.
|
||||
}
|
||||
|
||||
#serverBlock(block: Record<string, unknown>): void {
|
||||
this.#close();
|
||||
if (block.type === 'server_tool_use') {
|
||||
this.#openBlock(
|
||||
'server_tool_use',
|
||||
{
|
||||
type: 'server_tool_use',
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
input: {},
|
||||
},
|
||||
block.id as string | undefined,
|
||||
);
|
||||
this.#delta({
|
||||
type: 'input_json_delta',
|
||||
partial_json: jsonOf(block.input),
|
||||
});
|
||||
this.#close();
|
||||
} else {
|
||||
// A server-tool *result* block (web_search_tool_result, …) arrives
|
||||
// whole — no deltas.
|
||||
this.#openBlock('other', block);
|
||||
this.#close();
|
||||
}
|
||||
}
|
||||
|
||||
onChunk(ev: Record<string, unknown>): void {
|
||||
if (this.#ended) return;
|
||||
switch (ev.type) {
|
||||
case 'text':
|
||||
if (typeof ev.text === 'string') {
|
||||
this.#ensureText();
|
||||
this.#delta({ type: 'text_delta', text: ev.text });
|
||||
}
|
||||
break;
|
||||
case 'reasoning_start':
|
||||
this.#signedReasoning = ev.format === 'anthropic';
|
||||
if (this.#signedReasoning) {
|
||||
this.#close();
|
||||
this.#openBlock('thinking', {
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
signature: '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'reasoning':
|
||||
if (
|
||||
this.#signedReasoning &&
|
||||
this.#open?.kind === 'thinking' &&
|
||||
typeof ev.reasoning === 'string'
|
||||
) {
|
||||
this.#delta({
|
||||
type: 'thinking_delta',
|
||||
thinking: ev.reasoning,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'reasoning_detail':
|
||||
this.#reasoningDetail(
|
||||
ev.detail as Record<string, unknown> | undefined,
|
||||
);
|
||||
break;
|
||||
case 'tool_use_start':
|
||||
this.#startedToolIds.add(ev.id);
|
||||
this.#close();
|
||||
this.#openBlock(
|
||||
'tool_use',
|
||||
{ type: 'tool_use', id: ev.id, name: ev.name, input: {} },
|
||||
ev.id as string | undefined,
|
||||
);
|
||||
break;
|
||||
case 'tool_input_delta':
|
||||
if (
|
||||
this.#open?.kind === 'tool_use' &&
|
||||
this.#open.id === ev.id &&
|
||||
typeof ev.partialJson === 'string'
|
||||
) {
|
||||
this.#delta({
|
||||
type: 'input_json_delta',
|
||||
partial_json: ev.partialJson,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'tool_use':
|
||||
this.#sawToolUse = true;
|
||||
if (
|
||||
this.#open?.kind === 'tool_use' &&
|
||||
this.#open.id === ev.id
|
||||
) {
|
||||
this.#close(); // already streamed incrementally
|
||||
} else if (this.#startedToolIds.has(ev.id)) {
|
||||
// Streamed and closed already (a parallel call that ended
|
||||
// after the next one opened) — never emit it twice.
|
||||
} else {
|
||||
this.#close();
|
||||
this.#openBlock(
|
||||
'tool_use',
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
input: {},
|
||||
},
|
||||
ev.id as string | undefined,
|
||||
);
|
||||
this.#delta({
|
||||
type: 'input_json_delta',
|
||||
partial_json: jsonOf(ev.input),
|
||||
});
|
||||
this.#close();
|
||||
}
|
||||
break;
|
||||
case 'server_tool':
|
||||
this.#serverBlock(ev.block as Record<string, unknown>);
|
||||
break;
|
||||
case 'compaction':
|
||||
this.#close();
|
||||
this.#openBlock('compaction', {
|
||||
type: 'compaction',
|
||||
content: null,
|
||||
});
|
||||
this.#delta({
|
||||
type: 'compaction_delta',
|
||||
content: ev.encrypted_content,
|
||||
});
|
||||
this.#close();
|
||||
// Legacy canonical frame, shared with the OpenAI-compatible
|
||||
// routes — kept alongside the native block for a caller still
|
||||
// watching for it; unknown SSE events are spec-ignored.
|
||||
this.#sse.write('compaction', {
|
||||
type: 'compaction',
|
||||
...(ev.id !== undefined ? { id: ev.id } : {}),
|
||||
encrypted_content: ev.encrypted_content,
|
||||
});
|
||||
break;
|
||||
case 'safeguard_results':
|
||||
this.#safeguards = ev.results as unknown[];
|
||||
break;
|
||||
case 'usage':
|
||||
this.#finish(ev);
|
||||
break;
|
||||
case 'error':
|
||||
this.#error(ev);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#finish(ev: Record<string, unknown>): void {
|
||||
this.#close();
|
||||
const stopReason = promoteStopForToolCalls(
|
||||
(ev.stopReason as string | undefined) ??
|
||||
(this.#sawToolUse ? 'tool_use' : 'end_turn'),
|
||||
this.#sawToolUse,
|
||||
'tool_use',
|
||||
);
|
||||
const stopDetails = ev.stopDetails as
|
||||
Record<string, unknown> | null | undefined;
|
||||
const usageDetails = (ev.usageDetails as UsageDetails | undefined) ?? {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
const contextManagement = ev.contextManagement as
|
||||
Record<string, unknown> | undefined;
|
||||
this.#sse.write('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: {
|
||||
stop_reason: stopReason,
|
||||
stop_sequence:
|
||||
(ev.stopSequence as string | null | undefined) ?? null,
|
||||
...(stopDetails ? { stop_details: stopDetails } : {}),
|
||||
...(this.#safeguards
|
||||
? { safeguard_results: this.#safeguards }
|
||||
: {}),
|
||||
},
|
||||
usage: anthropicUsage(usageDetails),
|
||||
...(contextManagement
|
||||
? { context_management: contextManagement }
|
||||
: {}),
|
||||
});
|
||||
this.#sse.write('message_stop', { type: 'message_stop' });
|
||||
this.#ended = true;
|
||||
this.#sse.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-band error chunk: writes `event: error` and ends the stream with no
|
||||
* `message_delta`/`message_stop` — a mid-stream failure is not a completed
|
||||
* message.
|
||||
*/
|
||||
#error(ev: Record<string, unknown>): void {
|
||||
const status = typeof ev.status === 'number' ? ev.status : 500;
|
||||
const type = STREAM_ERROR_TYPES[status] ?? 'api_error';
|
||||
this.#sse.write('error', {
|
||||
type: 'error',
|
||||
error: { type, message: ev.message ?? 'stream error' },
|
||||
request_id: this.#requestId,
|
||||
});
|
||||
this.#ended = true;
|
||||
this.#sse.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { renderAnthropicError, renderOpenAIError } from './wireErrors.js';
|
||||
|
||||
const req = {} as Request;
|
||||
|
||||
const withRequestId = <T>(requestId: string, fn: () => T): T =>
|
||||
runWithContext({ requestId }, fn);
|
||||
|
||||
describe('renderAnthropicError', () => {
|
||||
it.each([
|
||||
[400, 'invalid_request_error'],
|
||||
[401, 'authentication_error'],
|
||||
[402, 'billing_error'],
|
||||
[403, 'permission_error'],
|
||||
[404, 'not_found_error'],
|
||||
[409, 'conflict_error'],
|
||||
[413, 'request_too_large'],
|
||||
[429, 'rate_limit_error'],
|
||||
[504, 'timeout_error'],
|
||||
[529, 'overloaded_error'],
|
||||
])('maps status %s to error.type %s', (status, type) => {
|
||||
const r = renderAnthropicError(new HttpError(status, 'x'), req);
|
||||
expect(r.status).toBe(status);
|
||||
expect((r.body as { error: { type: string } }).error.type).toBe(type);
|
||||
});
|
||||
|
||||
it('falls back to api_error for a status with no Anthropic analog', () => {
|
||||
const r = renderAnthropicError(new HttpError(502, 'bad gateway'), req);
|
||||
expect((r.body as { error: { type: string } }).error.type).toBe(
|
||||
'api_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('never serializes `fields` (attempts) into the body', () => {
|
||||
const err = new HttpError(503, 'AI provider out of credits', {
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
fields: { attempts: [{ provider: 'claude', error: 'secret' }] },
|
||||
});
|
||||
const r = renderAnthropicError(err, req);
|
||||
expect(JSON.stringify(r.body)).not.toContain('attempts');
|
||||
expect(JSON.stringify(r.body)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('sets request-id (no dashes) on both the body and the header', () => {
|
||||
const r = withRequestId('abc-123-def-456', () =>
|
||||
renderAnthropicError(new HttpError(400, 'x'), req),
|
||||
);
|
||||
expect((r.body as { request_id: string }).request_id).toBe(
|
||||
'req_abc123def456',
|
||||
);
|
||||
expect(r.headers?.['request-id']).toBe('req_abc123def456');
|
||||
});
|
||||
|
||||
it('sets x-should-retry for 429/5xx and not for a plain 400', () => {
|
||||
const retryable = renderAnthropicError(new HttpError(429, 'x'), req);
|
||||
expect(retryable.headers?.['x-should-retry']).toBe('true');
|
||||
const notRetryable = renderAnthropicError(new HttpError(400, 'x'), req);
|
||||
expect(notRetryable.headers?.['x-should-retry']).toBe('false');
|
||||
});
|
||||
|
||||
it('defaults retry-after to 5 on a 429 with none supplied', () => {
|
||||
const r = renderAnthropicError(new HttpError(429, 'x'), req);
|
||||
expect(r.headers?.['retry-after']).toBe(5);
|
||||
});
|
||||
|
||||
it('surfaces an explicit retryAfter field over the 429 default', () => {
|
||||
const err = new HttpError(429, 'x', { fields: { retryAfter: 12 } });
|
||||
const r = renderAnthropicError(err, req);
|
||||
expect(r.headers?.['retry-after']).toBe(12);
|
||||
});
|
||||
|
||||
it('unwraps our upstream_bad_request wrapper back to the upstream status', () => {
|
||||
const err = new HttpError(400, 'request too large upstream', {
|
||||
legacyCode: 'upstream_bad_request',
|
||||
code: 'request_too_large',
|
||||
fields: { upstreamStatus: 413 },
|
||||
});
|
||||
const r = renderAnthropicError(err, req);
|
||||
expect(r.status).toBe(413);
|
||||
expect((r.body as { error: { type: string } }).error.type).toBe(
|
||||
'request_too_large',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not unwrap an upstream 422 — stays our generic 400', () => {
|
||||
const err = new HttpError(400, 'x', {
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields: { upstreamStatus: 422 },
|
||||
});
|
||||
const r = renderAnthropicError(err, req);
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
|
||||
it('scrubs the message on an unexpected (non-HttpError) 500', () => {
|
||||
const r = renderAnthropicError(new Error('db password hunter2'), req);
|
||||
expect(r.status).toBe(500);
|
||||
expect(JSON.stringify(r.body)).not.toContain('hunter2');
|
||||
});
|
||||
|
||||
it('keeps a curated 5xx HttpError message intact', () => {
|
||||
const err = new HttpError(500, 'AI provider authentication failed', {
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
});
|
||||
const r = renderAnthropicError(err, req);
|
||||
expect((r.body as { error: { message: string } }).error.message).toBe(
|
||||
'AI provider authentication failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores an exhausted outage chain to 502 api_error, with x-should-retry', () => {
|
||||
const err = new HttpError(400, 'AI provider unavailable', {
|
||||
legacyCode: 'upstream_provider_unavailable',
|
||||
fields: { attempts: [] },
|
||||
});
|
||||
const r = renderAnthropicError(err, req);
|
||||
expect(r.status).toBe(502);
|
||||
expect((r.body as { error: { type: string } }).error.type).toBe(
|
||||
'api_error',
|
||||
);
|
||||
expect(r.headers?.['x-should-retry']).toBe('true');
|
||||
});
|
||||
|
||||
it('restores an all-overloaded chain to 529 overloaded_error via upstreamStatus', () => {
|
||||
const err = new HttpError(400, 'AI provider overloaded', {
|
||||
legacyCode: 'upstream_provider_unavailable',
|
||||
fields: { attempts: [], upstreamStatus: 529 },
|
||||
});
|
||||
const r = renderAnthropicError(err, req);
|
||||
expect(r.status).toBe(529);
|
||||
expect((r.body as { error: { type: string } }).error.type).toBe(
|
||||
'overloaded_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores a mixed-failure chain to 502', () => {
|
||||
const err = new HttpError(400, 'All AI providers failed', {
|
||||
legacyCode: 'upstream_failed',
|
||||
fields: { attempts: [] },
|
||||
});
|
||||
const r = renderAnthropicError(err, req);
|
||||
expect(r.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderOpenAIError', () => {
|
||||
it.each([
|
||||
[401, 'authentication_error'],
|
||||
[403, 'permission_error'],
|
||||
[404, 'not_found_error'],
|
||||
[429, 'rate_limit_error'],
|
||||
])('maps status %s to error.type %s', (status, type) => {
|
||||
const r = renderOpenAIError(new HttpError(status, 'x'), req);
|
||||
expect((r.body as { error: { type: string } }).error.type).toBe(type);
|
||||
});
|
||||
|
||||
it('maps every 5xx to server_error', () => {
|
||||
const r = renderOpenAIError(new HttpError(500, 'x'), req);
|
||||
expect((r.body as { error: { type: string } }).error.type).toBe(
|
||||
'server_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to invalid_request_error for an unmapped 4xx (e.g. 402)', () => {
|
||||
const err = new HttpError(402, 'no funds', {
|
||||
legacyCode: 'subscription_required',
|
||||
});
|
||||
const r = renderOpenAIError(err, req);
|
||||
expect(r.status).toBe(402);
|
||||
const body = r.body as {
|
||||
error: { type: string; code: string | null };
|
||||
};
|
||||
expect(body.error.type).toBe('invalid_request_error');
|
||||
expect(body.error.code).toBe('subscription_required');
|
||||
});
|
||||
|
||||
it('carries the legacyCode through as error.code, param always null', () => {
|
||||
const r = renderOpenAIError(
|
||||
new HttpError(404, 'nope', { legacyCode: 'not_found' }),
|
||||
req,
|
||||
);
|
||||
const body = r.body as { error: { param: null; code: string } };
|
||||
expect(body.error.param).toBeNull();
|
||||
expect(body.error.code).toBe('not_found');
|
||||
});
|
||||
|
||||
it('never serializes `fields` (attempts) into the body', () => {
|
||||
const err = new HttpError(502, 'All AI providers failed', {
|
||||
legacyCode: 'upstream_failed',
|
||||
fields: { attempts: [{ provider: 'openai', error: 'secret' }] },
|
||||
});
|
||||
const r = renderOpenAIError(err, req);
|
||||
expect(JSON.stringify(r.body)).not.toContain('attempts');
|
||||
expect(JSON.stringify(r.body)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('scrubs the message on an unexpected (non-HttpError) 500', () => {
|
||||
const r = renderOpenAIError(new Error('db password hunter2'), req);
|
||||
expect(JSON.stringify(r.body)).not.toContain('hunter2');
|
||||
});
|
||||
|
||||
it('leaves an exhausted outage chain at its flat 400, unlike the Anthropic route', () => {
|
||||
const err = new HttpError(400, 'AI provider unavailable', {
|
||||
legacyCode: 'upstream_provider_unavailable',
|
||||
fields: { attempts: [] },
|
||||
});
|
||||
const r = renderOpenAIError(err, req);
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Vendor-compatible error envelopes for the Anthropic and OpenAI-compatible
|
||||
* wire routes, used as `RouteOptions.errorRenderer`. These replace the default
|
||||
* Puter `{error, message, code, ...fields}` shape for every error that reaches
|
||||
* those routes — including gate failures (auth, plan, rate limit) — with the
|
||||
* envelope the vendor's own client library expects.
|
||||
*
|
||||
* `fields` (and so `attempts`, with provider names and raw upstream text) is
|
||||
* never serialized here: the alarm gate still sees it through `onError`, which
|
||||
* runs before the renderer.
|
||||
*/
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import type { ErrorRenderer } from '../../core/http/types.js';
|
||||
|
||||
const RETRYABLE_STATUSES = new Set([408, 409, 429]);
|
||||
const isRetryableStatus = (status: number): boolean =>
|
||||
RETRYABLE_STATUSES.has(status) || status >= 500;
|
||||
|
||||
/**
|
||||
* `req_<requestId, no dashes>` — used on every Anthropic-route response,
|
||||
* success or error.
|
||||
*/
|
||||
export const anthropicRequestId = (): string =>
|
||||
`req_${(Context.get('requestId') ?? '').replaceAll('-', '')}`;
|
||||
|
||||
/**
|
||||
* An exhausted outage chain (`classifyAttempts`) reaches every renderer as a
|
||||
* 400, like main — `upstreamStatus` is where the real status rides.
|
||||
*/
|
||||
const OUTAGE_LEGACY_CODES = new Set([
|
||||
'upstream_provider_unavailable',
|
||||
'upstream_failed',
|
||||
]);
|
||||
|
||||
/**
|
||||
* `classifyAttempts`/`requestLevelError` (ChatCompletionDriver.ts) wrap an
|
||||
* upstream 4xx in our own 400 `upstream_bad_request` so the caller sees a
|
||||
* stable shape; unwrap it back to the upstream's own status when we have one,
|
||||
* so e.g. an upstream 413 still reads as 413. 422 is excluded: it isn't one of
|
||||
* the status codes either vendor's client expects back.
|
||||
*
|
||||
* `outageAs5xx` additionally restores an outage chain to the status Claude
|
||||
* Code's own gateway expects — 529 when the chain was all-overloaded, 502
|
||||
* otherwise. Only the Anthropic route opts in; every other caller keeps main's
|
||||
* flat 400.
|
||||
*/
|
||||
const effectiveStatus = (
|
||||
http: HttpError,
|
||||
opts: { outageAs5xx?: boolean } = {},
|
||||
): number => {
|
||||
const upstream = Number(http.fields?.upstreamStatus);
|
||||
if (
|
||||
http.legacyCode === 'upstream_bad_request' &&
|
||||
Number.isFinite(upstream) &&
|
||||
upstream >= 400 &&
|
||||
upstream < 500 &&
|
||||
upstream !== 422
|
||||
) {
|
||||
return upstream;
|
||||
}
|
||||
if (
|
||||
opts.outageAs5xx &&
|
||||
typeof http.legacyCode === 'string' &&
|
||||
OUTAGE_LEGACY_CODES.has(http.legacyCode)
|
||||
) {
|
||||
return upstream === 529 ? 529 : 502;
|
||||
}
|
||||
return http.statusCode;
|
||||
};
|
||||
|
||||
const retryAfterFor = (
|
||||
http: HttpError,
|
||||
status: number,
|
||||
): string | number | undefined =>
|
||||
(http.fields?.retryAfter as string | number | undefined) ??
|
||||
(status === 429 ? 5 : undefined);
|
||||
|
||||
/** A generic, never-leaky HttpError for a value that wasn't one already. */
|
||||
const toHttpError = (err: unknown): HttpError =>
|
||||
isHttpError(err) ? err : new HttpError(500, 'Internal server error');
|
||||
|
||||
/** Only a curated HttpError's own message is safe to show on a 5xx. */
|
||||
const messageFor = (http: HttpError, err: unknown, status: number): string =>
|
||||
status >= 500 && !isHttpError(err) ? 'Internal server error' : http.message;
|
||||
|
||||
// -- Anthropic ---------------------------------------------------------
|
||||
|
||||
const ANTHROPIC_TYPES: Record<number, string> = {
|
||||
400: 'invalid_request_error',
|
||||
401: 'authentication_error',
|
||||
402: 'billing_error',
|
||||
403: 'permission_error',
|
||||
404: 'not_found_error',
|
||||
409: 'conflict_error',
|
||||
413: 'request_too_large',
|
||||
429: 'rate_limit_error',
|
||||
504: 'timeout_error',
|
||||
529: 'overloaded_error',
|
||||
};
|
||||
const ANTHROPIC_KNOWN_TYPES = new Set(Object.values(ANTHROPIC_TYPES));
|
||||
|
||||
/**
|
||||
* `{type:'error', error:{type, message}, request_id}`, matching live Anthropic
|
||||
*
|
||||
* - Claude Code's own error map.
|
||||
*/
|
||||
export const renderAnthropicError: ErrorRenderer = (err, _req: Request) => {
|
||||
const http = toHttpError(err);
|
||||
const status = effectiveStatus(http, { outageAs5xx: true });
|
||||
const type =
|
||||
(http.code && ANTHROPIC_KNOWN_TYPES.has(http.code)
|
||||
? http.code
|
||||
: undefined) ??
|
||||
ANTHROPIC_TYPES[status] ??
|
||||
'api_error';
|
||||
const message = messageFor(http, err, status);
|
||||
const requestId = anthropicRequestId();
|
||||
const retryAfter = retryAfterFor(http, status);
|
||||
|
||||
return {
|
||||
status,
|
||||
body: {
|
||||
type: 'error',
|
||||
error: { type, message },
|
||||
request_id: requestId,
|
||||
},
|
||||
headers: {
|
||||
'request-id': requestId,
|
||||
'x-should-retry': String(isRetryableStatus(status)),
|
||||
...(retryAfter !== undefined ? { 'retry-after': retryAfter } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// -- OpenAI --------------------------------------------------------------
|
||||
|
||||
const OPENAI_TYPES: Record<number, string> = {
|
||||
401: 'authentication_error',
|
||||
403: 'permission_error',
|
||||
404: 'not_found_error',
|
||||
429: 'rate_limit_error',
|
||||
};
|
||||
|
||||
/** `{error:{message, type, param, code}}`, matching the OpenAI client libraries. */
|
||||
export const renderOpenAIError: ErrorRenderer = (err, _req: Request) => {
|
||||
const http = toHttpError(err);
|
||||
const status = effectiveStatus(http);
|
||||
const type =
|
||||
status >= 500
|
||||
? 'server_error'
|
||||
: (OPENAI_TYPES[status] ?? 'invalid_request_error');
|
||||
const message = messageFor(http, err, status);
|
||||
const retryAfter = retryAfterFor(http, status);
|
||||
|
||||
return {
|
||||
status,
|
||||
body: {
|
||||
error: {
|
||||
message,
|
||||
type,
|
||||
param: null,
|
||||
code: http.legacyCode ?? http.code ?? null,
|
||||
},
|
||||
},
|
||||
...(retryAfter !== undefined
|
||||
? { headers: { 'retry-after': retryAfter } }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import type { Request, Response } from 'express';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import type {
|
||||
ResolvedShare,
|
||||
ShareRecipient,
|
||||
@@ -96,7 +97,7 @@ export class ShareController extends PuterController {
|
||||
})
|
||||
async createShares(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#body(req);
|
||||
const body = bodyRecord(req);
|
||||
const recipients = this.#recipients(body);
|
||||
const items = this.#items(body, actor);
|
||||
const mode = typeof body.mode === 'string' ? body.mode : 'read';
|
||||
@@ -183,7 +184,7 @@ export class ShareController extends PuterController {
|
||||
})
|
||||
async revokeShare(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#body(req);
|
||||
const body = bodyRecord(req);
|
||||
const recipients = this.#recipients(body);
|
||||
const items = this.#items(body, actor);
|
||||
|
||||
@@ -322,6 +323,8 @@ export class ShareController extends PuterController {
|
||||
@Get('/shared-by-me/apps', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
// `shared-by-me` already answers one, and this is its index.
|
||||
allowFullAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: SHARE_LIST_LIMIT,
|
||||
})
|
||||
@@ -405,6 +408,7 @@ export class ShareController extends PuterController {
|
||||
@Get('/audit', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: SHARE_LIST_LIMIT,
|
||||
})
|
||||
@@ -439,7 +443,8 @@ export class ShareController extends PuterController {
|
||||
}
|
||||
|
||||
// -- Blocking -----------------------------------------------------
|
||||
// User sessions only: a block list is a safety control, not an app's to touch.
|
||||
// User sessions only — no app, and no access token either: a block list is
|
||||
// a safety control, not an app's to touch.
|
||||
|
||||
/**
|
||||
* GET /share/blocks — who the caller is refusing shares from, and whether
|
||||
@@ -480,7 +485,7 @@ export class ShareController extends PuterController {
|
||||
})
|
||||
async createBlock(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#body(req);
|
||||
const body = bodyRecord(req);
|
||||
if (this.#isBlockAll(body)) {
|
||||
const { all } = await this.services.share.setBlockAllSenders(
|
||||
actor,
|
||||
@@ -508,7 +513,7 @@ export class ShareController extends PuterController {
|
||||
})
|
||||
async deleteBlock(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#body(req);
|
||||
const body = bodyRecord(req);
|
||||
if (this.#isBlockAll(body)) {
|
||||
const { all } = await this.services.share.setBlockAllSenders(
|
||||
actor,
|
||||
@@ -592,16 +597,6 @@ export class ShareController extends PuterController {
|
||||
return actor;
|
||||
}
|
||||
|
||||
#body(req: Request): Record<string, unknown> {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new HttpError(400, 'body must be an object', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
#query(req: Request): Record<string, unknown> {
|
||||
return (req.query ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -375,6 +375,38 @@ describe('StaticPagesController GET /confirm-email-by-token', () => {
|
||||
expect(Boolean(refreshed?.requires_email_confirmation)).toBe(false);
|
||||
});
|
||||
|
||||
it('re-runs the email checks, as the code route does', async () => {
|
||||
const user = await makeUser({ email_confirmed: 0 });
|
||||
const deny = (_k: string, data: unknown) => {
|
||||
Object.assign(data as object, {
|
||||
allow: false,
|
||||
message: 'Address no longer accepted.',
|
||||
});
|
||||
};
|
||||
server.clients.event.on('email.validate', deny);
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute(
|
||||
'get',
|
||||
'/confirm-email-by-token',
|
||||
makeReq({
|
||||
query: {
|
||||
user_uuid: user.uuid,
|
||||
token: user.email_confirm_token,
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
} finally {
|
||||
server.clients.event.off('email.validate', deny);
|
||||
}
|
||||
expect(String(captured.body)).toContain('Address no longer accepted.');
|
||||
const refreshed = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(Boolean(refreshed?.email_confirmed)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the email is already confirmed on a different account', async () => {
|
||||
// Duplicate-email gate fires only when the existing row is
|
||||
// (a) email_confirmed=1 and (b) has a non-null password — the
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { PuterController } from '../types.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
import { promoteToVerifiedGroup } from '../../util/userProvisioning.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
|
||||
/**
|
||||
* One-off user-facing pages.
|
||||
@@ -274,85 +274,14 @@ export class StaticPagesController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
// v2 writes `clean_email` at signup (lowercased email). Older rows
|
||||
// that predate that may be null — fall back to email.lower().
|
||||
const cleanEmail =
|
||||
(user.clean_email as string | null | undefined) ??
|
||||
String(user.email ?? '').toLowerCase();
|
||||
|
||||
// An account that already confirmed this address proved access
|
||||
// to the inbox. The strip below would take it away from them,
|
||||
// so refuse here instead. Password-less accounts count: an
|
||||
// identity provider verified the address for those, and they
|
||||
// are exactly what the old `password IS NOT NULL` clause let
|
||||
// through.
|
||||
const confirmedRival =
|
||||
await this.stores.user.findConfirmedOtherByEmail(
|
||||
user.id as number,
|
||||
user.email as string,
|
||||
cleanEmail,
|
||||
);
|
||||
if (confirmedRival) {
|
||||
res.send(
|
||||
err('This email was confirmed on a different account.'),
|
||||
);
|
||||
try {
|
||||
await this.services.signup.confirmEmail(user);
|
||||
} catch (e) {
|
||||
if (!(e instanceof HttpError)) throw e;
|
||||
res.send(err(e.message));
|
||||
return;
|
||||
}
|
||||
|
||||
// Revoke any other accounts' pending change-email slots targeting
|
||||
// this address — they're no longer valid once someone confirms it.
|
||||
await this.clients.db.write(
|
||||
'UPDATE `user` SET `unconfirmed_change_email` = NULL, `change_email_confirm_token` = NULL WHERE `unconfirmed_change_email` = ?',
|
||||
[user.email],
|
||||
);
|
||||
|
||||
// Take the address off every remaining row before confirming
|
||||
// this one. The check above leaves only unconfirmed rows, and
|
||||
// only one row may own an address once this one is confirmed.
|
||||
await this.stores.user.unconfirmOthersByEmail(
|
||||
user.id,
|
||||
user.email as string,
|
||||
cleanEmail,
|
||||
);
|
||||
|
||||
await this.stores.user.update(user.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
email_confirm_code: null,
|
||||
email_confirm_token: null,
|
||||
});
|
||||
|
||||
await promoteToVerifiedGroup(
|
||||
this.stores.group,
|
||||
this.config,
|
||||
user,
|
||||
);
|
||||
|
||||
// Best-effort side-channels — don't fail the user-visible response
|
||||
// if sockets or the event bus are unavailable.
|
||||
try {
|
||||
await this.services.socket.send(
|
||||
{ room: user.id },
|
||||
'user.email_confirmed',
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
this.clients.event?.emit(
|
||||
'user.email-confirmed',
|
||||
{
|
||||
user_id: user.id,
|
||||
user_uid: user.uuid,
|
||||
email: user.email,
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
res.send(ok('Your email has been successfully confirmed.'));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -303,6 +303,21 @@ describe('team endpoints over HTTP', () => {
|
||||
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count a team it hides from an app token', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', '/teams?includeTotal=true', appToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { uid: string }[];
|
||||
total: number;
|
||||
};
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
// A count over rows the page withholds reports the hidden team.
|
||||
expect(body.total).toBe(body.items.length);
|
||||
});
|
||||
|
||||
it('still lists a team to its owner session with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.token);
|
||||
|
||||
@@ -111,16 +111,22 @@ export class TeamController extends PuterController {
|
||||
async listTeams(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const teams = await this.stores.team.listTeamsForUser(userId);
|
||||
// An app only sees a team whose owner opened the directory to apps;
|
||||
// a team that hasn't is indistinguishable from no team at all.
|
||||
const visible = isAccountContext(req.actor)
|
||||
? teams
|
||||
: teams.filter((t) => this.services.team.isDirectoryOpen(t));
|
||||
// In SQL, or cursor and total describe teams the app cannot see.
|
||||
const page = await this.stores.team.listTeamsForUser(userId, {
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
includeTotal: req.query.includeTotal === 'true',
|
||||
openOnly: !isAccountContext(req.actor),
|
||||
});
|
||||
res.json({
|
||||
items: visible.map((t) =>
|
||||
items: page.items.map((t) =>
|
||||
toClientTeam(t, t.owner_user_id === userId),
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total === undefined ? {} : { total: page.total }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,11 +242,7 @@ export class TeamController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admits an app actor, like `listTeams` and `listMembers`, once the team
|
||||
* has opted in. Discloses nothing a colleague cannot already read through
|
||||
* `/members`.
|
||||
*/
|
||||
/** App actors too, once opted in; carries the `uuid` `/members` hides. */
|
||||
@Get('/:uid/directory', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
@@ -336,6 +338,29 @@ export class TeamController extends PuterController {
|
||||
res.json({ temporary_password: temporaryPassword });
|
||||
}
|
||||
|
||||
// The gate closes the seat's own route to fix it, so the owner must.
|
||||
@Post('/:uid/members/:username/email', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async updateSeatEmail(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.updateSeatEmail(
|
||||
uid,
|
||||
userId,
|
||||
target,
|
||||
(req.body as { email?: unknown } | undefined)?.email ?? null,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// Same budget as a password reset: both are a step toward the account.
|
||||
@Post('/:uid/members/:username/2fa-reset', {
|
||||
subdomain: 'api',
|
||||
|
||||
@@ -1163,6 +1163,64 @@ describe('WebDAVController', () => {
|
||||
const xml = cap2.body as string;
|
||||
expect(xml).toContain(lockToken);
|
||||
});
|
||||
|
||||
it('grants and reports the requested Timeout, on create and refresh', async () => {
|
||||
const uniquePath = `/test/timeout-lock-${Date.now()}.txt`;
|
||||
const actor = {
|
||||
user: { id: 1, uuid: 'test-uuid', username: 'test' },
|
||||
};
|
||||
const { res: res1, captured: cap1 } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'LOCK',
|
||||
path: uniquePath,
|
||||
headers: { timeout: 'Second-120' },
|
||||
actor,
|
||||
}),
|
||||
res1,
|
||||
noop,
|
||||
);
|
||||
expect(cap1.statusCode).toBe(200);
|
||||
expect(cap1.body as string).toContain(
|
||||
'<D:timeout>Second-120</D:timeout>',
|
||||
);
|
||||
const lockToken = cap1.headers['lock-token']!.replace(/[<>]/g, '');
|
||||
|
||||
const { res: res2, captured: cap2 } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'LOCK',
|
||||
path: uniquePath,
|
||||
headers: { if: `(<${lockToken}>)`, timeout: 'Second-300' },
|
||||
actor,
|
||||
}),
|
||||
res2,
|
||||
noop,
|
||||
);
|
||||
expect(cap2.statusCode).toBe(200);
|
||||
expect(cap2.body as string).toContain(
|
||||
'<D:timeout>Second-300</D:timeout>',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the server max when no Timeout is requested', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'LOCK',
|
||||
path: `/test/default-timeout-${Date.now()}.txt`,
|
||||
actor: {
|
||||
user: { id: 1, uuid: 'test-uuid', username: 'test' },
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(captured.body as string).toContain(
|
||||
'<D:timeout>Second-3600</D:timeout>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
@@ -1296,6 +1354,21 @@ describe('WebDAVController verbs', () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Index of the `eventName` GUI emit about `uid` among recorded calls. */
|
||||
const guiEventIndex = (
|
||||
calls: unknown[][],
|
||||
eventName: string,
|
||||
uid: string,
|
||||
): number =>
|
||||
calls.findIndex(
|
||||
([name, data]) =>
|
||||
name === eventName &&
|
||||
(data as { response: Record<string, unknown> }).response.uid ===
|
||||
uid,
|
||||
);
|
||||
const guiResponse = (calls: unknown[][], index: number) =>
|
||||
(calls[index]?.[1] as { response: Record<string, unknown> }).response;
|
||||
|
||||
describe('GET / HEAD', () => {
|
||||
it('streams file bytes with a strong ETag and Last-Modified', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
@@ -1897,20 +1970,49 @@ describe('WebDAVController verbs', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns 204 when overwriting an existing destination', async () => {
|
||||
it('overwrites an existing destination with 204 and announces the replaced entry', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const source = `/${username}/Documents/copy-over-src.txt`;
|
||||
const destination = `/${username}/Documents/copy-over-dst.txt`;
|
||||
await putFile(actor, source, 'fresh');
|
||||
await putFile(actor, destination, 'stale');
|
||||
|
||||
const captured = await dispatch({
|
||||
method: 'COPY',
|
||||
path: source,
|
||||
actor,
|
||||
headers: { destination, host: 'dav.puter.localhost' },
|
||||
});
|
||||
const replaced =
|
||||
(await server.stores.fsEntry.getEntryByPath(destination))!;
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let captured: Awaited<ReturnType<typeof dispatch>>;
|
||||
let removed: Record<string, unknown> | undefined;
|
||||
try {
|
||||
captured = await dispatch({
|
||||
method: 'COPY',
|
||||
path: source,
|
||||
actor,
|
||||
headers: { destination, host: 'dav.puter.localhost' },
|
||||
});
|
||||
const calls = emitSpy.mock.calls;
|
||||
const added = () =>
|
||||
calls.findIndex(
|
||||
([name]) => name === 'outer.gui.item.added',
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(added()).toBeGreaterThanOrEqual(0),
|
||||
);
|
||||
const removedAt = guiEventIndex(
|
||||
calls,
|
||||
'outer.gui.item.removed',
|
||||
replaced.uuid,
|
||||
);
|
||||
// Removal first: the desktop hides rows by path.
|
||||
expect(removedAt).toBeGreaterThanOrEqual(0);
|
||||
expect(removedAt).toBeLessThan(added());
|
||||
removed = guiResponse(calls, removedAt);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
expect(captured.statusCode).toBe(204);
|
||||
// Other clients drop the replaced entry's row by this.
|
||||
expect(removed?.path).toBe(destination);
|
||||
});
|
||||
|
||||
it('returns 412 when the destination exists and Overwrite is F', async () => {
|
||||
@@ -1991,6 +2093,55 @@ describe('WebDAVController verbs', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('overwrites an existing destination and announces both changes', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const source = `/${username}/Documents/move-over-src.txt`;
|
||||
const destination = `/${username}/Documents/move-over-dst.txt`;
|
||||
await putFile(actor, source, 'fresh');
|
||||
await putFile(actor, destination, 'stale');
|
||||
const moving =
|
||||
(await server.stores.fsEntry.getEntryByPath(source))!;
|
||||
const replaced =
|
||||
(await server.stores.fsEntry.getEntryByPath(destination))!;
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let captured: Awaited<ReturnType<typeof dispatch>>;
|
||||
let moved: Record<string, unknown> | undefined;
|
||||
let removed: Record<string, unknown> | undefined;
|
||||
try {
|
||||
captured = await dispatch({
|
||||
method: 'MOVE',
|
||||
path: source,
|
||||
actor,
|
||||
headers: { destination, host: 'dav.puter.localhost' },
|
||||
});
|
||||
const calls = emitSpy.mock.calls;
|
||||
const movedAt = () =>
|
||||
guiEventIndex(calls, 'outer.gui.item.moved', moving.uuid);
|
||||
await vi.waitFor(() =>
|
||||
expect(movedAt()).toBeGreaterThanOrEqual(0),
|
||||
);
|
||||
const removedAt = guiEventIndex(
|
||||
calls,
|
||||
'outer.gui.item.removed',
|
||||
replaced.uuid,
|
||||
);
|
||||
// Removal first: the desktop hides rows by path.
|
||||
expect(removedAt).toBeGreaterThanOrEqual(0);
|
||||
expect(removedAt).toBeLessThan(movedAt());
|
||||
moved = guiResponse(calls, movedAt());
|
||||
removed = guiResponse(calls, removedAt);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
expect(captured.statusCode).toBe(204);
|
||||
expect(moved).toMatchObject({
|
||||
path: destination,
|
||||
old_path: source,
|
||||
});
|
||||
expect(removed?.path).toBe(destination);
|
||||
});
|
||||
|
||||
it('returns 412 when the destination exists and Overwrite is F', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const source = `/${username}/Documents/move-nf-src.txt`;
|
||||
|
||||
@@ -21,7 +21,6 @@ import { compare as bcryptCompare } from 'bcrypt';
|
||||
import type { Request, Response } from 'express';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { EventMap } from '../../clients/event/types.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
@@ -56,7 +55,10 @@ import {
|
||||
} from '../../services/fs/sharePathMask.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import { toLegacyEntry } from '../fs/legacyFsHelpers.js';
|
||||
import {
|
||||
emitGuiItemEvent,
|
||||
type GuiItemEventName,
|
||||
} from '../fs/legacyFsHelpers.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import {
|
||||
createLock,
|
||||
@@ -65,6 +67,7 @@ import {
|
||||
getFileLocks,
|
||||
getLockIfValid,
|
||||
hasWritePermission,
|
||||
parseLockTimeout,
|
||||
refreshLock,
|
||||
} from './locks.js';
|
||||
import {
|
||||
@@ -947,7 +950,8 @@ export class WebDAVController extends PuterController {
|
||||
newName: pathPosix.basename(destPath),
|
||||
overwrite,
|
||||
});
|
||||
this.#emitGuiEvent('outer.gui.item.added', copy);
|
||||
// Announcing what the copy replaced keeps a ghost row off other clients.
|
||||
this.#emitGuiEvent('outer.gui.item.added', copy, undefined, destExists);
|
||||
res.status(destExists ? 204 : 201).end();
|
||||
}
|
||||
|
||||
@@ -998,9 +1002,12 @@ export class WebDAVController extends PuterController {
|
||||
newName: pathPosix.basename(destPath),
|
||||
overwrite,
|
||||
});
|
||||
this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
old_path: davPath,
|
||||
});
|
||||
this.#emitGuiEvent(
|
||||
'outer.gui.item.moved',
|
||||
moved,
|
||||
{ old_path: davPath },
|
||||
destExists,
|
||||
);
|
||||
res.status(destExists ? 204 : 201).end();
|
||||
}
|
||||
|
||||
@@ -1018,6 +1025,10 @@ export class WebDAVController extends PuterController {
|
||||
// and block writes for everyone else.
|
||||
await this.#assertWrite(actor, davPath);
|
||||
|
||||
const timeoutSeconds = parseLockTimeout(
|
||||
req.headers.timeout as string | undefined,
|
||||
);
|
||||
|
||||
// Refresh existing lock
|
||||
if (headerToken) {
|
||||
const existing = await getLockIfValid(r, headerToken);
|
||||
@@ -1025,14 +1036,19 @@ export class WebDAVController extends PuterController {
|
||||
throw new HttpError(412, 'Lock token not found', {
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
await refreshLock(r, headerToken);
|
||||
await refreshLock(r, headerToken, timeoutSeconds);
|
||||
res.status(200)
|
||||
.set({
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
...DAV_HEADERS,
|
||||
})
|
||||
.send(
|
||||
lockResponseXml(headerToken, davPath, existing.lockScope),
|
||||
lockResponseXml(
|
||||
headerToken,
|
||||
davPath,
|
||||
existing.lockScope,
|
||||
timeoutSeconds,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1056,7 +1072,7 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
const token = await createLock(r, davPath, lockScope);
|
||||
const token = await createLock(r, davPath, lockScope, timeoutSeconds);
|
||||
const status = 200;
|
||||
|
||||
res.status(status)
|
||||
@@ -1065,7 +1081,7 @@ export class WebDAVController extends PuterController {
|
||||
'Lock-Token': `<${token}>`,
|
||||
...DAV_HEADERS,
|
||||
})
|
||||
.send(lockResponseXml(token, davPath, lockScope));
|
||||
.send(lockResponseXml(token, davPath, lockScope, timeoutSeconds));
|
||||
}
|
||||
|
||||
// -- UNLOCK ------------------------------------------------------
|
||||
@@ -1122,11 +1138,7 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
const allowed = await this.services.acl.check(
|
||||
actor,
|
||||
{
|
||||
path: root.path,
|
||||
resolveAncestors: () =>
|
||||
this.services.fs.getAncestorChain(root.path),
|
||||
},
|
||||
this.services.acl.fsDescriptor(root.path),
|
||||
'read',
|
||||
);
|
||||
if (!allowed) return null;
|
||||
@@ -1145,14 +1157,8 @@ export class WebDAVController extends PuterController {
|
||||
// -- ACL helpers -------------------------------------------------
|
||||
|
||||
async #canRead(actor: Actor, path: string): Promise<boolean> {
|
||||
return await this.services.acl.check(
|
||||
actor,
|
||||
{
|
||||
path,
|
||||
resolveAncestors: () => this.services.fs.getAncestorChain(path),
|
||||
},
|
||||
'read',
|
||||
);
|
||||
const acl = this.services.acl;
|
||||
return await acl.check(actor, acl.fsDescriptor(path), 'read');
|
||||
}
|
||||
|
||||
async #assertRead(actor: Actor, path: string): Promise<void> {
|
||||
@@ -1163,11 +1169,8 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
|
||||
async #assertWrite(actor: Actor, path: string): Promise<void> {
|
||||
const descriptor = {
|
||||
path,
|
||||
resolveAncestors: () => this.services.fs.getAncestorChain(path),
|
||||
};
|
||||
const ok = await this.services.acl.check(actor, descriptor, 'write');
|
||||
const acl = this.services.acl;
|
||||
const ok = await acl.check(actor, acl.fsDescriptor(path), 'write');
|
||||
if (!ok)
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
@@ -1176,33 +1179,25 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
// -- Event emission ----------------------------------------------
|
||||
|
||||
#emitGuiEvent<T extends keyof EventMap>(
|
||||
eventName: T,
|
||||
#emitGuiEvent(
|
||||
eventName: GuiItemEventName,
|
||||
entry: FSEntry,
|
||||
extra?: Record<string, unknown>,
|
||||
replaced?: FSEntry | null,
|
||||
): void {
|
||||
const meta = {};
|
||||
void Promise.resolve()
|
||||
.then(async () => {
|
||||
const response = {
|
||||
...(await toLegacyEntry(this.clients.event, entry, {
|
||||
forOwner: true,
|
||||
})),
|
||||
...extra,
|
||||
from_new_service: true,
|
||||
};
|
||||
this.clients.event.emit(
|
||||
void (async () => {
|
||||
try {
|
||||
await emitGuiItemEvent(
|
||||
this.clients.event,
|
||||
eventName,
|
||||
{
|
||||
user_id_list: [entry.userId],
|
||||
response,
|
||||
} as unknown as EventMap[T],
|
||||
meta,
|
||||
entry,
|
||||
extra,
|
||||
replaced,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
} catch {
|
||||
// non-critical
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// -- Misc helpers ------------------------------------------------
|
||||
@@ -1397,6 +1392,7 @@ function lockResponseXml(
|
||||
token: string,
|
||||
path: string,
|
||||
scope: 'exclusive' | 'shared',
|
||||
timeoutSeconds: number,
|
||||
): string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:prop xmlns:D="DAV:">
|
||||
@@ -1406,7 +1402,7 @@ function lockResponseXml(
|
||||
<D:lockscope><D:${scope}/></D:lockscope>
|
||||
<D:depth>0</D:depth>
|
||||
<D:owner><D:href>webdav-user</D:href></D:owner>
|
||||
<D:timeout>Second-7200</D:timeout>
|
||||
<D:timeout>Second-${timeoutSeconds}</D:timeout>
|
||||
<D:locktoken><D:href>${escapeXml(token)}</D:href></D:locktoken>
|
||||
<D:lockroot><D:href>${escapeXml(encodeURI(path))}</D:href></D:lockroot>
|
||||
</D:activelock>
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
getFileLocks,
|
||||
getLockIfValid,
|
||||
hasWritePermission,
|
||||
MAX_LOCK_TIMEOUT_SECONDS,
|
||||
parseLockTimeout,
|
||||
refreshLock,
|
||||
} from './locks.js';
|
||||
|
||||
@@ -69,6 +71,32 @@ describe('extractLockToken', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLockTimeout', () => {
|
||||
it('grants the requested Second-N', () => {
|
||||
expect(parseLockTimeout('Second-60')).toBe(60);
|
||||
});
|
||||
|
||||
it('uses the first Second-N entry after Infinite', () => {
|
||||
expect(parseLockTimeout('Infinite, Second-120')).toBe(120);
|
||||
});
|
||||
|
||||
it('caps requests above the max', () => {
|
||||
expect(parseLockTimeout('Second-4100000000')).toBe(
|
||||
MAX_LOCK_TIMEOUT_SECONDS,
|
||||
);
|
||||
});
|
||||
|
||||
it('grants at least one second', () => {
|
||||
expect(parseLockTimeout('Second-0')).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to the max for missing, Infinite or malformed headers', () => {
|
||||
expect(parseLockTimeout(undefined)).toBe(MAX_LOCK_TIMEOUT_SECONDS);
|
||||
expect(parseLockTimeout('Infinite')).toBe(MAX_LOCK_TIMEOUT_SECONDS);
|
||||
expect(parseLockTimeout('Second-abc')).toBe(MAX_LOCK_TIMEOUT_SECONDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLock / getLockIfValid', () => {
|
||||
it('mints a urn:uuid token that resolves back to its path and scope', async () => {
|
||||
const path = uniquePath('a.txt');
|
||||
@@ -110,6 +138,49 @@ describe('createLock / getLockIfValid', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock timeouts', () => {
|
||||
it('expires the token key after the granted timeout', async () => {
|
||||
const token = await createLock(
|
||||
redis,
|
||||
uniquePath('ttl.txt'),
|
||||
'exclusive',
|
||||
120,
|
||||
);
|
||||
const ttl = await redis.ttl(`dav:lock:${token}`);
|
||||
expect(ttl).toBeGreaterThan(110);
|
||||
expect(ttl).toBeLessThanOrEqual(120);
|
||||
});
|
||||
|
||||
it('refresh extends the token and path keys to the new timeout', async () => {
|
||||
const path = uniquePath('ttl-refresh.txt');
|
||||
const token = await createLock(redis, path, 'exclusive', 30);
|
||||
await refreshLock(redis, token, 600);
|
||||
expect(await redis.ttl(`dav:lock:${token}`)).toBeGreaterThan(590);
|
||||
expect(await redis.ttl(`dav:locks:${path}`)).toBeGreaterThan(590);
|
||||
});
|
||||
|
||||
it('a shorter sibling lock does not shorten the path map', async () => {
|
||||
const path = uniquePath('ttl-siblings.txt');
|
||||
await createLock(redis, path, 'shared', 600);
|
||||
await createLock(redis, path, 'shared', 5);
|
||||
expect(await redis.ttl(`dav:locks:${path}`)).toBeGreaterThan(590);
|
||||
});
|
||||
|
||||
it('ignores path-map entries that have expired', async () => {
|
||||
const path = uniquePath('ttl-stale-entry.txt');
|
||||
const live = await createLock(redis, path, 'shared', 600);
|
||||
const raw = JSON.parse((await redis.get(`dav:locks:${path}`))!);
|
||||
raw['urn:uuid:22222222-2222-2222-2222-222222222222'] = {
|
||||
lockScope: 'exclusive',
|
||||
lockType: 'write',
|
||||
expiresAt: Date.now() - 1000,
|
||||
};
|
||||
await redis.set(`dav:locks:${path}`, JSON.stringify(raw));
|
||||
const locks = await getFileLocks(redis, path);
|
||||
expect(locks.map((l) => l.lockToken)).toEqual([live]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileLocks inheritance', () => {
|
||||
it('reports a lock held on an ancestor directory', async () => {
|
||||
const dir = uniquePath('dir');
|
||||
@@ -214,6 +285,22 @@ describe('hasWritePermission', () => {
|
||||
expect(await hasWritePermission(redis, locked, elsewhere)).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a directory lock cover its descendants', async () => {
|
||||
const dir = uniquePath('covered');
|
||||
const token = await createLock(redis, dir, 'exclusive');
|
||||
expect(await hasWritePermission(redis, `${dir}/c.txt`, token)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a lock cover a sibling sharing its name prefix', async () => {
|
||||
const dir = uniquePath('prefix');
|
||||
const sibling = `${dir}c`;
|
||||
await createLock(redis, sibling, 'shared');
|
||||
const token = await createLock(redis, dir, 'shared');
|
||||
expect(await hasWritePermission(redis, sibling, token)).toBe(false);
|
||||
});
|
||||
|
||||
it("denies when another holder's exclusive lock also covers the path", async () => {
|
||||
const dir = uniquePath('conflict');
|
||||
const filePath = `${dir}/file.txt`;
|
||||
|
||||
@@ -26,12 +26,13 @@ import type { Cluster } from 'ioredis';
|
||||
*
|
||||
* Two key families: `dav:lock:<token>` → JSON `{ path, lockScope, lockType }`
|
||||
* (per-token metadata) `dav:locks:<path>` → JSON `{ <token>: { lockScope,
|
||||
* lockType }, ... }` (per-path map)
|
||||
* lockType, expiresAt }, ... }` (per-path map)
|
||||
*
|
||||
* Both keys share the same TTL so they expire together.
|
||||
* Each token key expires with its own lock; the path map lives as long as its
|
||||
* longest-lived entry, and expired entries in it are ignored.
|
||||
*/
|
||||
|
||||
const DAV_LOCK_TTL_SECONDS = 30;
|
||||
export const MAX_LOCK_TIMEOUT_SECONDS = 3600;
|
||||
const LOCK_PREFIX = 'dav:lock:';
|
||||
const LOCKS_PREFIX = 'dav:locks:';
|
||||
|
||||
@@ -42,32 +43,51 @@ export interface LockInfo {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PathLockEntry {
|
||||
lockScope: 'exclusive' | 'shared';
|
||||
lockType: 'write';
|
||||
/** Epoch ms. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type PathLockMap = Record<string, PathLockEntry>;
|
||||
|
||||
/**
|
||||
* Lock timeout to grant for a `Timeout` request header (RFC 4918 §10.7): the
|
||||
* first `Second-N` entry, capped at the max. `Infinite` or no usable entry gets
|
||||
* the max.
|
||||
*/
|
||||
export function parseLockTimeout(header: string | undefined): number {
|
||||
for (const part of (header ?? '').split(',')) {
|
||||
const match = part.trim().match(/^Second-(\d+)$/i);
|
||||
if (!match) continue;
|
||||
const seconds = Number(match[1]);
|
||||
return Math.min(Math.max(seconds, 1), MAX_LOCK_TIMEOUT_SECONDS);
|
||||
}
|
||||
return MAX_LOCK_TIMEOUT_SECONDS;
|
||||
}
|
||||
|
||||
export async function createLock(
|
||||
redis: Cluster,
|
||||
filePath: string,
|
||||
lockScope: 'exclusive' | 'shared',
|
||||
lockType: 'write' = 'write',
|
||||
timeoutSeconds: number = MAX_LOCK_TIMEOUT_SECONDS,
|
||||
): Promise<string> {
|
||||
const lockToken = `urn:uuid:${randomUUID()}`;
|
||||
const lockType = 'write';
|
||||
const meta = JSON.stringify({ path: filePath, lockScope, lockType });
|
||||
|
||||
// Per-token metadata
|
||||
await redis.set(
|
||||
`${LOCK_PREFIX}${lockToken}`,
|
||||
meta,
|
||||
'EX',
|
||||
DAV_LOCK_TTL_SECONDS,
|
||||
);
|
||||
await redis.set(`${LOCK_PREFIX}${lockToken}`, meta, 'EX', timeoutSeconds);
|
||||
|
||||
// Per-path map — merge with any existing locks on this path
|
||||
const existing = await getPathLockMap(redis, filePath);
|
||||
existing[lockToken] = { lockScope, lockType };
|
||||
await redis.set(
|
||||
`${LOCKS_PREFIX}${filePath}`,
|
||||
JSON.stringify(existing),
|
||||
'EX',
|
||||
DAV_LOCK_TTL_SECONDS,
|
||||
);
|
||||
const pathMap = await getPathLockMap(redis, filePath);
|
||||
pathMap[lockToken] = {
|
||||
lockScope,
|
||||
lockType,
|
||||
expiresAt: Date.now() + timeoutSeconds * 1000,
|
||||
};
|
||||
await writePathLockMap(redis, filePath, pathMap);
|
||||
|
||||
return lockToken;
|
||||
}
|
||||
@@ -81,16 +101,7 @@ export async function deleteLock(
|
||||
const meta = JSON.parse(raw) as { path: string };
|
||||
const pathMap = await getPathLockMap(redis, meta.path);
|
||||
delete pathMap[lockToken];
|
||||
if (Object.keys(pathMap).length === 0) {
|
||||
await redis.del(`${LOCKS_PREFIX}${meta.path}`);
|
||||
} else {
|
||||
await redis.set(
|
||||
`${LOCKS_PREFIX}${meta.path}`,
|
||||
JSON.stringify(pathMap),
|
||||
'EX',
|
||||
DAV_LOCK_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
await writePathLockMap(redis, meta.path, pathMap);
|
||||
}
|
||||
await redis.del(`${LOCK_PREFIX}${lockToken}`);
|
||||
}
|
||||
@@ -98,28 +109,27 @@ export async function deleteLock(
|
||||
export async function refreshLock(
|
||||
redis: Cluster,
|
||||
lockToken: string,
|
||||
timeoutSeconds: number = MAX_LOCK_TIMEOUT_SECONDS,
|
||||
): Promise<boolean> {
|
||||
const raw = await redis.get(`${LOCK_PREFIX}${lockToken}`);
|
||||
if (!raw) return false;
|
||||
const meta = JSON.parse(raw) as { path: string };
|
||||
const meta = JSON.parse(raw) as {
|
||||
path: string;
|
||||
lockScope: 'exclusive' | 'shared';
|
||||
lockType: 'write';
|
||||
};
|
||||
|
||||
// Re-set with fresh TTL
|
||||
await redis.set(
|
||||
`${LOCK_PREFIX}${lockToken}`,
|
||||
raw,
|
||||
'EX',
|
||||
DAV_LOCK_TTL_SECONDS,
|
||||
);
|
||||
// Refresh the path map TTL too
|
||||
const pathRaw = await redis.get(`${LOCKS_PREFIX}${meta.path}`);
|
||||
if (pathRaw) {
|
||||
await redis.set(
|
||||
`${LOCKS_PREFIX}${meta.path}`,
|
||||
pathRaw,
|
||||
'EX',
|
||||
DAV_LOCK_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
await redis.set(`${LOCK_PREFIX}${lockToken}`, raw, 'EX', timeoutSeconds);
|
||||
|
||||
// Re-add the entry even if the path map expired, so the lock keeps
|
||||
// blocking other writers.
|
||||
const pathMap = await getPathLockMap(redis, meta.path);
|
||||
pathMap[lockToken] = {
|
||||
lockScope: meta.lockScope,
|
||||
lockType: meta.lockType,
|
||||
expiresAt: Date.now() + timeoutSeconds * 1000,
|
||||
};
|
||||
await writePathLockMap(redis, meta.path, pathMap);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -139,9 +149,8 @@ export async function getFileLocks(
|
||||
for (const [token, info] of Object.entries(map)) {
|
||||
results.push({
|
||||
lockToken: token,
|
||||
lockScope: (info as { lockScope: 'exclusive' | 'shared' })
|
||||
.lockScope,
|
||||
lockType: (info as { lockType: 'write' }).lockType,
|
||||
lockScope: info.lockScope,
|
||||
lockType: info.lockType,
|
||||
path: current,
|
||||
});
|
||||
}
|
||||
@@ -184,9 +193,7 @@ export async function hasWritePermission(
|
||||
if (!myLock) return false; // token expired or invalid
|
||||
|
||||
// Token's path must match or be an ancestor of the target
|
||||
if (!filePath.startsWith(myLock.path) && myLock.path !== filePath) {
|
||||
return false;
|
||||
}
|
||||
if (!isSameOrDescendant(filePath, myLock.path)) return false;
|
||||
|
||||
// Check lock scope rules
|
||||
for (const lock of locks) {
|
||||
@@ -198,20 +205,51 @@ export async function hasWritePermission(
|
||||
|
||||
// -- Internals -------------------------------------------------------
|
||||
|
||||
function isSameOrDescendant(filePath: string, ancestor: string): boolean {
|
||||
if (filePath === ancestor) return true;
|
||||
const prefix = ancestor.endsWith('/') ? ancestor : `${ancestor}/`;
|
||||
return filePath.startsWith(prefix);
|
||||
}
|
||||
|
||||
/** The path's lock map with already-expired entries dropped. */
|
||||
async function getPathLockMap(
|
||||
redis: Cluster,
|
||||
filePath: string,
|
||||
): Promise<Record<string, { lockScope: string; lockType: string }>> {
|
||||
): Promise<PathLockMap> {
|
||||
const raw = await redis.get(`${LOCKS_PREFIX}${filePath}`);
|
||||
if (!raw) return {};
|
||||
let map: PathLockMap;
|
||||
try {
|
||||
return JSON.parse(raw) as Record<
|
||||
string,
|
||||
{ lockScope: string; lockType: string }
|
||||
>;
|
||||
map = JSON.parse(raw) as PathLockMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of Object.entries(map)) {
|
||||
// Entries without expiresAt predate per-entry expiry; refreshLock
|
||||
// re-adds live ones.
|
||||
if (!(entry.expiresAt > now)) {
|
||||
delete map[token];
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Store the map with a TTL covering its longest-lived entry, or drop it. */
|
||||
async function writePathLockMap(
|
||||
redis: Cluster,
|
||||
filePath: string,
|
||||
map: PathLockMap,
|
||||
): Promise<void> {
|
||||
const key = `${LOCKS_PREFIX}${filePath}`;
|
||||
if (Object.keys(map).length === 0) {
|
||||
await redis.del(key);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const latest = Math.max(...Object.values(map).map((e) => e.expiresAt));
|
||||
const ttlSeconds = Math.max(1, Math.ceil((latest - now) / 1000));
|
||||
await redis.set(key, JSON.stringify(map), 'EX', ttlSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,14 +60,6 @@ export class WispController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireAuth: false,
|
||||
// Unauthenticated by design, which makes it a token-guessing
|
||||
// oracle without a ceiling.
|
||||
rateLimit: {
|
||||
scope: 'wisp-token-verify',
|
||||
limit: 300,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
},
|
||||
this.#verify,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
actorOwnsRow,
|
||||
assertActorEmailVerified,
|
||||
makeActor,
|
||||
requireContextActor,
|
||||
requireContextUserActor,
|
||||
SYSTEM_ACTOR,
|
||||
type Actor,
|
||||
} from './actor.js';
|
||||
import { runWithContext } from './context.js';
|
||||
|
||||
const user = { id: 7, uuid: 'u-7', username: 'dev', email_confirmed: true };
|
||||
const plain = makeActor({ user });
|
||||
const asApp = (appId: number) =>
|
||||
makeActor({ user, app: { id: appId, uid: `app-${appId}` } });
|
||||
|
||||
describe('requireContextActor', () => {
|
||||
it('returns the request actor and 401s without one', () => {
|
||||
expect(runWithContext({ actor: plain }, requireContextActor)).toBe(
|
||||
plain,
|
||||
);
|
||||
expect(() =>
|
||||
runWithContext({ actor: undefined }, requireContextActor),
|
||||
).toThrow(expect.objectContaining({ statusCode: 401 }));
|
||||
});
|
||||
|
||||
it('accepts an actor without a user id', () => {
|
||||
expect(
|
||||
runWithContext({ actor: SYSTEM_ACTOR }, requireContextActor),
|
||||
).toBe(SYSTEM_ACTOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireContextUserActor', () => {
|
||||
it('returns an actor acting for a user', () => {
|
||||
expect(runWithContext({ actor: plain }, requireContextUserActor)).toBe(
|
||||
plain,
|
||||
);
|
||||
});
|
||||
|
||||
it('401s without an actor or without a user id', () => {
|
||||
for (const actor of [undefined, SYSTEM_ACTOR]) {
|
||||
expect(() =>
|
||||
runWithContext({ actor }, requireContextUserActor),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
statusCode: 401,
|
||||
legacyCode: 'unauthorized',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertActorEmailVerified', () => {
|
||||
const unverified: Actor = makeActor({
|
||||
user: { ...user, email_confirmed: false },
|
||||
});
|
||||
|
||||
it('is inert unless strict verification is configured', () => {
|
||||
expect(() => assertActorEmailVerified(unverified, {})).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unverified actor with 400 under strict verification', () => {
|
||||
const strict = { strict_email_verification_required: true };
|
||||
expect(() => assertActorEmailVerified(plain, strict)).not.toThrow();
|
||||
expect(() => assertActorEmailVerified(unverified, strict)).toThrow(
|
||||
expect.objectContaining({
|
||||
statusCode: 400,
|
||||
legacyCode: 'account_is_not_verified',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actorOwnsRow', () => {
|
||||
it('grants the owning user acting as themselves', () => {
|
||||
expect(actorOwnsRow(plain, { ownerUserId: 7, appOwnerId: 3 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(actorOwnsRow(plain, { ownerUserId: 8, appOwnerId: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('grants an app actor only the owner’s rows that app created', () => {
|
||||
expect(actorOwnsRow(asApp(3), { ownerUserId: 7, appOwnerId: 3 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(actorOwnsRow(asApp(4), { ownerUserId: 7, appOwnerId: 3 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(actorOwnsRow(asApp(3), { ownerUserId: 8, appOwnerId: 3 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 796 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user