mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
261
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a290dd446f | ||
|
|
1e280601c6 | ||
|
|
2262975785 | ||
|
|
e6e6e3ba9a | ||
|
|
e85cd9d53d | ||
|
|
828b5d9430 | ||
|
|
3b1ba72ddd | ||
|
|
4245ac04ac | ||
|
|
b8559c1221 | ||
|
|
6c4fa629a9 | ||
|
|
e6531d4357 | ||
|
|
0c27571030 | ||
|
|
6e6d9d2869 | ||
|
|
34529b2e79 | ||
|
|
7e4ac6542e | ||
|
|
3331dbd464 | ||
|
|
391b175a68 | ||
|
|
19f35b4200 | ||
|
|
9877bb79d0 | ||
|
|
2982e894b1 | ||
|
|
10bdf701e0 | ||
|
|
e764a10ee4 | ||
|
|
7e461e14a2 | ||
|
|
48cc706ad3 | ||
|
|
20ea616b59 | ||
|
|
73a7a38ea8 | ||
|
|
5c3fab5579 | ||
|
|
695862c61f | ||
|
|
bbc86e7b70 | ||
|
|
e35b57068b | ||
|
|
5faed55076 | ||
|
|
928d5fec16 | ||
|
|
ebcc4bb597 | ||
|
|
51541d3739 | ||
|
|
43b9200205 | ||
|
|
d5e9417d2d | ||
|
|
dec205d959 | ||
|
|
d87f3464fc | ||
|
|
25410cac92 | ||
|
|
66a1baef24 | ||
|
|
5c4df9cbd7 | ||
|
|
f89b3a888a | ||
|
|
bf545dda25 | ||
|
|
c706b4472d | ||
|
|
93b8041c3d | ||
|
|
89f9f9728f | ||
|
|
645409eddb | ||
|
|
58a763bcd1 | ||
|
|
fd91d72654 | ||
|
|
7fbf491e6f | ||
|
|
a8833de9d5 | ||
|
|
3a8b6394de | ||
|
|
f5d656650f | ||
|
|
dd314da16d | ||
|
|
47c1d4268f | ||
|
|
b9e87935cc | ||
|
|
73dd95bd41 | ||
|
|
c5bc358ef2 | ||
|
|
57c9eb9e1a | ||
|
|
a0c0544928 | ||
|
|
6dcae81a56 | ||
|
|
09baee6381 | ||
|
|
68aab718a1 | ||
|
|
5d6b2f3265 | ||
|
|
28730010c3 | ||
|
|
817ece40ba | ||
|
|
7c868e947a | ||
|
|
66984cd3d6 | ||
|
|
5fa508ba91 | ||
|
|
c1fca467bf | ||
|
|
e726c19311 | ||
|
|
6643642aa7 | ||
|
|
65dc3b5f4c | ||
|
|
c225fabbd4 | ||
|
|
fdcc2e301b | ||
|
|
183ab0780d | ||
|
|
81c69f243b | ||
|
|
6a3ecaff2a | ||
|
|
70f343d6fe | ||
|
|
172aa01e1c | ||
|
|
cba6ac8184 | ||
|
|
492f3d235a | ||
|
|
d9a01a3748 | ||
|
|
aab21ef91d | ||
|
|
2479e6a064 | ||
|
|
7cba26bb39 | ||
|
|
af0dc06f49 | ||
|
|
14900514b9 | ||
|
|
7faaef8cf0 | ||
|
|
c188257f85 | ||
|
|
4543a645f2 | ||
|
|
75d3b07e0f | ||
|
|
9da32c73cb | ||
|
|
102cef523f | ||
|
|
0e1be72f92 | ||
|
|
ef1afb9cc1 | ||
|
|
0462ddd6f5 | ||
|
|
8d7e70cde4 | ||
|
|
52e481128f | ||
|
|
c237e2433f | ||
|
|
a4bb57bb79 | ||
|
|
861cf5efb8 | ||
|
|
cf34f9fea9 | ||
|
|
92a6d30e26 | ||
|
|
23e8705b03 | ||
|
|
3240a4670a | ||
|
|
3bf83d40e9 | ||
|
|
c3368f553e | ||
|
|
27def94d8b | ||
|
|
2c139929ac | ||
|
|
b957f797da | ||
|
|
393500d565 | ||
|
|
a108837e82 | ||
|
|
859e88753f | ||
|
|
5160b44c4b | ||
|
|
17a9485fb2 | ||
|
|
d3c44f3d6c | ||
|
|
d49ba5281d | ||
|
|
c107c80bd8 | ||
|
|
cf90e775bf | ||
|
|
0a46096c41 | ||
|
|
289a44ae79 | ||
|
|
827e9bc343 | ||
|
|
f2ffaeb823 | ||
|
|
6f594f4b60 | ||
|
|
326be042f1 | ||
|
|
5b824fb745 | ||
|
|
cf6cb126b5 | ||
|
|
302df1edd2 | ||
|
|
e212509ed3 | ||
|
|
d3e3755d6d | ||
|
|
948ce6e529 | ||
|
|
d117dc231e | ||
|
|
eefa53e5bd | ||
|
|
db89fbfc52 | ||
|
|
97ec5d115c | ||
|
|
722a6cf50f | ||
|
|
d039dcfee5 | ||
|
|
d09aa11f2e | ||
|
|
8a2264546d | ||
|
|
0801e1dc44 | ||
|
|
a04d5fa5e0 | ||
|
|
e1280e8cc7 | ||
|
|
70884c2e30 | ||
|
|
917e95e3d7 | ||
|
|
27222bbb0d | ||
|
|
3720034464 | ||
|
|
de6cab1f9a | ||
|
|
2d1fbc2611 | ||
|
|
0afc70218f | ||
|
|
8d3abbc5f0 | ||
|
|
7ec14bc7db | ||
|
|
2843010a97 | ||
|
|
ad4e21ee61 | ||
|
|
4de39ab6f2 | ||
|
|
fbb646b8eb | ||
|
|
46f75ecd6f | ||
|
|
f68caa3bbd | ||
|
|
0109bf5b72 | ||
|
|
c69fb52d5c | ||
|
|
3a26f1f1c2 | ||
|
|
f1b94a5b69 | ||
|
|
e3fce9191a | ||
|
|
2c0a1b9bbb | ||
|
|
0d7417f858 | ||
|
|
2eee098c66 | ||
|
|
890d4b3c16 | ||
|
|
af9a7c6345 | ||
|
|
6fff1fd75f | ||
|
|
e6b7b08bda | ||
|
|
f4c3cbd53d | ||
|
|
c0c2f35266 | ||
|
|
ccea980753 | ||
|
|
2bddebd576 | ||
|
|
b9866ddbeb | ||
|
|
6fc79a7184 | ||
|
|
50039571b6 | ||
|
|
1216f20567 | ||
|
|
0752ea761c | ||
|
|
98461088f8 | ||
|
|
d4a3224aa3 | ||
|
|
197ce6faff | ||
|
|
c98e293879 | ||
|
|
ebbe382596 | ||
|
|
01d28eb2c5 | ||
|
|
7d1c2e7f0a | ||
|
|
22b499af20 | ||
|
|
7208f86723 | ||
|
|
2974a8b01c | ||
|
|
7f05a707bd | ||
|
|
1d056253ef | ||
|
|
a22dc5facb | ||
|
|
aeafd4ac79 | ||
|
|
14861df6b2 | ||
|
|
34a7595a6d | ||
|
|
b2108c0216 | ||
|
|
5a5a449210 | ||
|
|
0405de30bd | ||
|
|
40c2799b06 | ||
|
|
4bcdeefe68 | ||
|
|
b6ba6dd90e | ||
|
|
954f8b7b8d | ||
|
|
24d700673c | ||
|
|
c6d64029e0 | ||
|
|
40ff866598 | ||
|
|
36888e35d3 | ||
|
|
30432b931a | ||
|
|
e2174a14cf | ||
|
|
d3cb89d8c9 | ||
|
|
c8625b3396 | ||
|
|
b576f01d12 | ||
|
|
a87cafee3c | ||
|
|
1c51fde163 | ||
|
|
b05699db77 | ||
|
|
163e48ab27 | ||
|
|
0b41d992a4 | ||
|
|
c2ddfd6867 | ||
|
|
81b9dc1f06 | ||
|
|
5506ddb2c2 | ||
|
|
c74469b01a | ||
|
|
b706693f82 | ||
|
|
cd800148d5 | ||
|
|
900b0d7aa6 | ||
|
|
7e21c1f888 | ||
|
|
00d6f9c230 | ||
|
|
4c03080efa | ||
|
|
432a11d289 | ||
|
|
e9e233404b | ||
|
|
8101d42604 | ||
|
|
88cedd43bc | ||
|
|
d3a2934d9b | ||
|
|
13a36b6199 | ||
|
|
a155464d46 | ||
|
|
40d1c998bb | ||
|
|
b4837d49b6 | ||
|
|
e23042b42e | ||
|
|
240a733285 | ||
|
|
bb202be12c | ||
|
|
8c4cb601c9 | ||
|
|
64a7c7b7c4 | ||
|
|
2dd2887ff7 | ||
|
|
4bbe8a2789 | ||
|
|
3e9ceb673b | ||
|
|
9c4d1ef535 | ||
|
|
bf57518262 | ||
|
|
d48fb11de6 | ||
|
|
7ed87f760b | ||
|
|
ee37be16f0 | ||
|
|
a8cfaeefbd | ||
|
|
58cb8ee048 | ||
|
|
a72fbd1d47 | ||
|
|
f6408db74e | ||
|
|
df20993d8b | ||
|
|
708fe1bc4e | ||
|
|
e782fa832e | ||
|
|
c947c2c989 | ||
|
|
3de4613b7d | ||
|
|
def7290c48 | ||
|
|
1335b46404 | ||
|
|
0e15ac98a9 | ||
|
|
f127b2509d |
No files matched your search
@@ -1,8 +1,19 @@
|
||||
name: Backend Tests
|
||||
|
||||
# Runs on backend and extension changes. Tests are co-located with the
|
||||
# code (src/backend/**/*.test.ts, extensions/**/*.test.ts), so test-only
|
||||
# changes match the same globs. puter.js SDK changes are covered by
|
||||
# puterjs-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/backend/**'
|
||||
- 'extensions/**'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/backend-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Docs Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/docs/**'
|
||||
|
||||
jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build docs
|
||||
run: npm run build
|
||||
working-directory: src/docs
|
||||
@@ -0,0 +1,145 @@
|
||||
name: Puter.js API Tests
|
||||
|
||||
# Runs on puter.js SDK changes — the suites live in src/puter-js/tests,
|
||||
# so test changes are covered by the same glob — plus the pieces the
|
||||
# runners depend on: the worker preamble, the in-memory test env in
|
||||
# testUtil, and the backend vitest config the API-tests config extends.
|
||||
# Backend and extension changes run backend-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/puter-js/**'
|
||||
- 'src/worker/**'
|
||||
- 'src/backend/testUtil.ts'
|
||||
- 'src/backend/vitest.config.ts'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/puterjs-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# The runners exercise the built artifacts: the SDK bundle
|
||||
# (src/puter-js/dist) for node + browser, and the worker preamble
|
||||
# (src/worker/dist) for workerd.
|
||||
- name: Build puter.js SDK and worker preamble
|
||||
run: npm run build:workerLib
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run puter.js API tests (node, browser, workerd)
|
||||
run: npm run test:puterjs
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
# SDK coverage on both refs, reported as a PR comment — same shape as
|
||||
# backend-tests.yaml. This run uses the istanbul-instrumented bundle
|
||||
# (the `test` job above keeps exercising the production build).
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- ref: ${{ github.base_ref }}
|
||||
artifact: base
|
||||
- ref: ${{ github.head_ref }}
|
||||
artifact: pr
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# The base leg is best-effort — it only enriches the PR comment with
|
||||
# a comparison, and the base ref may predate the coverage script.
|
||||
- name: Run puter.js API tests with coverage
|
||||
continue-on-error: ${{ matrix.artifact == 'base' }}
|
||||
run: npm run test:puterjs:coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-${{ matrix.artifact }}
|
||||
path: src/puter-js/coverage/coverage-*.json
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
report-coverage:
|
||||
needs: coverage
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
path: src/puter-js/coverage
|
||||
|
||||
# May not exist (base ref without the coverage script, or a failed
|
||||
# base run) — then report without the comparison column.
|
||||
- name: Download base coverage
|
||||
id: base-coverage
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
- name: Report coverage on PR
|
||||
if: steps.base-coverage.outcome == 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
json-final-path: src/puter-js/coverage/coverage-final.json
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
|
||||
- name: Report coverage on PR (no base comparison)
|
||||
if: steps.base-coverage.outcome != 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
json-final-path: src/puter-js/coverage/coverage-final.json
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"plugins": ["prettier-plugin-jsdoc"]
|
||||
}
|
||||
@@ -8,6 +8,8 @@ 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/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/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.
|
||||
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
|
||||
@@ -16,70 +18,21 @@ Use these as the source of truth before exploring further:
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
## Repo-wide conventions
|
||||
|
||||
The backend is organized as a layered stack inspired by Controller–Service–Repository with dependency injection. Every layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together.
|
||||
These apply everywhere — backend, puter.js, and GUI.
|
||||
|
||||
### Layers (top → bottom)
|
||||
### Language & files
|
||||
|
||||
| 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 via `RouteOptions`), call services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional. RPC-style handlers exposed over `/drivers/*`. Thin shells that validate RPC inputs and call into services/stores. |
|
||||
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
|
||||
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
|
||||
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamo, email, event bus). Knows protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
|
||||
|
||||
Each layer receives the layers beneath it through its constructor. Dependencies are explicit and traceable from `PuterServer`.
|
||||
|
||||
### Cross-layer rules
|
||||
|
||||
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you want to, the abstraction is wrong — fix the abstraction.
|
||||
- **Don't call sideways within a layer for code reuse.** If two services need the same logic, lift it into a util/helper. Services should not depend on other services for shared code.
|
||||
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only when the value is genuinely request-scoped and would otherwise thread through many layers. Today it's used sparingly — mostly for `actor` and `req`.
|
||||
|
||||
### Extensions
|
||||
|
||||
Extensions live in [extensions/](extensions/) and parallel the layered stack. They are for **non-crucial parts of the system** — things Puter still works without if removed. If a feature is load-bearing for clients, it belongs in core, not in 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')` returns a lazy proxy to instantiated objects. `extension.config` exposes live config.
|
||||
|
||||
### Language & file conventions
|
||||
|
||||
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
|
||||
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
|
||||
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
|
||||
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
- 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 }`).
|
||||
|
||||
### Comments
|
||||
|
||||
Keep comments light. Prefer self-documenting code — clear names, small functions, obvious flow. Add a comment **only** when:
|
||||
|
||||
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
|
||||
- A non-trivial usage detail would otherwise trip the next reader.
|
||||
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR — those rot.
|
||||
|
||||
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the *why* in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
|
||||
|
||||
**Use plain ASCII `-` in comment section dividers, not box-drawing characters.** Write `// -- Section name --`, not `// ── Section name ──`. The `─` (U+2500) character renders inconsistently in some editors and diff tools and adds nothing over `-`.
|
||||
|
||||
### Tests
|
||||
|
||||
When adding new behavior (function, endpoint, driver method, branch of logic), add a test for it.
|
||||
|
||||
- Use Vitest for unit and integration tests. Test files go next to the code they test, named `*.test.ts` or `*.test.js`.
|
||||
- **Mock data, not methods.** Stub the inputs (fixtures, fake rows, sample payloads) rather than mocking the function under test or the layer beneath it. Over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service), not within the same layer you're testing.
|
||||
- Prefer test server over mocking deps. We have existing test utilities for running full test server to boot deps based on config. (see [src/backend/testUtil.ts](src/backend/testUtil.ts)).
|
||||
- **Hit a real database / real client where reasonable.** Integration shapes catch the things unit tests with mocks miss.
|
||||
- **Regression tests for bug fixes.** A test that fails before your fix and passes after is the cheapest insurance against the bug coming back.
|
||||
- If something is genuinely hard to test (UI animation, third-party glue), skip it but say so in the PR.
|
||||
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.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
@@ -94,6 +47,106 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
|
||||
### 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 genuinely think the existing pattern is wrong, raise it — don't quietly diverge.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo, dead import, or missing typehint in files you're already touching. Don't ride a refactor along with a bug fix — that just makes review harder.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
| 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 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.
|
||||
|
||||
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 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`.
|
||||
|
||||
### 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.
|
||||
|
||||
### Backend tests
|
||||
|
||||
- Vitest; test files sit next to the code they test (`*.test.ts` / `*.test.js`). Run with `npm run test:backend`.
|
||||
- **Mock data, not methods.** Stub inputs (fixtures, fake rows, payloads), not the function under test or the layer beneath it — over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service).
|
||||
- **Prefer the test server over mocking deps.** `setupPuterTestEnv()` in [src/backend/testUtil.ts](src/backend/testUtil.ts) boots a fully in-memory backend; hit a real database/client shape where reasonable — integration shapes catch what mocked unit tests miss.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `KV.js`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), hand-maintained type declarations in [src/puter-js/types/](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. Reference the hand-maintained declarations in [src/puter-js/types/](src/puter-js/types/) with `import(...)` types rather than re-declaring shapes:
|
||||
|
||||
```js
|
||||
/** @typedef {import('../../types/modules/ai').ChatOptions} ChatOptions */
|
||||
|
||||
/** @type {ChatOptions} */
|
||||
const options = { model: 'gpt-5-nano' };
|
||||
```
|
||||
|
||||
When a shape only exists locally, declare it as an inline object literal — not the `@typedef {Object}` + `@property` list form — and use `unknown` over `*`:
|
||||
|
||||
```js
|
||||
/** @typedef {{ key: string, value: unknown }} KVEntry */
|
||||
```
|
||||
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value, matching the `.d.ts` declarations exactly. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean.
|
||||
|
||||
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:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @typedef {{key:string, value: unknown}} KVEntry
|
||||
*/
|
||||
|
||||
/** @type {KVEntry[]} */
|
||||
let entries = [];
|
||||
```
|
||||
|
||||
Every SDK change carries all five of the following — a puter.js PR missing one is incomplete:
|
||||
|
||||
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.** Update [src/puter-js/types/modules/](src/puter-js/types/modules/)`<module>.d.ts` and re-export new types through `index.d.ts` / `types/puter.d.ts`. 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.
|
||||
|
||||
[doc/contributing-apis.md](doc/contributing-apis.md) walks the full lifecycle of adding an API across backend + SDK.
|
||||
|
||||
---
|
||||
|
||||
## GUI
|
||||
|
||||
[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.
|
||||
|
||||
- **Reuse existing UI primitives before writing new ones.** Windows and dialogs are `UIWindow*` functions in [src/gui/src/UI/](src/gui/src/UI/); generic pieces already exist (`UIAlert`, `UIPrompt`, `UIContextMenu`, `UINotification`, `UIPopover`, widgets in [UI/Components/](src/gui/src/UI/Components/)). A new window should read like its neighbors: an async function taking an options object, composing an HTML string, wiring behavior with jQuery, delegating to `UIWindow(...)`.
|
||||
- **Match the visual language.** Use existing CSS classes (`button`, `button-primary`, window chrome, form styles) and copy the layout patterns of neighboring windows; new styles go in [src/gui/src/css/](src/gui/src/css/) following existing conventions. Verify anything positional (menus, overlays, z-index) on both desktop and mobile viewports.
|
||||
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js). Run `npm run check-translations` before opening the PR.
|
||||
- **Shared logic goes in helpers/services.** Reusable non-UI logic belongs in [src/gui/src/helpers/](src/gui/src/helpers/) or [src/gui/src/services/](src/gui/src/services/), not copy-pasted between windows.
|
||||
- **Tests.** Vitest is wired for the GUI (`src/**/*.test.js`; see [appOrder.test.js](src/gui/src/UI/Dashboard/appOrder.test.js) for the shape). Extract pure logic into functions and test those. Desktop behavior driven through puter.js (`puter.ui.*`) is covered by the Playwright harness in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/) — add a spec there when you change how the desktop renders SDK-driven UI.
|
||||
+4
-2
@@ -16,6 +16,8 @@ Add tests for new behavior, endpoints, or bug fixes. If something's genuinely ha
|
||||
|
||||
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.
|
||||
@@ -26,9 +28,9 @@ For private security reports, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
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. Update docs for API changes
|
||||
## 5. Adding or changing APIs
|
||||
|
||||
If you change puter-js APIs (drivers or endpoints used by puter-js), update [developer docs](src/docs/). puter-js itself may also need updating.
|
||||
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.
|
||||
|
||||
## 6. Boy Scout Rule — leave it 1% better
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"disable_user_signup": false,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
|
||||
// dev-time webpack watcher; `prod` serves pre-built bundles.
|
||||
"env": "dev",
|
||||
// Console output format. `json` replaces the global console so every call
|
||||
// emits one structured JSON line (level, timestamp, msg, and the active
|
||||
// request's trace id) — one event per call, so a line-oriented log
|
||||
// collector can't split stack traces across events and level filtering
|
||||
// works. Unset (the default) leaves console output human-readable.
|
||||
"log_format": "text",
|
||||
"version": "0.0.0",
|
||||
// Stable identity for this server node — used by pager alerts and
|
||||
// graceful-shutdown coordination.
|
||||
@@ -75,6 +81,9 @@
|
||||
"url_signature_secret": "change-me",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"min_pass_length": 6,
|
||||
// When true, anonymous users must log in instead of creating temp or
|
||||
// permanent accounts.
|
||||
"disable_user_signup": false,
|
||||
"allow_system_login": false,
|
||||
"strict_email_verification_required": false,
|
||||
"captcha": {
|
||||
@@ -260,6 +269,10 @@
|
||||
// ─ Chat / completion ─
|
||||
"claude": { "apiKey": "" },
|
||||
"openai-completion": { "apiKey": "" },
|
||||
"azure-openai": {
|
||||
"apiKey": "",
|
||||
"apiURL": ""
|
||||
},
|
||||
"gemini": { "apiKey": "" },
|
||||
"groq": { "apiKey": "" },
|
||||
"deepseek": { "apiKey": "" },
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Contributing APIs
|
||||
|
||||
How to add a new public API to Puter, and how to maintain one that already exists. "Public API" means anything applications can call: HTTP endpoints, `/drivers/*` methods, and the puter.js methods that wrap them. This guide is written for human contributors and AI agents alike — [AGENTS.md](../AGENTS.md) defers to it for API work.
|
||||
|
||||
Companion docs: [architecture.md](architecture.md) (backend layering), [pagination.md](pagination.md) (list APIs), [src/puter-js/tests/api/README.md](../src/puter-js/tests/api/README.md) (SDK test environment).
|
||||
|
||||
## Rule zero: don't break callers
|
||||
|
||||
puter.js is served live from `https://js.puter.com/v2/` with no version pinning — every app in existence picks up your change the moment it deploys, and the backend endpoints underneath have the same property. Assume every observable behavior (parameter handling, response fields, error codes, ordering) has someone depending on it.
|
||||
|
||||
Every change is backward compatible unless a maintainer has explicitly agreed to a break beforehand.
|
||||
|
||||
## 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".
|
||||
|
||||
## Adding a new API
|
||||
|
||||
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.)
|
||||
- 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.
|
||||
|
||||
#### Controller or driver?
|
||||
|
||||
Both are supported ways to define an API. **Prefer a controller when you need fine-grained control** — URL shape, HTTP verbs, per-route gates, response and streaming formats. A **driver** fits when the API is a set of RPC methods implementing one of the named interfaces on `/drivers/*` (`puter-kvstore`, `puter-chat-completion`, …) and the generic driver plumbing — one call envelope, interchangeable implementations, per-method policies — covers what you need.
|
||||
|
||||
- **Controller:** extend `PuterController` ([src/backend/controllers/types.ts](../src/backend/controllers/types.ts)) and declare routes with the `@Controller(prefix)` class decorator plus `@Get`/`@Post`/… method decorators ([src/backend/core/http/decorators.ts](../src/backend/core/http/decorators.ts)), each taking `(path, routeOptions)` — or override `registerRoutes(router)` imperatively. Core controllers register in [src/backend/controllers/index.ts](../src/backend/controllers/index.ts); extensions use `extension.registerController(...)` or the plain route helpers.
|
||||
- **Driver:** extend `PuterDriver` ([src/backend/drivers/types.ts](../src/backend/drivers/types.ts)) and mark it with the `@Driver(interfaceName, opts)` decorator ([src/backend/drivers/decorators.ts](../src/backend/drivers/decorators.ts)), which also declares its policies. Core drivers register in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts); extensions use `extension.registerDriver(...)`.
|
||||
|
||||
#### Middleware and gates
|
||||
|
||||
- **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`), and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
- Add the method to the matching module in [src/puter-js/src/modules/](../src/puter-js/src/modules/), matching the calling conventions of its siblings (promise-returning; positional shortcut plus options form where that's the local pattern).
|
||||
- Validate cheap preconditions client-side and throw `{ message, code }` objects; pass backend errors through unchanged rather than swallowing or re-wrapping them.
|
||||
|
||||
### 4. Types
|
||||
|
||||
- Add or extend the declaration in [src/puter-js/types/modules/](../src/puter-js/types/modules/)`<module>.d.ts` and re-export new types through `index.d.ts`. Declarations must match the runtime exactly — optionality, defaults, and return types included.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
|
||||
## Maintaining an existing API
|
||||
|
||||
Changes are **additive by default**:
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
Rare and deliberate. In order: explicit maintainer sign-off, a documented migration path, and a rollout plan — typically new surface added first, old surface deprecated, old surface removed much later, if ever. Never break as a side effect of a refactor.
|
||||
|
||||
### Deprecating
|
||||
|
||||
The old surface keeps working. Mark it `@deprecated` in the type declarations, note the replacement on its docs page, and stop using it in examples. Removal is a separate, maintainer-approved decision.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] Backward compatible (or the break was explicitly approved)
|
||||
- [ ] Right home: extension if core never calls it; layered structure either way
|
||||
- [ ] puter.js method matches sibling conventions; errors are `{ message, code }` with stable codes
|
||||
- [ ] Types updated and matching runtime behavior
|
||||
- [ ] Docs page + area overview updated, with a runnable example
|
||||
- [ ] Tests: backend + three-platform SDK suite (+ e2e for desktop-rendered UI)
|
||||
- [ ] Security pass on the diff
|
||||
- [ ] You ran it end to end
|
||||
@@ -104,7 +104,7 @@ Puterは[**puter.com**](https://puter.com)でホストサービスとして利
|
||||
|
||||
メンテナーやコミュニティと以下のチャンネルを通じてつながりましょう:
|
||||
|
||||
- バグ報告や機能リクエストがありますか? [issueを開く](https://github.com/HeyPuter/puter/issues/new/choose) してください。
|
||||
- バグ報告や機能リクエストは [新規Issue](https://github.com/HeyPuter/puter/issues/new/choose) まで
|
||||
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
|
||||
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Pagination convention for list APIs
|
||||
|
||||
Every list endpoint follows one wire contract. This document is the source of
|
||||
truth for adding pagination to a new or existing list surface.
|
||||
|
||||
## Wire contract
|
||||
|
||||
Requests accept:
|
||||
|
||||
| Param | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `limit` | number | Maximum items per page. Each endpoint documents its cap and default. |
|
||||
| `cursor` | string \| null | Opaque continuation token. `null` (or any presence of the key) requests the first page. |
|
||||
| `offset` | number | Skip N items. Legacy/discouraged — see below. Cannot be combined with `cursor`. |
|
||||
| `includeTotal` | boolean | Adds `total` to the response. |
|
||||
|
||||
Paginated responses are an envelope:
|
||||
|
||||
```json
|
||||
{ "items": [...], "cursor": "…", "total": 123 }
|
||||
```
|
||||
|
||||
- `cursor` is present only while more pages exist. Clients iterate until it
|
||||
is absent.
|
||||
- `total` is present only when the request set `includeTotal`.
|
||||
- **Pages may be short.** Post-query filtering (TTL expiry, permission
|
||||
checks) can shrink a page below `limit` — or even to zero — while a
|
||||
`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
|
||||
legacy shape (bare array) forever — old clients never break.
|
||||
|
||||
The envelope trigger depends on the endpoint's history:
|
||||
|
||||
- Endpoints where `limit` pre-dates the convention (readdir, subdomain
|
||||
`select`) return the envelope only when the request contains `cursor`
|
||||
(including `null`) or `includeTotal`; `limit`/`offset`-only requests keep
|
||||
the bare array.
|
||||
- Endpoints where every pagination param is new (kv `list`, workers
|
||||
`getFilePaths`) return the envelope when any pagination param is present.
|
||||
|
||||
## 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:
|
||||
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
|
||||
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
|
||||
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
|
||||
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
|
||||
ending in a unique tiebreaker (`id`).
|
||||
|
||||
Cursors that carry a sort also pin it: a request that passes a cursor plus a
|
||||
conflicting sort is rejected with 400.
|
||||
|
||||
## Offset
|
||||
|
||||
SQL-backed endpoints support `offset` natively. DynamoDB-backed endpoints
|
||||
(kv) emulate it by advancing past skipped items with `Select: COUNT` queries
|
||||
— no item data is transferred, but read capacity is still consumed for
|
||||
everything skipped, and the caller is metered for it. **Offset is supported
|
||||
for parity, not recommended** — cost grows linearly with the offset, so it is
|
||||
capped (kv: 5000). Use cursors.
|
||||
|
||||
## Totals (`includeTotal`)
|
||||
|
||||
- SQL: `SELECT COUNT(*)` with the same WHERE clause as the listing.
|
||||
- DynamoDB: a `Select: COUNT` loop over the query (TTL-filtered), metered to
|
||||
the caller. Cost is proportional to the total item count — request the
|
||||
total on the first page only, not every page.
|
||||
- Where visibility is decided per-actor after the query (protected apps in
|
||||
the catalog listing), `total` approximates the visible set: it counts
|
||||
non-protected plus caller-owned rows, and misses rows visible only through
|
||||
explicit permission grants.
|
||||
|
||||
## fetchUntilFull (kv only)
|
||||
|
||||
DynamoDB applies `Limit` before its filter expression, so TTL-filtered pages
|
||||
are structurally short. `fetchUntilFull: true` makes the backend keep
|
||||
fetching (bounded number of continuation queries) until the page holds
|
||||
`limit` items or the keyset is exhausted. Requires `limit`. If the bound is
|
||||
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`.
|
||||
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).
|
||||
3. Fetch `limit + 1` rows to detect whether a next page exists (SQL), or use
|
||||
`LastEvaluatedKey` (DynamoDB).
|
||||
4. Keep the no-params request returning the legacy full result.
|
||||
+36
-1
@@ -135,7 +135,7 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
|
||||
Why these knobs:
|
||||
|
||||
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
|
||||
@@ -199,6 +199,32 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
|
||||
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
|
||||
```
|
||||
|
||||
## Running behind your own reverse proxy
|
||||
|
||||
The bundled `puter-nginx` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Caddy, Traefik, HAProxy, a cloud load balancer, another nginx). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right.
|
||||
|
||||
You can keep the bundled nginx as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below.
|
||||
|
||||
**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit):
|
||||
|
||||
1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.<domain>`, `site.<domain>`, `app.<domain>` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it.
|
||||
|
||||
2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`.
|
||||
|
||||
3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors.
|
||||
|
||||
4. **Forward the standard proxy headers.** Puter needs:
|
||||
- `Host` — the original request host (rule 1).
|
||||
- `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream.
|
||||
- `X-Forwarded-For` — the real client IP (rate limiting + audit logs).
|
||||
- `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails.
|
||||
|
||||
5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled nginx if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable).
|
||||
|
||||
6. **Route the wildcard to Puter.** Your proxy must forward `*.<domain>` (covering `api.*`, `app.*`, and `s3.<domain>`) and `*.site.<domain>` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact.
|
||||
|
||||
With those in place the rest of the stack is unchanged — `docker compose up -d` as below.
|
||||
|
||||
## Step 4 — Bring it up
|
||||
|
||||
```bash
|
||||
@@ -353,6 +379,15 @@ Built-in proof-of-work captcha — no external service needed.
|
||||
|
||||
`difficulty` is one of `easy` / `medium` / `hard`.
|
||||
|
||||
### Disable new signups
|
||||
|
||||
Force visitors to log in with an existing account instead of creating a
|
||||
temporary or permanent one.
|
||||
|
||||
```json
|
||||
"disable_user_signup": true
|
||||
```
|
||||
|
||||
### Block disposable email TLDs
|
||||
|
||||
Only enforced when `env: "prod"`.
|
||||
|
||||
+1
-1
@@ -264,7 +264,7 @@ services:
|
||||
# Persistent runtime data (anything your config points at /var/puter).
|
||||
- ./puter/data/puter:/var/puter:z
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
test: wget --no-verbose --tries=1 --spider http://localhost:4100/ || exit 1
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
+145
-141
@@ -1,75 +1,92 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import {
|
||||
handleAppTelemetryUserCount,
|
||||
handleAppTelemetryUsers,
|
||||
} from './appTelemetry.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||||
({ query }) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
// `handleAppTelemetryUsers` calls `Context.get('actor')` for the permission
|
||||
// gate. Wrap test calls in `runWithContext` so the ALS lookup resolves.
|
||||
const callWithActor = async (
|
||||
actor: { user: { uuid: string; id?: number }; app?: null } | undefined,
|
||||
fn: () => Promise<void>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
// Importing the module registers the `appTelemetry` driver into the shared
|
||||
// extensionStore, so `setupTestServer` instantiates it on `server.drivers`.
|
||||
import { AppTelemetryDriver } from './appTelemetry.ts';
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: AppTelemetryDriver;
|
||||
|
||||
// `get_users` reads `Context.get('actor')` for the ownership gate. Wrap
|
||||
// actor-dependent calls in `runWithContext` so the ALS lookup resolves.
|
||||
const callWithActor = <T>(
|
||||
actor: { user: { uuid: string; id?: number } } | undefined,
|
||||
fn: () => Promise<T>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
driver = (server.drivers as unknown as Record<string, AppTelemetryDriver>)
|
||||
.appTelemetry;
|
||||
// Guard: the driver must have been wired onto the server. If this is
|
||||
// undefined the extension didn't register, and every test below would
|
||||
// fail with a confusing "cannot read property of undefined".
|
||||
expect(driver).toBeInstanceOf(AppTelemetryDriver);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
const seedOwnedApp = async (prefix: string) => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `${prefix}_${slug}`,
|
||||
title: `${prefix} ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
return { owner, app: app! };
|
||||
};
|
||||
|
||||
const seedUser = async (prefix: string, email: string | null = null) =>
|
||||
server.stores.user.create({
|
||||
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email,
|
||||
});
|
||||
|
||||
const grantAuthenticated = async (appId: number, userId: number) => {
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||||
[userId, appId, 'flag:app-is-authenticated', null],
|
||||
);
|
||||
};
|
||||
|
||||
const grantEmailRead = async (
|
||||
appId: number,
|
||||
userId: number,
|
||||
userUuid: string,
|
||||
) => {
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||||
[userId, appId, `user:${userUuid}:email:read`, null],
|
||||
);
|
||||
};
|
||||
|
||||
describe('appTelemetry driver — get_users', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(makeReq({}), res),
|
||||
).rejects.toMatchObject({
|
||||
await expect(driver.get_users({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('app_uuid'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(400) for a non-numeric limit', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-anything', limit: 'banana' }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-anything', limit: 'banana' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('limit'),
|
||||
@@ -77,22 +94,14 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when offset exceeds the allowed maximum', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-anything', offset: 1_000_001 }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-anything', offset: 1_000_001 }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws HttpError(404) when the app cannot be found', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-does-not-exist' }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-does-not-exist' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
message: 'App not found',
|
||||
@@ -100,25 +109,10 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('throws HttpError(403) when the caller does not own the app', async () => {
|
||||
// Seed an owner user + an app belonging to them, then call the
|
||||
// handler as a *different* user. The real permission service should
|
||||
// reject since the caller has no `apps-of-user:<owner>:write` perm.
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `app_${slug}`,
|
||||
title: `App ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
// Seed an owner + their app, then call as a *different* user. The
|
||||
// real permission service should reject: the caller has no
|
||||
// `apps-of-user:<owner>:write`.
|
||||
const { app } = await seedOwnedApp('owner');
|
||||
const intruder = await server.stores.user.create({
|
||||
username: `intruder_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
@@ -126,15 +120,11 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
email: null,
|
||||
});
|
||||
|
||||
const { res } = makeRes();
|
||||
await callWithActor(
|
||||
{ user: { uuid: intruder.uuid, id: intruder.id as number } },
|
||||
async () => {
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: app.uid }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
message: 'Permission denied',
|
||||
@@ -144,78 +134,92 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('returns an empty list for an owned app with no authenticated users', async () => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner2_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `app2_${slug}`,
|
||||
title: `App2 ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
const { owner, app } = await seedOwnedApp('owner2');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callWithActor(
|
||||
const result = await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
async () => {
|
||||
await handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
);
|
||||
},
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits user_email for a user who did not grant email:read', async () => {
|
||||
const { owner, app } = await seedOwnedApp('noemail');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(row.user).toBe(member.username);
|
||||
expect(row.user_uuid).toBe(member.uuid);
|
||||
// No grant → the field must be absent (not just null), so the email
|
||||
// never leaks to the app owner.
|
||||
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns user_email when the user granted email:read to the app', async () => {
|
||||
const { owner, app } = await seedOwnedApp('withemail');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
await grantEmailRead(
|
||||
app.id as number,
|
||||
member.id as number,
|
||||
member.uuid,
|
||||
);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(row.user_uuid).toBe(member.uuid);
|
||||
expect(row.user_email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('does not leak email granted to a *different* app', async () => {
|
||||
const { owner, app } = await seedOwnedApp('appA');
|
||||
const { app: otherApp } = await seedOwnedApp('appB');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
// Grant email:read against the OTHER app only.
|
||||
await grantEmailRead(
|
||||
otherApp.id as number,
|
||||
member.id as number,
|
||||
member.uuid,
|
||||
);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appTelemetry extension — handleAppTelemetryUserCount', () => {
|
||||
describe('appTelemetry driver — user_count', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUserCount(makeReq({}), res),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(driver.user_count({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(404) for an unknown app_uuid', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUserCount(
|
||||
makeReq({ app_uuid: 'app-not-here' }),
|
||||
res,
|
||||
),
|
||||
driver.user_count({ app_uuid: 'app-not-here' }),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('returns { count: 0 } for an app with no authenticated users', async () => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `counto_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `appcount_${slug}`,
|
||||
title: `AppCount ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await handleAppTelemetryUserCount(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual({ count: 0 });
|
||||
it('returns 0 for an app with no authenticated users', async () => {
|
||||
const { app } = await seedOwnedApp('appcount');
|
||||
await expect(driver.user_count({ app_uuid: app.uid })).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
+119
-80
@@ -1,11 +1,16 @@
|
||||
import type { Request, Response } from 'express';
|
||||
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 { extension } from '@heyputer/backend/src/extensions';
|
||||
|
||||
const clients = extension.import('client');
|
||||
const stores = extension.import('store');
|
||||
const services = extension.import('service');
|
||||
// App-telemetry lets an app owner enumerate the users who have
|
||||
// authenticated into their app. v1 shipped this as a driver on the
|
||||
// `app-telemetry` interface (methods `get_users` / `user_count`) and
|
||||
// puter-js's `puter.apps(...).getUsers()` still calls it that way
|
||||
// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`).
|
||||
// This is the v2 port of that driver — same interface/method/return shapes
|
||||
// so existing puter-js callers work unchanged.
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 1000;
|
||||
@@ -41,91 +46,125 @@ const parseIntParam = (
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const handleAppTelemetryUsers = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
/**
|
||||
* Driver exposing the `app-telemetry` interface.
|
||||
*
|
||||
* The `/drivers/call` permission gate checks
|
||||
* `service:app-telemetry:ii:app-telemetry`, which every actor already holds
|
||||
* via the blanket `service` grant (hardcoded-permissions.js +
|
||||
* `default_implicit_user_app_permissions`). The real authorization — "is the
|
||||
* caller the app owner?" — is enforced inside `get_users` below, exactly as
|
||||
* v1 did.
|
||||
*/
|
||||
export class AppTelemetryDriver extends PuterDriver {
|
||||
readonly driverInterface = 'app-telemetry';
|
||||
readonly driverName = 'app-telemetry';
|
||||
readonly isDefault = true;
|
||||
|
||||
const safeLimit = parseIntParam(req.query.limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(req.query.offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
/** Users who have authenticated into the given app (owner-only). */
|
||||
async get_users({
|
||||
app_uuid,
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
app_uuid?: string;
|
||||
limit?: unknown;
|
||||
offset?: unknown;
|
||||
} = {}): Promise<
|
||||
Array<{ user: string; user_uuid: string; user_email?: string | null }>
|
||||
> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
const safeLimit = parseIntParam(limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
|
||||
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly. v1 got
|
||||
// this for free because its entity-storage layer eager-joined the
|
||||
// owner row; v2's AppStore.getByUid returns the raw row with only
|
||||
// `owner_user_id` populated.
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = (await stores.user.getById(ownerId)) as {
|
||||
uuid?: string;
|
||||
} | null;
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const ownsApp = await services.permission
|
||||
.check(actor!, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
// The `apps-of-user:<uuid>:write` implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly — the raw
|
||||
// app row only carries `owner_user_id`. (v1 got the owner for free
|
||||
// because its entity-storage layer eager-joined the owner row.)
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = await this.stores.user.getById(ownerId);
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
|
||||
const users = await clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
INNER JOIN ${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 ?`,
|
||||
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
|
||||
);
|
||||
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);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
res.json(
|
||||
(users as Array<{ username: string; uuid: string }>).map((e) => ({
|
||||
user: e.username,
|
||||
user_uuid: e.uuid,
|
||||
})),
|
||||
);
|
||||
};
|
||||
const appId = (app as { id: number }).id;
|
||||
|
||||
export const handleAppTelemetryUserCount = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
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;
|
||||
}>;
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
// 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);
|
||||
}
|
||||
|
||||
const [row] = (await clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as Record<string, unknown>).id],
|
||||
)) as Array<{ n: number }>;
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ count: row?.n ?? 0 });
|
||||
};
|
||||
/** Count of users who have authenticated into the given app. */
|
||||
async user_count({
|
||||
app_uuid,
|
||||
}: { app_uuid?: string } = {}): Promise<number> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/users',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
handleAppTelemetryUsers,
|
||||
);
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/user-count',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
handleAppTelemetryUserCount,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
extension.registerDriver('appTelemetry', AppTelemetryDriver);
|
||||
@@ -132,9 +132,41 @@ describe('installedApps extension — handleInstalledApps', () => {
|
||||
expect(list[0].uid).toBe(app!.uid);
|
||||
expect(list[0].name).toBe(app!.name);
|
||||
expect(list[0].title).toBe(app!.title);
|
||||
// index_url is required so the dashboard can derive a hostname title
|
||||
// for anonymous (app-…) apps.
|
||||
expect(list[0].index_url).toBe(app!.index_url);
|
||||
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
|
||||
true,
|
||||
);
|
||||
// An owned app is not external, and the raw owner id must not leak.
|
||||
expect(list[0].external).toBe(false);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('flags apps with no owner_user_id as external', async () => {
|
||||
const user = await seedUser();
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
// createFromOrigin bootstraps an app with owner_user_id = null.
|
||||
const app = await server.stores.app.createFromOrigin(
|
||||
`app-${slug}`,
|
||||
`https://external-${slug}.example.com`,
|
||||
);
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].external).toBe(true);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
|
||||
|
||||
@@ -49,6 +49,8 @@ export const handleInstalledApps = async (
|
||||
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
|
||||
@@ -62,15 +64,23 @@ export const handleInstalledApps = async (
|
||||
|
||||
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
||||
res.json(
|
||||
installedApps.map((app) => ({
|
||||
...app,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
})),
|
||||
installedApps.map((app) => {
|
||||
// An app with no owner_user_id (null/empty) isn't owned by a Puter
|
||||
// user — it's an "external" app. Derive a flag and don't leak the
|
||||
// raw owner id to the client.
|
||||
const { owner_user_id, ...rest } = app;
|
||||
const external = owner_user_id == null || owner_user_id === '';
|
||||
return {
|
||||
...rest,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
external,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/installedApps',
|
||||
{ subdomain: 'api', requireUserActor: true },
|
||||
{ subdomain: 'api', requireUserActor: true, allowFullAccessToken: true },
|
||||
handleInstalledApps,
|
||||
);
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import {
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
driversContainers,
|
||||
} from '@heyputer/backend/src/exports';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const services = extension.import('service');
|
||||
const clients = extension.import('client');
|
||||
|
||||
@@ -107,6 +107,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.email).toBe(user.email);
|
||||
expect(body.is_temp).toBe(false);
|
||||
expect(body.oidc_only).toBe(false);
|
||||
// is_user_token is present (true) for user actors.
|
||||
expect(body.is_user_token).toBe(true);
|
||||
// `directories` is only sent to user actors — confirm it’s present.
|
||||
expect(body.directories).toBeDefined();
|
||||
// taskbar_items is only sent to user actors.
|
||||
@@ -146,6 +148,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
// is_user_token is stripped for app actors.
|
||||
expect(body.is_user_token).toBeUndefined();
|
||||
expect(body.desktop_bg_url).toBeUndefined();
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
@@ -154,6 +158,49 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.directories).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts tmp_password from metadata for user actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
hasDevAccountAccess: true,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Other metadata keys still reach the user's own client.
|
||||
expect(metadata.hasDevAccountAccess).toBe(true);
|
||||
expect(body.hasDevAccountAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('never sends user metadata to app actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.metadata).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||||
});
|
||||
|
||||
it('marks the user as oidc_only when password is null', async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const oidcUser = await server.stores.user.create({
|
||||
|
||||
+10
-1
@@ -65,6 +65,9 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
|
||||
if (metadata) delete metadata.tmp_password;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
@@ -72,10 +75,14 @@ export const handleWhoami = async (
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
phone: user.phone,
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
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,
|
||||
oidc_only: oidcOnly,
|
||||
taskbar_items: isUser
|
||||
? await getTaskbarItems(
|
||||
@@ -94,7 +101,7 @@ export const handleWhoami = async (
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
metadata: user.metadata,
|
||||
metadata,
|
||||
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
|
||||
};
|
||||
|
||||
@@ -157,6 +164,8 @@ export const handleWhoami = async (
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.is_user_token;
|
||||
delete details.metadata;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
|
||||
+22
-10
@@ -16,11 +16,20 @@
|
||||
# the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite.
|
||||
#
|
||||
# Tunable env vars:
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for nginx (default: 80)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for nginx (default: 80)
|
||||
# PUTER_PROTOCOL public scheme: http | https (default: http)
|
||||
# Set https when a TLS-terminating reverse proxy (Caddy,
|
||||
# Traefik, nginx, a cloud LB) sits in front — Puter then
|
||||
# builds https:// origins, S3 URLs, and redirects. See
|
||||
# "Running behind your own reverse proxy" in doc/self-hosting.md.
|
||||
# PUTER_TRUST_PROXY number of reverse-proxy hops in front (default: 1)
|
||||
# 1 = the bundled nginx (or a single external proxy);
|
||||
# 2 = two hops (e.g. Cloudflare → your proxy → Puter).
|
||||
# PUTER_ENV prod | dev (default: prod)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
|
||||
set -eu
|
||||
|
||||
@@ -28,6 +37,9 @@ PUTER_DIR="${PUTER_DIR:-puter-selfhosted}"
|
||||
PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}"
|
||||
PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}"
|
||||
PUTER_PORT="${PUTER_PORT:-80}"
|
||||
PUTER_PROTOCOL="${PUTER_PROTOCOL:-http}"
|
||||
PUTER_TRUST_PROXY="${PUTER_TRUST_PROXY:-1}"
|
||||
PUTER_ENV="${PUTER_ENV:-prod}"
|
||||
PUTER_FORCE="${PUTER_FORCE:-0}"
|
||||
|
||||
log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; }
|
||||
@@ -117,9 +129,9 @@ EOF
|
||||
cat > puter/config/config.json <<EOF
|
||||
{
|
||||
"domain": "$PUTER_DOMAIN",
|
||||
"protocol": "http",
|
||||
"protocol": "$PUTER_PROTOCOL",
|
||||
"pub_port": $PUTER_PORT,
|
||||
"env": "prod",
|
||||
"env": "$PUTER_ENV",
|
||||
|
||||
"static_hosting_domain": "site.$PUTER_DOMAIN",
|
||||
"static_hosting_domain_alt": "host.$PUTER_DOMAIN",
|
||||
@@ -159,7 +171,7 @@ EOF
|
||||
"s3": {
|
||||
"s3Config": {
|
||||
"endpoint": "http://s3:9000",
|
||||
"publicEndpoint": "http://s3.$PUTER_DOMAIN",
|
||||
"publicEndpoint": "$PUTER_PROTOCOL://s3.$PUTER_DOMAIN",
|
||||
"accessKeyId": "puter",
|
||||
"secretAccessKey": "$S3_SECRET_KEY",
|
||||
"region": "us-east-1",
|
||||
@@ -173,7 +185,7 @@ EOF
|
||||
"ollama": { "enabled": false }
|
||||
},
|
||||
|
||||
"trust_proxy": 1
|
||||
"trust_proxy": $PUTER_TRUST_PROXY
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
@@ -187,6 +199,6 @@ log "stack starting. first boot takes ~30s while MariaDB initialises."
|
||||
log "follow puter logs:"
|
||||
log " cd $PUTER_DIR && docker compose logs -f puter"
|
||||
log ""
|
||||
log "open http://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
|
||||
log "open $PUTER_PROTOCOL://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
|
||||
log "first-boot admin password is logged once — grab it with:"
|
||||
log " cd $PUTER_DIR && docker compose logs puter | grep password"
|
||||
Generated
+6391
-4808
File diff suppressed because it is too large.
Load diff
+31
-6
@@ -10,6 +10,7 @@
|
||||
"lib": "lib"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
@@ -20,6 +21,8 @@
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"@vitest/coverage-v8": "^4.0.14",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-istanbul": "^8.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"clean-css": "^5.3.2",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -31,9 +34,13 @@
|
||||
"globals": "^15.15.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"license-check-and-add": "^4.0.5",
|
||||
"nodemon": "^3.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-jsdoc": "^1.8.1",
|
||||
"simple-git": "^3.32.3",
|
||||
"typescript": "^5.4.5",
|
||||
"uglify-js": "^3.17.4",
|
||||
@@ -44,18 +51,24 @@
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test:backend": "vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"start=gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"test:backend": "npm run setupExtensions && vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "npm run setupExtensions && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:puterjs": "npm run setupExtensions && vitest run --config src/puter-js/tests/api/vitest.config.ts",
|
||||
"test:puterjs:node": "npm run test:puterjs -- src/puter-js/tests/api/runners/node.test.ts",
|
||||
"test:puterjs:browser": "npm run test:puterjs -- src/puter-js/tests/api/runners/browser.test.ts",
|
||||
"test:puterjs:workerd": "npm run test:puterjs -- src/puter-js/tests/api/runners/workerd.test.ts",
|
||||
"test:puterjs:coverage": "npm run build:workerLib:coverage && npm run setupExtensions && rm -rf src/puter-js/coverage && PUTER_COVERAGE=1 vitest run --config src/puter-js/tests/api/vitest.config.ts && node ./tools/puterjsCoverageReport.mjs",
|
||||
"build:workerLib:coverage": "cd src/puter-js && npm run build:coverage && cd ../worker && npm run build",
|
||||
"start:gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
|
||||
"prestart": "npm run build:ts",
|
||||
"prestart": "npm run setupExtensions && npm run build:ts",
|
||||
"dev": "npm start",
|
||||
"build": "npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
|
||||
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
|
||||
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
|
||||
"check-translations": "node tools/check-translations.js",
|
||||
"prepare": "husky",
|
||||
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
|
||||
"postinstall": "node ./tools/extensionSetup.mjs"
|
||||
"setupExtensions": "node ./tools/extensionSetup.mjs"
|
||||
},
|
||||
"workspaces": [
|
||||
"src/*",
|
||||
@@ -77,9 +90,21 @@
|
||||
"ai": "^6.0.73",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@google/genai": false,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareMigrationFilenames } from './MySQLDatabaseClient.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import {
|
||||
MySQLDatabaseClient,
|
||||
compareMigrationFilenames,
|
||||
} from './MySQLDatabaseClient.js';
|
||||
|
||||
describe('compareMigrationFilenames', () => {
|
||||
it('orders numbered migrations numerically, not lexically', () => {
|
||||
@@ -93,3 +97,76 @@ describe('compareMigrationFilenames', () => {
|
||||
expect([...sorted].sort(compareMigrationFilenames)).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Replica read failover ───────────────────────────────────────────
|
||||
//
|
||||
// `read()` normally goes to the replica batcher; when the replica side is
|
||||
// degraded (batcher load-shed or a transient connection error) and a real
|
||||
// replica is configured, the read retries once on the primary batcher.
|
||||
|
||||
type Batcher = { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
const makeClient = (opts: {
|
||||
replica: Batcher;
|
||||
primary: Batcher;
|
||||
multiNode?: boolean;
|
||||
}) => {
|
||||
const client = new MySQLDatabaseClient({
|
||||
database: { engine: 'mysql' },
|
||||
} as IConfig);
|
||||
// Bypass onServerStart (which would connect to a real database) and
|
||||
// inject the batchers directly. Configuration enum: SINGLE=0, REPLICA=1.
|
||||
Object.assign(client as unknown as Record<string, unknown>, {
|
||||
dbReplica: opts.replica,
|
||||
db: opts.primary,
|
||||
configuration: opts.multiNode === false ? 0 : 1,
|
||||
});
|
||||
return client;
|
||||
};
|
||||
|
||||
const codedError = (code: string) => {
|
||||
const err = new Error(code) as Error & { code: string };
|
||||
err.code = code;
|
||||
return err;
|
||||
};
|
||||
|
||||
describe('MySQLDatabaseClient.read — replica failover', () => {
|
||||
it('fails over to the primary on batcher load-shed errors', async () => {
|
||||
const replica = { execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')) };
|
||||
const primary = { execute: vi.fn().mockResolvedValue([[{ ok: 1 }]]) };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([{ ok: 1 }]);
|
||||
expect(primary.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails over on transient connection errors', async () => {
|
||||
const replica = { execute: vi.fn().mockRejectedValue(codedError('ECONNRESET')) };
|
||||
const primary = { execute: vi.fn().mockResolvedValue([[{ ok: 1 }]]) };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([{ ok: 1 }]);
|
||||
});
|
||||
|
||||
it('rethrows deterministic SQL errors without touching the primary', async () => {
|
||||
const replica = { execute: vi.fn().mockRejectedValue(codedError('ER_PARSE_ERROR')) };
|
||||
const primary = { execute: vi.fn() };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELEC oops')).rejects.toMatchObject({
|
||||
code: 'ER_PARSE_ERROR',
|
||||
});
|
||||
expect(primary.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail over in single-node configuration', async () => {
|
||||
const replica = { execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')) };
|
||||
const primary = { execute: vi.fn() };
|
||||
const client = makeClient({ replica, primary, multiNode: false });
|
||||
|
||||
await expect(client.read('SELECT 1')).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
});
|
||||
expect(primary.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,31 +19,24 @@
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
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 RETRIABLE_ERROR_CODES = new Set([
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_SEQUENCE_TIMEOUT',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
]);
|
||||
const DEFAULT_SELECT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const RETRIABLE_ERROR_MESSAGES = [
|
||||
'Connection lost',
|
||||
'read ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
const replicaFailoverCounter = metrics
|
||||
.getMeter('puter-backend')
|
||||
.createCounter('db.read.replica_failover', {
|
||||
description:
|
||||
'Reads that failed on the replica batcher and were retried on the primary',
|
||||
});
|
||||
|
||||
export { compareMigrationFilenames };
|
||||
|
||||
@@ -85,7 +78,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
this.db = new SQLBatcher(this.primaryPool, 30, 5);
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -96,7 +89,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
await this.runMigrations();
|
||||
}
|
||||
@@ -135,15 +128,36 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// The db.* spans measure the logical query, including time queued in
|
||||
// the SQLBatcher — the mysql2 auto-instrumentation only sees the
|
||||
// coalesced multi-statement flush, so per-query latency lives here.
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.dbReplica.execute(query, params);
|
||||
let result;
|
||||
try {
|
||||
result = await this.dbReplica.execute(query, params);
|
||||
} catch (error) {
|
||||
// Replica-side degradation (batcher load-shed or a transient
|
||||
// connection failure) shouldn't fail reads while the primary is
|
||||
// healthy. Deterministic errors (bad SQL) are rethrown — they
|
||||
// would fail identically on the primary.
|
||||
if (
|
||||
this.configuration !== Configuration.REPLICA ||
|
||||
!MySQLDatabaseClient.isFailoverWorthy(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
replicaFailoverCounter.add(1);
|
||||
result = await this.db.execute(query, params);
|
||||
}
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -153,6 +167,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -170,6 +185,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
@@ -195,6 +213,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -296,12 +315,53 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private createPool(poolConfig: PoolConfig): Pool {
|
||||
return createPool({
|
||||
const pool = createPool({
|
||||
maxPreparedStatements: 900,
|
||||
connectionLimit: 30,
|
||||
enableKeepAlive: true,
|
||||
...poolConfig,
|
||||
multipleStatements: true,
|
||||
} as PoolConfig);
|
||||
|
||||
// Server-side kill switch for runaway reads: MySQL applies
|
||||
// max_execution_time to SELECT statements only, so this is
|
||||
// write-safe. Without it, a stalled database turns reads into
|
||||
// indefinite hangs that no client-side timeout ever converts
|
||||
// into a failure. 0 disables.
|
||||
const selectTimeoutMs = Math.floor(
|
||||
Number(
|
||||
this.config.database?.selectTimeoutMs ??
|
||||
DEFAULT_SELECT_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
if (selectTimeoutMs > 0) {
|
||||
pool.on('connection', (conn) => {
|
||||
conn.query(
|
||||
`SET SESSION max_execution_time = ${selectTimeoutMs}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private createPrimaryBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private createReplicaBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 10,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'replica',
|
||||
readOnly: true,
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reinitialize the primary pool (e.g. after a health-check failure). */
|
||||
@@ -317,11 +377,11 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
password: dbConf.password ?? '',
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
this.db = new SQLBatcher(this.primaryPool, 30, 5);
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
this.replicaPool = this.primaryPool;
|
||||
this.dbReplica = new SQLBatcher(this.primaryPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.primaryPool);
|
||||
}
|
||||
|
||||
if (previous && previous !== this.primaryPool) {
|
||||
@@ -335,7 +395,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
const previous = this.replicaPool;
|
||||
this.replicaPool = this.createPool(this.config.database.replica);
|
||||
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
if (
|
||||
previous &&
|
||||
@@ -351,11 +411,14 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
static isRetriableError(error: unknown): boolean {
|
||||
const code = (error as { code?: string })?.code;
|
||||
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
|
||||
return isRetriableError(error);
|
||||
}
|
||||
|
||||
const msg = String((error as Error)?.message ?? '');
|
||||
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
|
||||
/** Replica failures worth retrying on the primary: batcher load-shed
|
||||
* or transient connection errors — never deterministic SQL errors. */
|
||||
private static isFailoverWorthy(error: unknown): boolean {
|
||||
const code = (error as { code?: string })?.code;
|
||||
return code === 'dbBatchFailed' || isRetriableError(error);
|
||||
}
|
||||
|
||||
async readWithRetry(
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { Pool, type PoolConfig, type QueryResult } from 'pg';
|
||||
import { Span } from '../../util/span.js';
|
||||
import {
|
||||
AbstractDatabaseClient,
|
||||
type BatchEntry,
|
||||
@@ -214,6 +215,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -222,6 +224,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -230,6 +233,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -238,6 +242,9 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return mapPostgresWriteResult(result);
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(entries: BatchEntry[]): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
@@ -258,6 +265,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
|
||||
@@ -18,10 +18,19 @@
|
||||
*/
|
||||
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import {
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
isNeverSentError,
|
||||
isRetriableError,
|
||||
} from './retriableErrors.js';
|
||||
|
||||
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
||||
const DEFAULT_FAILURE_THRESHOLD = 5;
|
||||
const DEFAULT_COOLDOWN_MS = 5_000;
|
||||
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000;
|
||||
const ACQUIRE_ATTEMPTS = 3;
|
||||
const ITEM_RETRY_ATTEMPTS = 2;
|
||||
const RETRY_BASE_BACKOFF_MS = 100;
|
||||
const FALLBACK_RETRY_CONCURRENCY = 8;
|
||||
|
||||
const meter = metrics.getMeter('puter-backend');
|
||||
@@ -53,6 +62,15 @@ const fallbackItemFailuresCounter = meter.createCounter(
|
||||
'Per-item failures observed during SQLBatcher per-item retry',
|
||||
},
|
||||
);
|
||||
const fallbackItemRetriesCounter = meter.createCounter(
|
||||
'sql_batcher.fallback.item_retries',
|
||||
{
|
||||
description:
|
||||
'Transient per-item failures retried during SQLBatcher fallback',
|
||||
},
|
||||
);
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class SQLBatcher {
|
||||
dbPool;
|
||||
@@ -61,18 +79,43 @@ export class SQLBatcher {
|
||||
maxQueueSize;
|
||||
failureThreshold;
|
||||
cooldownMs;
|
||||
poolLabel;
|
||||
readOnly;
|
||||
acquireTimeoutMs;
|
||||
queue = [];
|
||||
timeouts = [];
|
||||
#consecutiveFailures = 0;
|
||||
#lastFailureAt = 0;
|
||||
#metricAttrs;
|
||||
|
||||
/**
|
||||
* @param {object} dbPool mysql2 pool
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxTimeInQueue] ms an item may wait before flush
|
||||
* @param {number} [opts.maxBatchSize] items coalesced per flush
|
||||
* @param {number} [opts.maxQueueSize] drop-oldest high-water mark
|
||||
* @param {number} [opts.failureThreshold] consecutive failures to open the breaker
|
||||
* @param {number} [opts.cooldownMs] breaker open duration after last failure
|
||||
* @param {'primary'|'replica'} [opts.poolLabel] role label on metrics; in
|
||||
* single-node setups the 'replica' batcher shares the primary pool, so
|
||||
* this reflects the read/write role rather than a physical instance
|
||||
* @param {boolean} [opts.readOnly] this batcher only ever carries SELECTs,
|
||||
* so any transient failure is safe to retry
|
||||
* @param {number} [opts.acquireTimeoutMs] max wait for a pooled
|
||||
* connection; 0 disables the bound
|
||||
*/
|
||||
constructor(
|
||||
dbPool,
|
||||
maxTimeInQueue = 20,
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
{
|
||||
maxTimeInQueue = 20,
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
poolLabel = 'primary',
|
||||
readOnly = false,
|
||||
acquireTimeoutMs = DEFAULT_ACQUIRE_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
this.dbPool = dbPool;
|
||||
this.maxTimeInQueue = maxTimeInQueue;
|
||||
@@ -80,6 +123,10 @@ export class SQLBatcher {
|
||||
this.maxQueueSize = maxQueueSize;
|
||||
this.failureThreshold = failureThreshold;
|
||||
this.cooldownMs = cooldownMs;
|
||||
this.poolLabel = poolLabel;
|
||||
this.readOnly = readOnly;
|
||||
this.acquireTimeoutMs = acquireTimeoutMs;
|
||||
this.#metricAttrs = { pool: poolLabel };
|
||||
}
|
||||
|
||||
async execute(sql, values) {
|
||||
@@ -90,9 +137,13 @@ export class SQLBatcher {
|
||||
return this;
|
||||
}
|
||||
|
||||
#createPublicBatchError() {
|
||||
// The public error is deliberately opaque (no SQL, no internals), but
|
||||
// `reason` distinguishes the load-shed path for logs and callers:
|
||||
// breakerOpen | queueOverflow | connAcquire.
|
||||
#createPublicBatchError(reason) {
|
||||
const error = new Error('Database operation failed');
|
||||
error.code = 'dbBatchFailed';
|
||||
error.reason = reason;
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -106,8 +157,8 @@ export class SQLBatcher {
|
||||
|
||||
async query(sql, values) {
|
||||
if (this.#isBreakerOpen()) {
|
||||
enqueueRejectedCounter.add(1);
|
||||
throw this.#createPublicBatchError();
|
||||
enqueueRejectedCounter.add(1, this.#metricAttrs);
|
||||
throw this.#createPublicBatchError('breakerOpen');
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
@@ -117,8 +168,8 @@ export class SQLBatcher {
|
||||
// to have already exceeded any caller-side timeout anyway.
|
||||
while (this.queue.length >= this.maxQueueSize) {
|
||||
const dropped = this.queue.shift();
|
||||
dropped.reject(this.#createPublicBatchError());
|
||||
enqueueDroppedCounter.add(1);
|
||||
dropped.reject(this.#createPublicBatchError('queueOverflow'));
|
||||
enqueueDroppedCounter.add(1, this.#metricAttrs);
|
||||
}
|
||||
|
||||
this.queue.push({
|
||||
@@ -142,6 +193,61 @@ export class SQLBatcher {
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Bounded wait for a pooled connection. Without a bound, a stalled
|
||||
// database turns every flush into an indefinite hang — nothing fails,
|
||||
// so neither the breaker nor callers' own timeouts ever engage.
|
||||
#getConnectionWithTimeout() {
|
||||
const acquire = this.dbPool.promise().getConnection();
|
||||
if (!this.acquireTimeoutMs) return acquire;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
// A connection that arrives late must go back to the pool.
|
||||
acquire.then(
|
||||
(conn) => conn.release(),
|
||||
() => {},
|
||||
);
|
||||
const error = new Error(
|
||||
'Timed out acquiring database connection',
|
||||
);
|
||||
error.code = POOL_ACQUIRE_TIMEOUT;
|
||||
reject(error);
|
||||
}, this.acquireTimeoutMs);
|
||||
|
||||
acquire.then(
|
||||
(conn) => {
|
||||
if (timedOut) return;
|
||||
clearTimeout(timer);
|
||||
resolve(conn);
|
||||
},
|
||||
(err) => {
|
||||
if (timedOut) return;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Acquisition failures never sent a statement, so retrying is always
|
||||
// safe regardless of what the batch contains.
|
||||
async #acquireConnection() {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= ACQUIRE_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await this.#getConnectionWithTimeout();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < ACQUIRE_ATTEMPTS) {
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async flush(batch) {
|
||||
const timeout = this.timeouts.shift();
|
||||
if (timeout && !timeout._destroyed) {
|
||||
@@ -154,17 +260,17 @@ export class SQLBatcher {
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.dbPool.promise().getConnection();
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
this.#consecutiveFailures++;
|
||||
this.#lastFailureAt = Date.now();
|
||||
flushFailureCounter.add(1);
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
console.warn(
|
||||
'SQLBatcher could not acquire connection for flush:',
|
||||
error,
|
||||
);
|
||||
for (const b of batch) {
|
||||
b.reject(this.#createPublicBatchError());
|
||||
b.reject(this.#createPublicBatchError('connAcquire'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -206,8 +312,8 @@ export class SQLBatcher {
|
||||
// committed; re-running each item independently produces clean
|
||||
// success/failure outcomes for each caller. Concurrency is capped to
|
||||
// avoid briefly saturating the pool when a large batch fails.
|
||||
flushFailureCounter.add(1);
|
||||
fallbackInvocationsCounter.add(1);
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
fallbackInvocationsCounter.add(1, this.#metricAttrs);
|
||||
|
||||
const settled = new Array(batch.length);
|
||||
let cursor = 0;
|
||||
@@ -216,17 +322,7 @@ export class SQLBatcher {
|
||||
async () => {
|
||||
while (cursor < batch.length) {
|
||||
const i = cursor++;
|
||||
const b = batch[i];
|
||||
try {
|
||||
settled[i] = {
|
||||
ok: true,
|
||||
value: await this.dbPool
|
||||
.promise()
|
||||
.query(b.sql, b.values ?? []),
|
||||
};
|
||||
} catch (error) {
|
||||
settled[i] = { ok: false, error };
|
||||
}
|
||||
settled[i] = await this.#runFallbackItem(batch[i]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -246,7 +342,7 @@ export class SQLBatcher {
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
fallbackItemFailuresCounter.add(failureCount);
|
||||
fallbackItemFailuresCounter.add(failureCount, this.#metricAttrs);
|
||||
}
|
||||
|
||||
// Only escalate the breaker when the database itself looks unhealthy
|
||||
@@ -259,4 +355,39 @@ export class SQLBatcher {
|
||||
this.#consecutiveFailures++;
|
||||
}
|
||||
}
|
||||
|
||||
// Run one fallback item, retrying transient failures with backoff.
|
||||
// A read-only batcher may retry anything transient; a batcher that
|
||||
// carries writes only retries failures where the statement provably
|
||||
// never reached the server — a write that died mid-flight may have
|
||||
// committed, and re-running it would double-apply.
|
||||
async #runFallbackItem(b) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: await connection.query(b.sql, b.values ?? []),
|
||||
};
|
||||
} catch (error) {
|
||||
const canRetry = this.readOnly
|
||||
? isRetriableError(error)
|
||||
: isNeverSentError(error);
|
||||
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
attempt++;
|
||||
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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, vi } from 'vitest';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
|
||||
const makeError = (code: string, message = code): Error & { code: string } => {
|
||||
const error = new Error(message) as Error & { code: string };
|
||||
error.code = code;
|
||||
return error;
|
||||
};
|
||||
|
||||
interface FakeConnection {
|
||||
beginTransaction: ReturnType<typeof vi.fn>;
|
||||
query: ReturnType<typeof vi.fn>;
|
||||
commit: ReturnType<typeof vi.fn>;
|
||||
rollback: ReturnType<typeof vi.fn>;
|
||||
release: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
// The batch flush sends one coalesced multi-statement (always suffixed
|
||||
// `; SELECT 1`); fallback items send their original single statement.
|
||||
const isBatchQuery = (sql: string) => sql.endsWith('; SELECT 1');
|
||||
|
||||
const makeConnection = (
|
||||
onQuery: (sql: string, values: unknown[]) => unknown,
|
||||
): FakeConnection => ({
|
||||
beginTransaction: vi.fn(async () => {}),
|
||||
query: vi.fn(async (sql: string, values: unknown[]) => onQuery(sql, values)),
|
||||
commit: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
release: vi.fn(),
|
||||
});
|
||||
|
||||
const makePool = (connection: FakeConnection | (() => Promise<FakeConnection>)) => {
|
||||
const getConnection = vi.fn(async () =>
|
||||
typeof connection === 'function' ? connection() : connection,
|
||||
);
|
||||
return {
|
||||
pool: { promise: () => ({ getConnection }) },
|
||||
getConnection,
|
||||
};
|
||||
};
|
||||
|
||||
// Happy-path onQuery: batch returns one result row-set per statement plus
|
||||
// the trailing SELECT 1 row-set.
|
||||
const happyBatch = (sql: string) => {
|
||||
if (!isBatchQuery(sql)) throw new Error('unexpected fallback query');
|
||||
const statements = sql.split(';').length - 1;
|
||||
return [
|
||||
Array.from({ length: statements + 1 }, (_, i) => [{ n: i }]),
|
||||
undefined,
|
||||
];
|
||||
};
|
||||
|
||||
describe('SQLBatcher', () => {
|
||||
it('resolves each batched item with its own result', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(a[0]).toEqual([{ n: 0 }]);
|
||||
expect(b[0]).toEqual([{ n: 1 }]);
|
||||
expect(conn.beginTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(conn.commit).toHaveBeenCalledTimes(1);
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
maxQueueSize: 1,
|
||||
});
|
||||
|
||||
const first = batcher.query('SELECT a', []);
|
||||
const second = batcher.query('SELECT b', []);
|
||||
|
||||
await expect(first).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'queueOverflow',
|
||||
});
|
||||
await expect(second).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects with reason connAcquire after exhausting acquisition retries', async () => {
|
||||
const getConnection = vi.fn(async () => {
|
||||
throw makeError('ECONNREFUSED');
|
||||
});
|
||||
const pool = { promise: () => ({ getConnection }) };
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
await expect(batcher.query('SELECT a', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'connAcquire',
|
||||
});
|
||||
expect(getConnection).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('bounds connection acquisition and rejects when the pool never answers', async () => {
|
||||
const getConnection = vi.fn(
|
||||
() => new Promise(() => {}), // pool never yields a connection
|
||||
);
|
||||
const pool = { promise: () => ({ getConnection }) };
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
acquireTimeoutMs: 30,
|
||||
});
|
||||
|
||||
await expect(batcher.query('SELECT a', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'connAcquire',
|
||||
});
|
||||
expect(getConnection).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('opens the breaker after consecutive failures and rejects with reason breakerOpen', async () => {
|
||||
// Batch and fallback both fail with an ambiguous (non-retriable for
|
||||
// writes) connection error, so no item gets through.
|
||||
const conn = makeConnection(() => {
|
||||
throw makeError('ECONNRESET');
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(batcher.query('INSERT x', [])).rejects.toMatchObject({
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
await expect(batcher.query('INSERT y', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'breakerOpen',
|
||||
});
|
||||
});
|
||||
|
||||
it('retries transient fallback failures when readOnly', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
if (fallbackAttempts === 1) throw makeError('ECONNRESET');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const result = await batcher.query('SELECT a', []);
|
||||
expect(result[0]).toEqual([{ ok: 1 }]);
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('does not retry ambiguous failures on a batcher that carries writes', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
throw makeError('ECONNRESET');
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
await expect(batcher.query('INSERT x', [])).rejects.toMatchObject({
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
expect(fallbackAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('retries never-sent failures even on a batcher that carries writes', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
if (fallbackAttempts === 1) throw makeError('ECONNREFUSED');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
const result = await batcher.query('INSERT x', []);
|
||||
expect(result[0]).toEqual([{ ok: 1 }]);
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('never retries deterministic row-level errors and does not escalate the breaker', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ER_DUP_ENTRY');
|
||||
fallbackAttempts++;
|
||||
if (sql === 'INSERT dup') throw makeError('ER_DUP_ENTRY');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const dup = batcher.query('INSERT dup', []);
|
||||
const fine = batcher.query('INSERT fine', []);
|
||||
await expect(dup).rejects.toMatchObject({ code: 'ER_DUP_ENTRY' });
|
||||
await expect(fine).resolves.toBeTruthy();
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
|
||||
// One fallback item succeeded, so the breaker must stay closed.
|
||||
const conn2 = makeConnection(happyBatch);
|
||||
// reuse same batcher/pool: next query must not be rejected upfront
|
||||
conn.query.mockImplementation(conn2.query.getMockImplementation()!);
|
||||
await expect(batcher.query('SELECT a', [])).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ 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 { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
|
||||
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
|
||||
@@ -85,6 +86,14 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[50, ['0054_sessions_workers.sql']],
|
||||
[50, ['0055_username_nocase_unique.sql']],
|
||||
[51, ['0056_sessions_kind_worker.sql']],
|
||||
[52, ['0057_add_user_reputation.sql']],
|
||||
[53, ['0058_add_phone_verification.sql']],
|
||||
[54, ['0059_add_card_verification.sql']],
|
||||
[55, ['0060_add_card_fingerprint.sql']],
|
||||
[56, ['0061_add_suspended_at.sql']],
|
||||
[57, ['0062_blocked-app-origins.sql']],
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
@@ -128,6 +137,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -148,6 +158,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -164,6 +175,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
--
|
||||
|
||||
--
|
||||
-- Idempotent column-ensure helper (used by CALLs below; dropped at end of file)
|
||||
-- Idempotent column-ensure helper. Used by the CALLs below AND by later
|
||||
-- migration files, which CALL it as a one-line idempotent column add. Left
|
||||
-- resident (not dropped at end of file) so it outlives this migration.
|
||||
--
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_col;
|
||||
@@ -1333,7 +1335,10 @@ CALL _puter_add_col('user_update_audit', 'new_username', '`new_username` varchar
|
||||
CALL _puter_add_col('user_update_audit', 'reason', '`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
|
||||
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_col;
|
||||
-- _puter_add_col is intentionally NOT dropped here: later migration files CALL
|
||||
-- it as a one-line idempotent column add, and migrations replay on every boot
|
||||
-- with no applied-state tracking, so the helper must outlive this file. The
|
||||
-- DROP-before-CREATE at the top keeps this migration itself replay-safe.
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 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/>.
|
||||
|
||||
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
|
||||
-- E.164 number collected during verification (indexed like `email`);
|
||||
-- `requires_phone_verification` gates account use for low-reputation signups
|
||||
-- (not indexed, mirroring `requires_email_confirmation`).
|
||||
--
|
||||
-- Idempotent: column adds use _puter_add_col (defined in mig_1, which leaves it
|
||||
-- resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'phone', '`phone` varchar(20) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'requires_phone_verification', '`requires_phone_verification` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_phone_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_phone'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_phone` (`phone`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_phone_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Credit-card verification column. Mirrors SQLite migration 0059.
|
||||
-- `requires_card_verification` gates account use for low-reputation signups;
|
||||
-- the card itself never touches our DB, so this is the only column (not
|
||||
-- indexed, mirroring `requires_phone_verification`).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (from mig_1) so the
|
||||
-- directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'requires_card_verification', '`requires_card_verification` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
|
||||
-- is the Stripe card fingerprint (stable per card number) recorded when a user
|
||||
-- clears card verification — the card sibling of `phone`, indexed like it so
|
||||
-- admin tooling can find the accounts that verified with a given card. The card
|
||||
-- itself never touches our DB, only Stripe's fingerprint for it.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'card_fingerprint', '`card_fingerprint` varchar(128) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_card_fingerprint_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_card_fingerprint'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_card_fingerprint` (`card_fingerprint`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_card_fingerprint_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_at', '`suspended_at` bigint DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_suspended_at_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_suspended_at'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_suspended_at` (`suspended_at`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_suspended_at_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
|
||||
-- An app whose `index_url` host (or a request origin) matches an entry is
|
||||
-- denied access to Puter resources: it cannot obtain an app token and
|
||||
-- already-issued app tokens are rejected on each request. `include_subdomains
|
||||
-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
|
||||
-- AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`domain` VARCHAR(255) NOT NULL,
|
||||
`include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`created_by` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations).
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_reason', '`suspended_reason` VARCHAR(64) DEFAULT NULL');
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064.
|
||||
-- Used to measure the abuse system's false-positive rate: `unsuspend` (admin
|
||||
-- unblock) and `admin_create_user` are the false-positive signals; `suspend`
|
||||
-- gives the denominator. Preserves history the `user` suspension columns lose
|
||||
-- on unsuspend. `created_at` is unix seconds (matching `user.suspended_at`).
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`target_user_id` BIGINT DEFAULT NULL,
|
||||
`target_username` VARCHAR(255) DEFAULT NULL,
|
||||
`admin_username` VARCHAR(255) DEFAULT NULL,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`source` VARCHAR(64) DEFAULT NULL,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_abuse_moderation_events_created_at` (`created_at`),
|
||||
KEY `idx_abuse_moderation_events_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
|
||||
-- E.164 number collected during verification (indexed like `email`);
|
||||
-- `requires_phone_verification` gates account use for low-reputation signups
|
||||
-- (not indexed, mirroring `requires_email_confirmation`).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS phone varchar(20);
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_phone_verification boolean NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_phone ON "user" (phone);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Credit-card verification column. Mirrors SQLite migration 0059.
|
||||
-- `requires_card_verification` gates account use for low-reputation signups;
|
||||
-- the card itself never touches our DB, so this is the only column (not
|
||||
-- indexed, mirroring `requires_phone_verification`).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_card_verification boolean NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
|
||||
-- is the Stripe card fingerprint (stable per card number) recorded when a user
|
||||
-- clears card verification — the card sibling of `phone`, indexed like it so
|
||||
-- admin tooling can find the accounts that verified with a given card. The card
|
||||
-- itself never touches our DB, only Stripe's fingerprint for it.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS card_fingerprint varchar(128);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON "user" (card_fingerprint);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_at bigint;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON "user" (suspended_at);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062 /
|
||||
-- MySQL mysql_mig_17 (which Postgres was originally missed for). An app whose
|
||||
-- `index_url` host (or a request origin) matches an entry is denied access to
|
||||
-- Puter resources. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_app_origins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
include_subdomains SMALLINT NOT NULL DEFAULT 0,
|
||||
reason TEXT DEFAULT NULL,
|
||||
created_by VARCHAR(255) DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_app_origins_domain
|
||||
ON blocked_app_origins (domain);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_reason text;
|
||||
@@ -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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064 /
|
||||
-- MySQL mysql_mig_19. Used to measure the abuse system's false-positive rate:
|
||||
-- `unsuspend` (admin unblock) and `admin_create_user` are the false-positive
|
||||
-- signals; `suspend` gives the denominator. Preserves history the `user`
|
||||
-- suspension columns lose on unsuspend. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS abuse_moderation_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id BIGINT DEFAULT NULL,
|
||||
target_username VARCHAR(255) DEFAULT NULL,
|
||||
admin_username VARCHAR(255) DEFAULT NULL,
|
||||
reason TEXT DEFAULT NULL,
|
||||
source VARCHAR(64) DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON abuse_moderation_events (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON abuse_moderation_events (action);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Abuse v2 reputation score (0–100+) recorded at signup. Mirrors the
|
||||
-- `reputation smallint DEFAULT 100` column already present in the mysql and
|
||||
-- postgres baseline migrations.
|
||||
ALTER TABLE `user` ADD COLUMN `reputation` SMALLINT DEFAULT 100;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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/>.
|
||||
|
||||
-- SMS phone verification. `phone` holds the E.164 number collected during
|
||||
-- verification (indexed like `email`). `requires_phone_verification` gates
|
||||
-- account use until verified — the abuse v2 harness sets it for low-reputation
|
||||
-- signups instead of blocking them. (Not indexed, mirroring
|
||||
-- `requires_email_confirmation`.)
|
||||
ALTER TABLE `user` ADD COLUMN `phone` varchar(20) DEFAULT NULL;
|
||||
ALTER TABLE `user` ADD COLUMN `requires_phone_verification` tinyint(1) NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_phone ON `user` (`phone`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Credit-card verification ($0 authorization handled by a payments
|
||||
-- extension). `requires_card_verification` gates account use until verified —
|
||||
-- the abuse v2 harness sets it for low-reputation signups instead of blocking
|
||||
-- them. The card itself never touches our DB, so this is the only column.
|
||||
-- (Not indexed, mirroring `requires_phone_verification`.)
|
||||
ALTER TABLE `user` ADD COLUMN `requires_card_verification` tinyint(1) NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Card fingerprint column. `card_fingerprint` is the Stripe card fingerprint
|
||||
-- (stable per card number) recorded when a user clears card verification — the
|
||||
-- card sibling of `phone`. Indexed like `phone` so admin tooling can find the
|
||||
-- accounts that verified with a given card. The card itself never touches our
|
||||
-- DB, only Stripe's fingerprint for it. Server-only (not on the whoami
|
||||
-- allowlist), so it never reaches clients.
|
||||
ALTER TABLE `user` ADD COLUMN `card_fingerprint` varchar(128) DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON `user` (`card_fingerprint`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- When the account was suspended, as unix seconds; NULL while not suspended.
|
||||
-- The timestamp sibling of the existing boolean `suspended` flag, stamped by
|
||||
-- every suspension site. Indexed so the signup-abuse harness can count an IP's
|
||||
-- recently-suspended accounts (see extensions/abuse/v2 suspendedAccountsByIp).
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_at` INTEGER DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON `user` (`suspended_at`);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
|
||||
-- a request origin) matches an entry is denied access to Puter resources:
|
||||
-- it cannot obtain an app token and already-issued app tokens are rejected
|
||||
-- on each request. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"domain" TEXT NOT NULL,
|
||||
"include_subdomains" INTEGER NOT NULL DEFAULT 0,
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"created_by" TEXT DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
|
||||
ON `blocked_app_origins` (`domain`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Why an account was suspended, NULL while not suspended. Companion to the
|
||||
-- boolean `suspended` flag and `suspended_at` timestamp. Constrained at the
|
||||
-- application layer to a fixed set of reasons (see extensions/admin
|
||||
-- suspension_reasons.js) — kept as free TEXT here so adding a reason later
|
||||
-- needs no migration.
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_reason` TEXT DEFAULT NULL;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Append-only log of admin moderation actions, used to measure the abuse
|
||||
-- system's false-positive rate: `unsuspend` (admin unblock) and
|
||||
-- `admin_create_user` (an account minted by an admin, typically to recover a
|
||||
-- legitimately-blocked signup) are the false-positive signals; `suspend` gives
|
||||
-- the denominator. Unlike the `suspended`/`suspended_at`/`suspended_reason`
|
||||
-- columns on `user` — which are cleared on unsuspend and so keep no history —
|
||||
-- this table preserves every event. `created_at` is unix seconds (matching
|
||||
-- `user.suspended_at`), stamped by the recorder in
|
||||
-- extensions/admin/moderation_events.js. Written best-effort at the admin
|
||||
-- suspend/unsuspend/create-user sites; surfaced on the /admin/abuse dashboard.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"action" TEXT NOT NULL, -- 'suspend' | 'unsuspend' | 'admin_create_user'
|
||||
"target_user_id" INTEGER DEFAULT NULL,
|
||||
"target_username" TEXT DEFAULT NULL,
|
||||
"admin_username" TEXT DEFAULT NULL, -- the acting admin (req.actor.user.username)
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"source" TEXT DEFAULT NULL, -- 'user_page' | 'email_hostname_blacklist' | 'create_user'
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON `abuse_moderation_events` (`created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON `abuse_moderation_events` (`action`);
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** Error code set by the pool-acquisition timeout in SQLBatcher. */
|
||||
export const POOL_ACQUIRE_TIMEOUT = 'POOL_ACQUIRE_TIMEOUT';
|
||||
|
||||
const RETRIABLE_ERROR_CODES = new Set([
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_SEQUENCE_TIMEOUT',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
]);
|
||||
|
||||
const RETRIABLE_ERROR_MESSAGES = [
|
||||
'Connection lost',
|
||||
'read ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
|
||||
/**
|
||||
* Failures where the statement provably never reached the server, so a
|
||||
* retry can never double-apply it — safe even for writes. Anything that
|
||||
* can occur after the statement was sent (resets, protocol drops) is
|
||||
* deliberately excluded: the server may have committed before the
|
||||
* connection died.
|
||||
*/
|
||||
const NEVER_SENT_ERROR_CODES = new Set([
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
]);
|
||||
|
||||
const errorCode = (error: unknown): string | undefined =>
|
||||
(error as { code?: string } | null)?.code;
|
||||
|
||||
/**
|
||||
* Transient connection-level failures worth retrying — but only for
|
||||
* statements that are safe to run twice (reads). Row-level errors
|
||||
* (duplicate key, constraint violations) are deterministic and never
|
||||
* match.
|
||||
*/
|
||||
export const isRetriableError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
|
||||
|
||||
const msg = String((error as Error)?.message ?? '');
|
||||
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
|
||||
};
|
||||
|
||||
export const isNeverSentError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
return Boolean(code && NEVER_SENT_ERROR_CODES.has(code));
|
||||
};
|
||||
@@ -43,6 +43,7 @@ import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
|
||||
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
|
||||
@@ -151,6 +152,7 @@ export class DDBClient extends PuterClient {
|
||||
await this.#localInitPromise;
|
||||
}
|
||||
|
||||
@Span('ddb.get', (table: string) => ({ 'db.table': table }))
|
||||
async get<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
@@ -167,6 +169,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
const command = new PutCommand({
|
||||
TableName: table,
|
||||
@@ -178,6 +181,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchGet(
|
||||
params: { table: string; items: Record<string, unknown> }[],
|
||||
consistentRead = false,
|
||||
@@ -212,6 +218,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchPut', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
if (params.length === 0) {
|
||||
@@ -318,6 +327,7 @@ export class DDBClient extends PuterClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
|
||||
async del<T extends Record<string, unknown>>(table: string, key: T) {
|
||||
const command = new DeleteCommand({
|
||||
TableName: table,
|
||||
@@ -329,6 +339,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.query', (table: string) => ({ 'db.table': table }))
|
||||
async query<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
keys: T,
|
||||
@@ -336,7 +347,15 @@ export class DDBClient extends PuterClient {
|
||||
pageKey?: Record<string, unknown>,
|
||||
index = '',
|
||||
consistentRead = false,
|
||||
options?: { beginsWith?: { key: string; value: string } },
|
||||
options?: {
|
||||
beginsWith?: { key: string; value: string };
|
||||
select?: 'COUNT';
|
||||
filter?: {
|
||||
expression: string;
|
||||
values?: Record<string, unknown>;
|
||||
names?: Record<string, string>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const keyExpressionParts = Object.keys(keys).map(
|
||||
(key) => `#${key} = :${key}`,
|
||||
@@ -367,6 +386,11 @@ export class DDBClient extends PuterClient {
|
||||
expressionAttributeNames[`#${beginsKey}`] = beginsKey;
|
||||
}
|
||||
|
||||
if (options?.filter) {
|
||||
Object.assign(expressionAttributeValues, options.filter.values);
|
||||
Object.assign(expressionAttributeNames, options.filter.names);
|
||||
}
|
||||
|
||||
const command = new QueryCommand({
|
||||
TableName: table,
|
||||
...(!index ? {} : { IndexName: index }),
|
||||
@@ -376,6 +400,10 @@ export class DDBClient extends PuterClient {
|
||||
ConsistentRead: consistentRead,
|
||||
...(!pageKey ? {} : { ExclusiveStartKey: pageKey }),
|
||||
...(!limit ? {} : { Limit: limit }),
|
||||
...(options?.filter
|
||||
? { FilterExpression: options.filter.expression }
|
||||
: {}),
|
||||
...(options?.select ? { Select: options.select } : {}),
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
|
||||
@@ -383,6 +411,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.update', (table: string) => ({ 'db.table': table }))
|
||||
async update<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
|
||||
@@ -24,12 +24,20 @@ import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
|
||||
|
||||
// -- Types ------------------------------------------------------------
|
||||
/** Attachment shape passed through to the underlying transport. */
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
content: Buffer | string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
|
||||
// nodemailer doesn't ship TS types, so declare the subset we use.
|
||||
interface NodemailerTransport {
|
||||
sendMail: (options: SendMailOptions) => Promise<unknown>;
|
||||
close?: () => void;
|
||||
/** Subset of the transport's send result callers may care about. */
|
||||
export interface SentMessageInfo {
|
||||
messageId?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SendMailOptions {
|
||||
@@ -37,10 +45,18 @@ export interface SendMailOptions {
|
||||
to: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
/** Optional transport recipients when they differ from visible headers. */
|
||||
envelope?: {
|
||||
from?: string;
|
||||
to: string;
|
||||
};
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
attachments?: EmailAttachment[];
|
||||
/** Extra message headers (e.g. List-Unsubscribe), passed to the transport. */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
|
||||
@@ -100,7 +116,8 @@ const DOMAIN_ALIASES: Record<string, string> = {
|
||||
* - Policy + extensible validation (via `validate`)
|
||||
*/
|
||||
export class EmailClient extends PuterClient {
|
||||
private transport: NodemailerTransport | null = null;
|
||||
private transport: ReturnType<typeof nodemailer.createTransport> | null =
|
||||
null;
|
||||
private compiledTemplates: Partial<
|
||||
Record<EmailTemplateName, CompiledTemplate>
|
||||
> = {};
|
||||
@@ -123,8 +140,7 @@ export class EmailClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.transport = nodemailer.createTransport(emailConf as any);
|
||||
this.transport = nodemailer.createTransport(emailConf);
|
||||
console.log('[email] transport configured');
|
||||
}
|
||||
|
||||
@@ -159,21 +175,30 @@ export class EmailClient extends PuterClient {
|
||||
/**
|
||||
* Raw send — bypasses the template system. Useful for one-off
|
||||
* admin emails that don't warrant a named template.
|
||||
*
|
||||
* Returns the transport's send result, or `null` when no transport
|
||||
* is configured (the send is a no-op in that case — callers that
|
||||
* must not silently drop mail should check `isConfigured` first).
|
||||
*/
|
||||
async sendRaw(options: SendMailOptions): Promise<void> {
|
||||
async sendRaw(options: SendMailOptions) {
|
||||
if (!this.transport) {
|
||||
console.warn(
|
||||
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
|
||||
options,
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
await this.transport.sendMail({
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
return await this.transport.sendMail({
|
||||
...options,
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
|
||||
get isConfigured(): boolean {
|
||||
return this.transport !== null;
|
||||
}
|
||||
|
||||
// -- Public API: clean / validate ---------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { extensionStore } from '../../extensions';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
EventListener,
|
||||
@@ -99,26 +100,34 @@ 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) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error('Error in event listener for event', key, e);
|
||||
// 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) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +155,19 @@ export class EventClient extends PuterClient {
|
||||
this.#eventListeners[key] ?? (this.#eventListeners[key] = []);
|
||||
listeners.push(callback as EventListener);
|
||||
}
|
||||
off<P extends ListenKey>(
|
||||
key: P,
|
||||
callback: (
|
||||
key: MatchingEvents<P>,
|
||||
data: EventMap[MatchingEvents<P>],
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
const listeners = this.#eventListeners[key];
|
||||
if (!listeners) return;
|
||||
const idx = listeners.indexOf(callback as EventListener);
|
||||
if (idx !== -1) listeners.splice(idx, 1);
|
||||
}
|
||||
async #emitEvent<T extends keyof EventMap>(
|
||||
listener: EventListener,
|
||||
key: T,
|
||||
|
||||
@@ -3,17 +3,22 @@
|
||||
*
|
||||
* Domain objects (User, Actor, FSEntry, Socket, ...) are typed as `unknown`
|
||||
* here on purpose — pulling their real types in would couple this module to
|
||||
* most of the backend and risk import cycles. Refine at the listener if
|
||||
* you need narrowed access.
|
||||
* most of the backend and risk import cycles. Refine at the listener if you
|
||||
* need narrowed access.
|
||||
*
|
||||
* Conventions:
|
||||
* - `*.validate` events carry an `allow` flag listeners flip to reject;
|
||||
* they tend to grow listener-specific fields, so they accept extras via
|
||||
* an index signature.
|
||||
* - `outer.gui.*` events ride the `{ user_id_list, response }` envelope
|
||||
* the SocketService fans out to user-scoped channels.
|
||||
*
|
||||
* - `*.validate` events carry an `allow` flag listeners flip to reject; they tend
|
||||
* to grow listener-specific fields, so they accept extras via an index
|
||||
* signature.
|
||||
* - `outer.gui.*` events ride the `{ user_id_list, response }` envelope the
|
||||
* SocketService fans out to user-scoped channels.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
import { Actor } from '../../core';
|
||||
import { FSEntry } from '../../stores/fs/FSEntry';
|
||||
|
||||
@@ -116,6 +121,21 @@ export type EventMap = {
|
||||
actor: unknown;
|
||||
result: { allowed: boolean; reason?: string; error?: string };
|
||||
};
|
||||
// Emitted after an app token is granted (get-user-app-token). The app-side
|
||||
// analogue of `site.htmlServed` — the external-app abuse scanner
|
||||
// (extensions/subdomainAbuse/appScan.ts) listens to screenshot + classify
|
||||
// external `index_url`s that Puter never serves itself.
|
||||
'puter.app.authenticated': {
|
||||
app_uid?: string;
|
||||
user_id?: number | null;
|
||||
app?: {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
index_url?: string | null;
|
||||
owner_user_id?: number | null;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Auth / signup ----
|
||||
'puter.signup.validate': {
|
||||
@@ -126,7 +146,22 @@ export type EventMap = {
|
||||
req?: unknown;
|
||||
data?: unknown;
|
||||
abuse?: unknown;
|
||||
custom?: unknown;
|
||||
trail?: Array<string>;
|
||||
/**
|
||||
* Set by the abuse harness for flagged signups — the id under which the
|
||||
* decision trail is persisted to KV (`abuse:trail:<id>`), shared back
|
||||
* on the request for log / support correlation.
|
||||
*/
|
||||
trail_id?: string;
|
||||
/** Device signal forwarded verbatim from the signup request body. */
|
||||
fingerprint?: string | null;
|
||||
/**
|
||||
* Set by the abuse harness — require SMS phone verification
|
||||
* post-signup.
|
||||
*/
|
||||
requires_phone_verification?: boolean;
|
||||
/** Set by the abuse harness — require card verification post-signup. */
|
||||
requires_card_verification?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'puter.signup.success': {
|
||||
@@ -135,6 +170,9 @@ export type EventMap = {
|
||||
email: string;
|
||||
username: string;
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
/** True when the created account is a temp user (no email/password). */
|
||||
is_temp?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'email.validate': {
|
||||
@@ -154,12 +192,95 @@ export type EventMap = {
|
||||
user_uid: string;
|
||||
email: string;
|
||||
};
|
||||
// Phone-reuse cap is pure mechanism here — the abuse extension answers
|
||||
// (emitted via `emitAndWait`) by counting how many OTHER accounts have
|
||||
// already verified this number and flipping `allowed` to false when the
|
||||
// cross-account limit is hit. No extension listening → `allowed` stays
|
||||
// true and the send proceeds.
|
||||
'puter.phone-verification.check': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap sends per device (across accounts) so a
|
||||
// device farm can't dodge the per-account cap with fresh signups.
|
||||
device_fingerprint: string | null;
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Fire-and-forget signal that a code was actually sent — the abuse
|
||||
// extension bumps its per-number / per-account / per-device send-velocity
|
||||
// counters off this. No-op with no extension listening.
|
||||
'puter.phone-verification.sent': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
device_fingerprint: string | null;
|
||||
};
|
||||
'user.phone-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
};
|
||||
// Card verification is pure mechanism here — a payments extension fills
|
||||
// these in (emitted via `emitAndWait`). `enabled` stays null when no
|
||||
// extension is installed; the extension always sets it (true/false) so
|
||||
// the endpoints can distinguish "disabled" from "not installed".
|
||||
'puter.card-verification.setup': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
ip?: string | null;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap card-verification setups per device (across
|
||||
// accounts) before any Stripe SetupIntent is created.
|
||||
device_fingerprint: 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
|
||||
// when allowed or with no extension listening.
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
client_secret: string | null;
|
||||
publishable_key: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
setup_intent_id: string;
|
||||
enabled: boolean | null;
|
||||
verified: boolean;
|
||||
reason: string | null;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'user.card-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
};
|
||||
'user.username-changed': {
|
||||
user_id: number;
|
||||
old_username: string;
|
||||
new_username: string;
|
||||
};
|
||||
'user.email-changed': { user_id: number; new_email: string };
|
||||
// Fired after an account is torn down (self-serve, admin, or temp-user
|
||||
// logout cleanup). Listeners purge external state tied to the account —
|
||||
// e.g. the marketplace extension cancels the user's Stripe subscriptions.
|
||||
// The row is already gone by emit time, so identifiers ride the payload.
|
||||
'user.delete': {
|
||||
user_id: number;
|
||||
user_uuid?: string;
|
||||
stripe_customer_id?: string | null;
|
||||
};
|
||||
|
||||
// ---- Filesystem ----
|
||||
'fs.copy.node': {
|
||||
@@ -211,13 +332,19 @@ export type EventMap = {
|
||||
entry: unknown;
|
||||
host: string;
|
||||
requestPath: string;
|
||||
requestUrl?: string;
|
||||
requestHash?: string;
|
||||
mime: string;
|
||||
};
|
||||
|
||||
// ---- Thumbnails ----
|
||||
// The listener rewrites `thumbnail` in place (an s3:// key or legacy
|
||||
// https:// URL becomes a signed URL) and falls back to `uuid`/`uid` to
|
||||
// migrate inline data-URL thumbnails. Declared to match: the previous
|
||||
// `uri` member was never emitted nor read.
|
||||
'thumbnail.read': {
|
||||
uri: string;
|
||||
size?: string;
|
||||
uuid?: string;
|
||||
uid?: string;
|
||||
thumbnail?: string | null;
|
||||
};
|
||||
'thumbnail.created': { url: string };
|
||||
@@ -257,6 +384,96 @@ export type EventMap = {
|
||||
// SocketService re-emits each fanout-eligible event under
|
||||
// `sent-to-user.<wireName>` so per-user channels can subscribe by wire name.
|
||||
[K in `sent-to-user.${string}`]: { user_id: number; response: unknown };
|
||||
} & {
|
||||
// Generic per-driver-method lifecycle, emitted by DriverController for
|
||||
// EVERY driver call under a key scoped to the interface + method:
|
||||
// `driver.<iface>.<method>.before|after|error|reject`. Wildcard
|
||||
// subscribers can listen to `driver.*` for everything, `driver.<iface>.*`
|
||||
// for one interface, or the exact key for one method. The `.before` phase
|
||||
// is emitted via `emitAndWait`, so a listener may set `allow = false`
|
||||
// (with an optional `rejectReason`) to veto the call before it runs — a
|
||||
// vetoed call emits `.reject` instead of running.
|
||||
[K in `driver.${string}`]: DriverMethodLifecycleEvent;
|
||||
} & {
|
||||
// Generic per-route-endpoint lifecycle, emitted by the route materializer
|
||||
// for EVERY non-middleware route under a key scoped to the HTTP method +
|
||||
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
|
||||
// wildcard + veto semantics as the driver lifecycle above.
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
|
||||
* listener vetoes the call (sets `allow = false`); the call never runs and no
|
||||
* `after`/`error` follows.
|
||||
*/
|
||||
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
|
||||
|
||||
/**
|
||||
* Payload for `driver.<iface>.<method>.<phase>` events.
|
||||
*
|
||||
* One shape across all phases; read `phase` (or the key suffix) to branch.
|
||||
* `allow`/`rejectReason` are only meaningful on the `before` phase (emitted via
|
||||
* `emitAndWait`).
|
||||
*/
|
||||
export type DriverMethodLifecycleEvent = {
|
||||
phase: LifecyclePhase;
|
||||
iface: string;
|
||||
method: string;
|
||||
/** Resolved concrete driver name. */
|
||||
driver: string;
|
||||
/** Full actor object, if the request is authenticated. */
|
||||
actor?: Actor;
|
||||
/** Stable actor id (see `actorUid`), if the request is authenticated. */
|
||||
actorUid?: string;
|
||||
/** Call arguments. Present on every phase. */
|
||||
args?: unknown;
|
||||
/** Return value. Present on `after`. */
|
||||
result?: unknown;
|
||||
/** Thrown error. Present on `error`. */
|
||||
error?: unknown;
|
||||
/** Wall-clock duration of the invocation. Present on `after`/`error`. */
|
||||
durationMs?: number;
|
||||
/** Veto channel for `before`: set `false` to block the call. */
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload for `route.<method>.<path>.<phase>` events. Mirrors
|
||||
* {@link DriverMethodLifecycleEvent} for HTTP endpoints.
|
||||
*/
|
||||
export type RouteLifecycleEvent = {
|
||||
phase: LifecyclePhase;
|
||||
/** HTTP method, lowercased (`get`, `post`, ...). */
|
||||
method: string;
|
||||
/** Full route path including the controller prefix. */
|
||||
path: string;
|
||||
/**
|
||||
* The live express request/response for this call. Present on every phase.
|
||||
* On `before` a listener can read the parsed body/headers or write its own
|
||||
* response; on terminal phases they're useful for logging. These are real
|
||||
* in-process objects — never serialize or forward them across nodes.
|
||||
*/
|
||||
req: ExpressRequest;
|
||||
res: ExpressResponse;
|
||||
/** Full actor object, if the request is authenticated. */
|
||||
actor?: Actor;
|
||||
/** Stable actor id (see `actorUid`), if the request is authenticated. */
|
||||
actorUid?: string;
|
||||
/** Response status code. Present on `after`/`error`. */
|
||||
statusCode?: number;
|
||||
/** Wall-clock duration from `before` to terminal phase. */
|
||||
durationMs?: number;
|
||||
/** Set when the request did not complete normally (abort / >=500). */
|
||||
error?: unknown;
|
||||
/** Veto channel for `before`: set `false` to block the request. */
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
};
|
||||
|
||||
export type EventKey = keyof EventMap & string;
|
||||
@@ -265,11 +482,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>;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { EventClient } from './event/EventClient';
|
||||
import { DDBClient } from './dynamodb/DDBClient';
|
||||
import { RedisClient } from './redis/RedisClient';
|
||||
import { S3Client } from './s3/S3Client';
|
||||
import { PreludeClient } from './prelude/PreludeClient';
|
||||
import type { IPuterClientRegistry } from './types';
|
||||
|
||||
export const puterClients = {
|
||||
@@ -34,4 +35,5 @@ export const puterClients = {
|
||||
dynamo: DDBClient,
|
||||
redis: RedisClient,
|
||||
s3: S3Client,
|
||||
prelude: PreludeClient,
|
||||
} satisfies IPuterClientRegistry;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PreludeClient } from './PreludeClient';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const makeClient = (apiKey?: string) =>
|
||||
new PreludeClient({
|
||||
prelude: apiKey ? { apiKey } : undefined,
|
||||
} as unknown as IConfig);
|
||||
|
||||
const okJson = (body: unknown) =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('PreludeClient', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('isConfigured reflects whether an apiKey is set', () => {
|
||||
expect(makeClient('sk_test').isConfigured()).toBe(true);
|
||||
expect(makeClient().isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
describe('isCountrySupported (€0.07 cap)', () => {
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
it('allows revenue markets up to the cap (incl. the priciest)', () => {
|
||||
expect(client.isCountrySupported('US')).toBe(true); // €0.0043
|
||||
expect(client.isCountrySupported('DE')).toBe(true); // €0.0598
|
||||
expect(client.isCountrySupported('SA')).toBe(true); // €0.0638
|
||||
expect(client.isCountrySupported('us')).toBe(true); // case-insensitive
|
||||
});
|
||||
|
||||
it('rejects countries above the cap, with no SMS, or unknown', () => {
|
||||
expect(client.isCountrySupported('PK')).toBe(false); // €0.3548
|
||||
expect(client.isCountrySupported('ID')).toBe(false); // €0.2430
|
||||
expect(client.isCountrySupported('LI')).toBe(false); // null (no SMS)
|
||||
expect(client.isCountrySupported('ZZ')).toBe(false); // unknown
|
||||
expect(client.isCountrySupported(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a configured maxSmsCostEur override', () => {
|
||||
const strict = new PreludeClient({
|
||||
prelude: { apiKey: 'sk', maxSmsCostEur: 0.01 },
|
||||
} as unknown as IConfig);
|
||||
expect(strict.isCountrySupported('US')).toBe(true); // €0.0043
|
||||
expect(strict.isCountrySupported('DE')).toBe(false); // €0.0598 > 0.01
|
||||
|
||||
const loose = new PreludeClient({
|
||||
prelude: { apiKey: 'sk', maxSmsCostEur: 0.5 },
|
||||
} as unknown as IConfig);
|
||||
expect(loose.isCountrySupported('PK')).toBe(true); // €0.3548 <= 0.5
|
||||
});
|
||||
});
|
||||
|
||||
it('createVerification POSTs the phone target + ip signal with bearer auth', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({ id: 'vrf_1', status: 'success' }),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
});
|
||||
|
||||
expect(res).toEqual({ id: 'vrf_1', status: 'success' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.prelude.dev/v2/verification');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Bearer sk_test');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
target: { type: 'phone_number', value: '+14155550123' },
|
||||
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
|
||||
// hardcoded to en-US so the message text is always English.
|
||||
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
|
||||
signals: { ip: '203.0.113.7' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the delivery channel sequence Prelude reports', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({
|
||||
id: 'vrf_1',
|
||||
status: 'success',
|
||||
channels: ['whatsapp', 'sms'],
|
||||
}),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123');
|
||||
|
||||
expect(res.channels).toEqual(['whatsapp', 'sms']);
|
||||
});
|
||||
|
||||
it('forwards device_id and user_agent signals when supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
device_id: 'thumb_abc123',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(init.body).signals).toEqual({
|
||||
ip: '203.0.113.7',
|
||||
device_id: 'thumb_abc123',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the signals object entirely when none are supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123');
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
'signals',
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards dispatch_id as a top-level field, not inside signals', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
dispatch_id: 'd1f5e9a0-0000-4000-8000-000000000000',
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.dispatch_id).toBe('d1f5e9a0-0000-4000-8000-000000000000');
|
||||
expect(body.signals).toEqual({ ip: '203.0.113.7' });
|
||||
expect(body.signals).not.toHaveProperty('dispatch_id');
|
||||
});
|
||||
|
||||
it('omits dispatch_id when not supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', { ip: '203.0.113.7' });
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
'dispatch_id',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes a configured template_id + sender_id + preferred channel', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = new PreludeClient({
|
||||
prelude: {
|
||||
apiKey: 'sk_test',
|
||||
templateId: 'tmpl_puter',
|
||||
senderId: 'Puter',
|
||||
preferredChannel: 'sms',
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
await client.createVerification('+14155550123');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(init.body).options).toEqual({
|
||||
code_size: 6,
|
||||
preferred_channel: 'sms', // config override beats the rcs default
|
||||
locale: 'en-US',
|
||||
template_id: 'tmpl_puter',
|
||||
sender_id: 'Puter',
|
||||
});
|
||||
});
|
||||
|
||||
it('checkVerification POSTs target + code and returns the status', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.checkVerification('+14155550123', '123456');
|
||||
|
||||
expect(res).toEqual({ status: 'success' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.prelude.dev/v2/verification/check');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
target: { type: 'phone_number', value: '+14155550123' },
|
||||
code: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws (does not call fetch) when not configured', async () => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
client.createVerification('+14155550123'),
|
||||
).rejects.toThrow(/not configured/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-2xx Prelude response', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ message: 'bad target' }),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
makeClient('sk_test').checkVerification('+1', 'x'),
|
||||
).rejects.toThrow(/Prelude .* failed: 400/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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 { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import { COUNTRY_SMS_PRICES } from './countries.js';
|
||||
|
||||
const PRELUDE_API_BASE = 'https://api.prelude.dev/v2';
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
/** OTP length — matches the 6-box code UI. Prelude allows 4–8. */
|
||||
const PRELUDE_CODE_SIZE = 6;
|
||||
/**
|
||||
* Channel Prelude prioritizes for delivery. RCS is far cheaper than SMS, so we
|
||||
* prefer it by default; Prelude routes to the next reachable channel (SMS) when
|
||||
* RCS isn't available on the destination. (Requires an RCS agent provisioned in
|
||||
* the Prelude account to actually use RCS — otherwise it just falls through to
|
||||
* SMS.) Override with `config.prelude.preferredChannel`.
|
||||
*/
|
||||
const DEFAULT_PREFERRED_CHANNEL: PreludeChannel = 'rcs';
|
||||
|
||||
/** Delivery channels Prelude can prioritize via `options.preferred_channel`. */
|
||||
export type PreludeChannel =
|
||||
| 'sms'
|
||||
| 'rcs'
|
||||
| 'whatsapp'
|
||||
| 'viber'
|
||||
| 'zalo'
|
||||
| 'telegram';
|
||||
/**
|
||||
* Channels Prelude reports in a verification's `channels` array — the ordered
|
||||
* delivery sequence, first entry first. Superset of `PreludeChannel`: 'silent'
|
||||
* and 'voice' can appear as delivery methods but can't be preferred.
|
||||
*/
|
||||
export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice';
|
||||
/**
|
||||
* Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds
|
||||
* this — or that have no SMS channel — are not offered phone verification. The
|
||||
* cap covers every realistic revenue market (priciest are Germany €0.0598 and
|
||||
* Saudi Arabia €0.0638) while excluding the expensive, high-fraud long tail.
|
||||
* Override per-deployment with `config.prelude.maxSmsCostEur`.
|
||||
*/
|
||||
const DEFAULT_MAX_SMS_COST_EUR = 0.07;
|
||||
|
||||
/** Status returned by Prelude when creating/retrying a verification. */
|
||||
export type PreludeCreateStatus =
|
||||
| 'success'
|
||||
| 'retry'
|
||||
| 'challenged'
|
||||
| 'blocked'
|
||||
| 'shadow_blocked';
|
||||
|
||||
/** Status returned by Prelude when checking a code. */
|
||||
export type PreludeCheckStatus =
|
||||
| 'success'
|
||||
| 'failure'
|
||||
| 'expired_or_not_found'
|
||||
| 'transaction_missing'
|
||||
| 'transaction_mismatch';
|
||||
|
||||
/**
|
||||
* Prelude Verify v2 client (https://docs.prelude.so/verify/v2). Sends and checks
|
||||
* SMS one-time codes — Prelude generates, delivers, and validates the code, so
|
||||
* we never store one ourselves. No-ops with a warning when no API key is
|
||||
* configured (so dev environments don't crash); `isConfigured()` lets callers
|
||||
* surface a clean "phone verification unavailable" error instead.
|
||||
*/
|
||||
export class PreludeClient extends PuterClient {
|
||||
private apiKey: string | null = null;
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
this.apiKey = config.prelude?.apiKey ?? null;
|
||||
}
|
||||
|
||||
override onServerStart(): void {
|
||||
if (!this.apiKey) {
|
||||
console.warn(
|
||||
'[prelude] no apiKey configured — SMS phone verification is disabled',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when an API key is configured and verification can be attempted. */
|
||||
isConfigured(): boolean {
|
||||
return !!this.apiKey;
|
||||
}
|
||||
|
||||
/** ISO region used to parse local-format numbers (config-driven). */
|
||||
get defaultCountry(): string | undefined {
|
||||
return this.config.prelude?.defaultCountry;
|
||||
}
|
||||
|
||||
/** Per-SMS cost ceiling in EUR (config override or the default cap). */
|
||||
get maxSmsCostEur(): number {
|
||||
return this.config.prelude?.maxSmsCostEur ?? DEFAULT_MAX_SMS_COST_EUR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether SMS verification should be offered for a country. False when the
|
||||
* country is unknown, has no SMS channel, or its rate exceeds the cost cap.
|
||||
* @param iso ISO-3166 alpha-2 (e.g. 'US') — from the parsed phone number.
|
||||
*/
|
||||
isCountrySupported(iso: string | undefined): boolean {
|
||||
if (!iso) return false; // couldn't determine country → can't price it
|
||||
const price = COUNTRY_SMS_PRICES[iso.toUpperCase()];
|
||||
if (!price || price.sms == null) return false;
|
||||
return price.sms <= this.maxSmsCostEur;
|
||||
}
|
||||
|
||||
async createVerification(
|
||||
target: string,
|
||||
signals: {
|
||||
ip?: string;
|
||||
device_id?: string;
|
||||
user_agent?: string;
|
||||
dispatch_id?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
id?: string;
|
||||
status: PreludeCreateStatus;
|
||||
channels?: PreludeDeliveryChannel[];
|
||||
}> {
|
||||
// Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without
|
||||
// code_size Prelude uses the dashboard default (4). preferred_channel
|
||||
// prioritizes RCS (cheaper); Prelude falls back to SMS when unavailable.
|
||||
const options: Record<string, unknown> = {
|
||||
code_size: PRELUDE_CODE_SIZE,
|
||||
preferred_channel:
|
||||
this.config.prelude?.preferredChannel ??
|
||||
DEFAULT_PREFERRED_CHANNEL,
|
||||
locale: 'en-US',
|
||||
};
|
||||
// Branding lives in the Prelude dashboard (the message text is a
|
||||
// template); these just select a Puter-branded template / sender when
|
||||
// configured. See IPreludeConfig.
|
||||
const { templateId, senderId } = this.config.prelude ?? {};
|
||||
if (templateId) options.template_id = templateId;
|
||||
if (senderId) options.sender_id = senderId;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
target: { type: 'phone_number', value: target },
|
||||
options,
|
||||
};
|
||||
// Only attach signals we actually have — Prelude treats the object as
|
||||
// optional and an empty one adds nothing.
|
||||
const sig: Record<string, string> = {};
|
||||
if (signals.ip) sig.ip = signals.ip;
|
||||
if (signals.device_id) sig.device_id = signals.device_id;
|
||||
if (signals.user_agent) sig.user_agent = signals.user_agent;
|
||||
if (Object.keys(sig).length > 0) body.signals = sig;
|
||||
// dispatch_id is a top-level field, not a member of `signals`.
|
||||
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
|
||||
return this.#post('/verification', body) as Promise<{
|
||||
id?: string;
|
||||
status: PreludeCreateStatus;
|
||||
channels?: PreludeDeliveryChannel[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a code the user entered against the active verification for `target`.
|
||||
* @returns `{ status }` — `'success'` means verified.
|
||||
*/
|
||||
async checkVerification(
|
||||
target: string,
|
||||
code: string,
|
||||
): Promise<{ status: PreludeCheckStatus }> {
|
||||
return this.#post('/verification/check', {
|
||||
target: { type: 'phone_number', value: target },
|
||||
code,
|
||||
}) as Promise<{ status: PreludeCheckStatus }>;
|
||||
}
|
||||
|
||||
async #post(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Prelude is not configured');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${PRELUDE_API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const json = (await res.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Prelude ${path} failed: ${res.status} ${
|
||||
(json as { message?: string })?.message ?? ''
|
||||
}`.trim(),
|
||||
);
|
||||
}
|
||||
return json;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** Per-country Prelude SMS pricing, hardcoded from the Prelude global price
|
||||
* sheet. `sms` is the average cost in EUR, or null when SMS is unavailable
|
||||
* there. Used to cap phone verification to affordable countries (see
|
||||
* PreludeClient.isCountrySupported). Regenerate from the price sheet when
|
||||
* Prelude updates rates. */
|
||||
export interface CountryPrice {
|
||||
name: string;
|
||||
/** Prelude SMS average in EUR, or null when SMS is not available. */
|
||||
sms: number | null;
|
||||
}
|
||||
|
||||
export const COUNTRY_SMS_PRICES: Record<string, CountryPrice> = {
|
||||
AD: {
|
||||
name: 'Andorra',
|
||||
sms: 0.0515,
|
||||
},
|
||||
AE: {
|
||||
name: 'United Arab Emirates',
|
||||
sms: 0.0145,
|
||||
},
|
||||
AF: {
|
||||
name: 'Afghanistan',
|
||||
sms: 0.2409,
|
||||
},
|
||||
AG: {
|
||||
name: 'Antigua and Barbuda',
|
||||
sms: 0.0755,
|
||||
},
|
||||
AI: {
|
||||
name: 'Anguilla',
|
||||
sms: 0.0765,
|
||||
},
|
||||
AL: {
|
||||
name: 'Albania',
|
||||
sms: 0.0345,
|
||||
},
|
||||
AM: {
|
||||
name: 'Armenia',
|
||||
sms: 0.118,
|
||||
},
|
||||
AN: {
|
||||
name: 'Netherlands Antilles',
|
||||
sms: null,
|
||||
},
|
||||
AO: {
|
||||
name: 'Angola',
|
||||
sms: 0.0347,
|
||||
},
|
||||
AR: {
|
||||
name: 'Argentina',
|
||||
sms: 0.0614,
|
||||
},
|
||||
AS: {
|
||||
name: 'American Samoa',
|
||||
sms: 0.077,
|
||||
},
|
||||
AT: {
|
||||
name: 'Austria',
|
||||
sms: 0.014,
|
||||
},
|
||||
AU: {
|
||||
name: 'Australia',
|
||||
sms: 0.0091,
|
||||
},
|
||||
AW: {
|
||||
name: 'Aruba',
|
||||
sms: 0.0617,
|
||||
},
|
||||
AZ: {
|
||||
name: 'Azerbaijan',
|
||||
sms: 0.0969,
|
||||
},
|
||||
BA: {
|
||||
name: 'Bosnia and Herzegovina',
|
||||
sms: 0.0801,
|
||||
},
|
||||
BB: {
|
||||
name: 'Barbados',
|
||||
sms: 0.2021,
|
||||
},
|
||||
BD: {
|
||||
name: 'Bangladesh',
|
||||
sms: 0.1844,
|
||||
},
|
||||
BE: {
|
||||
name: 'Belgium',
|
||||
sms: 0.0297,
|
||||
},
|
||||
BF: {
|
||||
name: 'Burkina Faso',
|
||||
sms: 0.063,
|
||||
},
|
||||
BG: {
|
||||
name: 'Bulgaria',
|
||||
sms: 0.0799,
|
||||
},
|
||||
BH: {
|
||||
name: 'Bahrain',
|
||||
sms: 0.014,
|
||||
},
|
||||
BI: {
|
||||
name: 'Burundi',
|
||||
sms: 0.217,
|
||||
},
|
||||
BJ: {
|
||||
name: 'Benin',
|
||||
sms: 0.13,
|
||||
},
|
||||
BM: {
|
||||
name: 'Bermuda',
|
||||
sms: 0.21,
|
||||
},
|
||||
BN: {
|
||||
name: 'Brunei',
|
||||
sms: 0.035,
|
||||
},
|
||||
BO: {
|
||||
name: 'Bolivia',
|
||||
sms: 0.0708,
|
||||
},
|
||||
BQ: {
|
||||
name: 'Caribbean Netherlands',
|
||||
sms: 0.0565,
|
||||
},
|
||||
BR: {
|
||||
name: 'Brazil',
|
||||
sms: 0.0086,
|
||||
},
|
||||
BS: {
|
||||
name: 'Bahamas',
|
||||
sms: 0.0672,
|
||||
},
|
||||
BT: {
|
||||
name: 'Bhutan',
|
||||
sms: null,
|
||||
},
|
||||
BW: {
|
||||
name: 'Botswana',
|
||||
sms: 0.0235,
|
||||
},
|
||||
BY: {
|
||||
name: 'Belarus',
|
||||
sms: 0.118,
|
||||
},
|
||||
BZ: {
|
||||
name: 'Belize',
|
||||
sms: 0.1529,
|
||||
},
|
||||
CA: {
|
||||
name: 'Canada',
|
||||
sms: 0.0052,
|
||||
},
|
||||
CD: {
|
||||
name: 'Congo RDC',
|
||||
sms: 0.1112,
|
||||
},
|
||||
CF: {
|
||||
name: 'Central African Republic',
|
||||
sms: 0.2,
|
||||
},
|
||||
CG: {
|
||||
name: 'Congo',
|
||||
sms: 0.2507,
|
||||
},
|
||||
CH: {
|
||||
name: 'Switzerland',
|
||||
sms: 0.0163,
|
||||
},
|
||||
CI: {
|
||||
name: "Cote d'Ivoire",
|
||||
sms: 0.19,
|
||||
},
|
||||
CK: {
|
||||
name: 'Cook Islands',
|
||||
sms: 0.1041,
|
||||
},
|
||||
CL: {
|
||||
name: 'Chile',
|
||||
sms: 0.0027,
|
||||
},
|
||||
CM: {
|
||||
name: 'Cameroon',
|
||||
sms: 0.1504,
|
||||
},
|
||||
CN: {
|
||||
name: 'China',
|
||||
sms: 0.0053,
|
||||
},
|
||||
CO: {
|
||||
name: 'Colombia',
|
||||
sms: 0.0008,
|
||||
},
|
||||
CR: {
|
||||
name: 'Costa Rica',
|
||||
sms: 0.0045,
|
||||
},
|
||||
CU: {
|
||||
name: 'Cuba',
|
||||
sms: 0.0512,
|
||||
},
|
||||
CV: {
|
||||
name: 'Cabo Verde',
|
||||
sms: 0.2031,
|
||||
},
|
||||
CW: {
|
||||
name: 'Curacao',
|
||||
sms: 0.0138,
|
||||
},
|
||||
CY: {
|
||||
name: 'Northern Cyprus',
|
||||
sms: 0.0065,
|
||||
},
|
||||
CZ: {
|
||||
name: 'Czech Republic',
|
||||
sms: 0.0299,
|
||||
},
|
||||
DE: {
|
||||
name: 'Germany',
|
||||
sms: 0.0598,
|
||||
},
|
||||
DJ: {
|
||||
name: 'Djibouti',
|
||||
sms: 0.0897,
|
||||
},
|
||||
DK: {
|
||||
name: 'Denmark',
|
||||
sms: 0.0301,
|
||||
},
|
||||
DM: {
|
||||
name: 'Dominica',
|
||||
sms: 0.0824,
|
||||
},
|
||||
DO: {
|
||||
name: 'Dominican Republic',
|
||||
sms: 0.0351,
|
||||
},
|
||||
DZ: {
|
||||
name: 'Algeria',
|
||||
sms: 0.196,
|
||||
},
|
||||
EC: {
|
||||
name: 'Ecuador',
|
||||
sms: 0.1041,
|
||||
},
|
||||
EE: {
|
||||
name: 'Estonia',
|
||||
sms: 0.0234,
|
||||
},
|
||||
EG: {
|
||||
name: 'Egypt',
|
||||
sms: 0.1561,
|
||||
},
|
||||
ER: {
|
||||
name: 'Eritrea',
|
||||
sms: 0.0659,
|
||||
},
|
||||
ES: {
|
||||
name: 'Spain',
|
||||
sms: 0.0193,
|
||||
},
|
||||
ET: {
|
||||
name: 'Ethiopia',
|
||||
sms: 0.2741,
|
||||
},
|
||||
FI: {
|
||||
name: 'Finland',
|
||||
sms: 0.043,
|
||||
},
|
||||
FJ: {
|
||||
name: 'Fiji',
|
||||
sms: 0.069,
|
||||
},
|
||||
FK: {
|
||||
name: 'Falkland Islands',
|
||||
sms: 0.0713,
|
||||
},
|
||||
FM: {
|
||||
name: 'Micronesia',
|
||||
sms: 0.0118,
|
||||
},
|
||||
FO: {
|
||||
name: 'Faroe Islands',
|
||||
sms: 0.0319,
|
||||
},
|
||||
FR: {
|
||||
name: 'France',
|
||||
sms: 0.03,
|
||||
},
|
||||
GA: {
|
||||
name: 'Gabon',
|
||||
sms: 0.15,
|
||||
},
|
||||
GB: {
|
||||
name: 'United Kingdom',
|
||||
sms: 0.026,
|
||||
},
|
||||
GD: {
|
||||
name: 'Grenada',
|
||||
sms: null,
|
||||
},
|
||||
GE: {
|
||||
name: 'Georgia',
|
||||
sms: 0.0788,
|
||||
},
|
||||
GF: {
|
||||
name: 'French Guiana',
|
||||
sms: 0.05,
|
||||
},
|
||||
GG: {
|
||||
name: 'Guernsey',
|
||||
sms: 0.025,
|
||||
},
|
||||
GH: {
|
||||
name: 'Ghana',
|
||||
sms: 0.15,
|
||||
},
|
||||
GI: {
|
||||
name: 'Gibraltar',
|
||||
sms: 0.0134,
|
||||
},
|
||||
GL: {
|
||||
name: 'Greenland',
|
||||
sms: 0.0048,
|
||||
},
|
||||
GM: {
|
||||
name: 'Gambia',
|
||||
sms: 0.1283,
|
||||
},
|
||||
GN: {
|
||||
name: 'Guinea',
|
||||
sms: 0.2,
|
||||
},
|
||||
GP: {
|
||||
name: 'Guadeloupe',
|
||||
sms: 0.0455,
|
||||
},
|
||||
GQ: {
|
||||
name: 'Equatorial Guinea',
|
||||
sms: 0.099,
|
||||
},
|
||||
GR: {
|
||||
name: 'Greece',
|
||||
sms: 0.0296,
|
||||
},
|
||||
GT: {
|
||||
name: 'Guatemala',
|
||||
sms: 0.1202,
|
||||
},
|
||||
GU: {
|
||||
name: 'Guam',
|
||||
sms: 0.02,
|
||||
},
|
||||
GW: {
|
||||
name: 'Guinea-Bissau',
|
||||
sms: 0.1566,
|
||||
},
|
||||
GY: {
|
||||
name: 'Guyana',
|
||||
sms: 0.2178,
|
||||
},
|
||||
HK: {
|
||||
name: 'Hong Kong',
|
||||
sms: 0.038,
|
||||
},
|
||||
HN: {
|
||||
name: 'Honduras',
|
||||
sms: 0.162,
|
||||
},
|
||||
HR: {
|
||||
name: 'Croatia',
|
||||
sms: 0.03,
|
||||
},
|
||||
HT: {
|
||||
name: 'Haiti',
|
||||
sms: 0.09,
|
||||
},
|
||||
HU: {
|
||||
name: 'Hungary',
|
||||
sms: 0.029,
|
||||
},
|
||||
ID: {
|
||||
name: 'Indonesia',
|
||||
sms: 0.243,
|
||||
},
|
||||
IE: {
|
||||
name: 'Ireland',
|
||||
sms: 0.0305,
|
||||
},
|
||||
IL: {
|
||||
name: 'Israel',
|
||||
sms: 0.01,
|
||||
},
|
||||
IM: {
|
||||
name: 'Isle of Man',
|
||||
sms: 0.0385,
|
||||
},
|
||||
IN: {
|
||||
name: 'India',
|
||||
sms: 0.0375,
|
||||
},
|
||||
IQ: {
|
||||
name: 'Iraq',
|
||||
sms: 0.151,
|
||||
},
|
||||
IR: {
|
||||
name: 'Iran',
|
||||
sms: 0.14,
|
||||
},
|
||||
IS: {
|
||||
name: 'Iceland',
|
||||
sms: 0.0493,
|
||||
},
|
||||
IT: {
|
||||
name: 'Italy',
|
||||
sms: 0.0245,
|
||||
},
|
||||
JE: {
|
||||
name: 'Jersey',
|
||||
sms: 0.025,
|
||||
},
|
||||
JM: {
|
||||
name: 'Jamaica',
|
||||
sms: 0.1525,
|
||||
},
|
||||
JO: {
|
||||
name: 'Jordan',
|
||||
sms: 0.2094,
|
||||
},
|
||||
JP: {
|
||||
name: 'Japan',
|
||||
sms: 0.017,
|
||||
},
|
||||
KE: {
|
||||
name: 'Kenya',
|
||||
sms: 0.125,
|
||||
},
|
||||
KG: {
|
||||
name: 'Kyrgyzstan',
|
||||
sms: 0.15,
|
||||
},
|
||||
KH: {
|
||||
name: 'Cambodia',
|
||||
sms: 0.1529,
|
||||
},
|
||||
KI: {
|
||||
name: 'Kiribati',
|
||||
sms: 0.025,
|
||||
},
|
||||
KM: {
|
||||
name: 'Comoros',
|
||||
sms: 0.15,
|
||||
},
|
||||
KN: {
|
||||
name: 'Saint Kitts and Nevis',
|
||||
sms: 0.1295,
|
||||
},
|
||||
KR: {
|
||||
name: 'South Korea',
|
||||
sms: 0.006,
|
||||
},
|
||||
KW: {
|
||||
name: 'Kuwait',
|
||||
sms: 0.145,
|
||||
},
|
||||
KY: {
|
||||
name: 'Cayman Islands',
|
||||
sms: 0.2172,
|
||||
},
|
||||
KZ: {
|
||||
name: 'Kazakhstan',
|
||||
sms: 0.202,
|
||||
},
|
||||
LA: {
|
||||
name: 'Laos',
|
||||
sms: 0.15,
|
||||
},
|
||||
LB: {
|
||||
name: 'Lebanon',
|
||||
sms: 0.153,
|
||||
},
|
||||
LC: {
|
||||
name: 'Saint Lucia',
|
||||
sms: 0.0892,
|
||||
},
|
||||
LI: {
|
||||
name: 'Liechtenstein',
|
||||
sms: null,
|
||||
},
|
||||
LK: {
|
||||
name: 'Sri Lanka',
|
||||
sms: 0.3593,
|
||||
},
|
||||
LR: {
|
||||
name: 'Liberia',
|
||||
sms: 0.1448,
|
||||
},
|
||||
LS: {
|
||||
name: 'Lesotho',
|
||||
sms: 0.0301,
|
||||
},
|
||||
LT: {
|
||||
name: 'Lithuania',
|
||||
sms: 0.0261,
|
||||
},
|
||||
LU: {
|
||||
name: 'Luxembourg',
|
||||
sms: 0.034,
|
||||
},
|
||||
LV: {
|
||||
name: 'Latvia',
|
||||
sms: 0.028,
|
||||
},
|
||||
LY: {
|
||||
name: 'Libya',
|
||||
sms: 0.1898,
|
||||
},
|
||||
MA: {
|
||||
name: 'Morocco',
|
||||
sms: 0.105,
|
||||
},
|
||||
MC: {
|
||||
name: 'Monaco',
|
||||
sms: 0.1219,
|
||||
},
|
||||
MD: {
|
||||
name: 'Moldova',
|
||||
sms: 0.065,
|
||||
},
|
||||
ME: {
|
||||
name: 'Montenegro',
|
||||
sms: 0.07,
|
||||
},
|
||||
MG: {
|
||||
name: 'Madagascar',
|
||||
sms: 0.24,
|
||||
},
|
||||
MH: {
|
||||
name: '',
|
||||
sms: 0.032,
|
||||
},
|
||||
MK: {
|
||||
name: 'Macedonia',
|
||||
sms: 0.0096,
|
||||
},
|
||||
ML: {
|
||||
name: 'Mali',
|
||||
sms: 0.136,
|
||||
},
|
||||
MM: {
|
||||
name: 'Myanmar',
|
||||
sms: 0.3037,
|
||||
},
|
||||
MN: {
|
||||
name: 'Mongolia',
|
||||
sms: 0.183,
|
||||
},
|
||||
MO: {
|
||||
name: 'Macao',
|
||||
sms: 0.005,
|
||||
},
|
||||
MP: {
|
||||
name: 'Northern Mariana Islands',
|
||||
sms: 0.0669,
|
||||
},
|
||||
MQ: {
|
||||
name: 'Martinique',
|
||||
sms: 0.0455,
|
||||
},
|
||||
MR: {
|
||||
name: 'Mauritania',
|
||||
sms: 0.161,
|
||||
},
|
||||
MS: {
|
||||
name: 'Montserrat',
|
||||
sms: 0.0725,
|
||||
},
|
||||
MT: {
|
||||
name: 'Malta',
|
||||
sms: 0.0343,
|
||||
},
|
||||
MU: {
|
||||
name: 'Mauritius',
|
||||
sms: 0.1431,
|
||||
},
|
||||
MV: {
|
||||
name: 'Maldives',
|
||||
sms: 0.145,
|
||||
},
|
||||
MW: {
|
||||
name: 'Malawi',
|
||||
sms: 0.19,
|
||||
},
|
||||
MX: {
|
||||
name: 'Mexico',
|
||||
sms: 0.0021,
|
||||
},
|
||||
MY: {
|
||||
name: 'Malaysia',
|
||||
sms: 0.08,
|
||||
},
|
||||
MZ: {
|
||||
name: 'Mozambique',
|
||||
sms: 0.2266,
|
||||
},
|
||||
NA: {
|
||||
name: 'Namibia',
|
||||
sms: 0.0173,
|
||||
},
|
||||
NC: {
|
||||
name: 'New Caledonia',
|
||||
sms: 0.052,
|
||||
},
|
||||
NE: {
|
||||
name: 'Niger',
|
||||
sms: 0.143,
|
||||
},
|
||||
NG: {
|
||||
name: 'Nigeria',
|
||||
sms: 0.198,
|
||||
},
|
||||
NI: {
|
||||
name: 'Nicaragua',
|
||||
sms: 0.1033,
|
||||
},
|
||||
NL: {
|
||||
name: 'Netherlands',
|
||||
sms: 0.046,
|
||||
},
|
||||
NO: {
|
||||
name: 'Norway',
|
||||
sms: 0.03,
|
||||
},
|
||||
NP: {
|
||||
name: 'Nepal',
|
||||
sms: 0.1645,
|
||||
},
|
||||
NR: {
|
||||
name: 'Nauru',
|
||||
sms: null,
|
||||
},
|
||||
NU: {
|
||||
name: 'Niue',
|
||||
sms: null,
|
||||
},
|
||||
NZ: {
|
||||
name: 'New Zealand',
|
||||
sms: 0.0372,
|
||||
},
|
||||
OM: {
|
||||
name: 'Oman',
|
||||
sms: 0.0713,
|
||||
},
|
||||
PA: {
|
||||
name: 'Panama',
|
||||
sms: 0.07,
|
||||
},
|
||||
PE: {
|
||||
name: 'Peru',
|
||||
sms: 0.13,
|
||||
},
|
||||
PF: {
|
||||
name: 'French Polynesia',
|
||||
sms: 0.0518,
|
||||
},
|
||||
PG: {
|
||||
name: 'Papua New Guinea',
|
||||
sms: 0.15,
|
||||
},
|
||||
PH: {
|
||||
name: 'Philippines',
|
||||
sms: 0.1237,
|
||||
},
|
||||
PK: {
|
||||
name: 'Pakistan',
|
||||
sms: 0.3548,
|
||||
},
|
||||
PL: {
|
||||
name: 'Poland',
|
||||
sms: 0.011,
|
||||
},
|
||||
PM: {
|
||||
name: 'Saint Pierre and Miquelon',
|
||||
sms: 0.0905,
|
||||
},
|
||||
PR: {
|
||||
name: 'Puerto Rico',
|
||||
sms: 0.02,
|
||||
},
|
||||
PS: {
|
||||
name: 'Palestine',
|
||||
sms: 0.2541,
|
||||
},
|
||||
PT: {
|
||||
name: 'Portugal',
|
||||
sms: 0.009,
|
||||
},
|
||||
PW: {
|
||||
name: 'Palau',
|
||||
sms: null,
|
||||
},
|
||||
PY: {
|
||||
name: 'Paraguay',
|
||||
sms: 0.0236,
|
||||
},
|
||||
QA: {
|
||||
name: 'Qatar',
|
||||
sms: 0.1393,
|
||||
},
|
||||
RE: {
|
||||
name: 'Reunion',
|
||||
sms: 0.034,
|
||||
},
|
||||
RO: {
|
||||
name: 'Romania',
|
||||
sms: 0.0255,
|
||||
},
|
||||
RS: {
|
||||
name: 'Serbia',
|
||||
sms: 0.1935,
|
||||
},
|
||||
RU: {
|
||||
name: 'Russia',
|
||||
sms: 0.2028,
|
||||
},
|
||||
RW: {
|
||||
name: 'Rwanda',
|
||||
sms: 0.1177,
|
||||
},
|
||||
SA: {
|
||||
name: 'Saudi Arabia',
|
||||
sms: 0.0638,
|
||||
},
|
||||
SB: {
|
||||
name: 'Solomon Islands',
|
||||
sms: 0.0357,
|
||||
},
|
||||
SC: {
|
||||
name: 'Seychelles',
|
||||
sms: 0.0404,
|
||||
},
|
||||
SD: {
|
||||
name: 'Sudan',
|
||||
sms: 0.224,
|
||||
},
|
||||
SE: {
|
||||
name: 'Sweden',
|
||||
sms: 0.026,
|
||||
},
|
||||
SG: {
|
||||
name: 'Singapore',
|
||||
sms: 0.0256,
|
||||
},
|
||||
SI: {
|
||||
name: 'Slovenia',
|
||||
sms: 0.1,
|
||||
},
|
||||
SK: {
|
||||
name: 'Slovakia',
|
||||
sms: 0.0245,
|
||||
},
|
||||
SL: {
|
||||
name: 'Sierra Leone',
|
||||
sms: 0.2322,
|
||||
},
|
||||
SM: {
|
||||
name: 'San Marino',
|
||||
sms: null,
|
||||
},
|
||||
SN: {
|
||||
name: 'Senegal',
|
||||
sms: 0.1151,
|
||||
},
|
||||
SO: {
|
||||
name: 'Somalia',
|
||||
sms: 0.06,
|
||||
},
|
||||
SR: {
|
||||
name: 'Suriname',
|
||||
sms: 0.119,
|
||||
},
|
||||
SS: {
|
||||
name: 'South Sudan',
|
||||
sms: 0.15,
|
||||
},
|
||||
ST: {
|
||||
name: 'Sao Tome and Principe',
|
||||
sms: 0.0133,
|
||||
},
|
||||
SV: {
|
||||
name: 'El Salvador',
|
||||
sms: 0.06,
|
||||
},
|
||||
SX: {
|
||||
name: 'Sint Maarten',
|
||||
sms: 0.0623,
|
||||
},
|
||||
SY: {
|
||||
name: 'Syria',
|
||||
sms: 0.221,
|
||||
},
|
||||
SZ: {
|
||||
name: 'Eswatini',
|
||||
sms: 0.12,
|
||||
},
|
||||
TC: {
|
||||
name: 'Turks and Caicos Islands',
|
||||
sms: null,
|
||||
},
|
||||
TD: {
|
||||
name: 'Chad',
|
||||
sms: 0.1711,
|
||||
},
|
||||
TG: {
|
||||
name: 'Togo',
|
||||
sms: 0.1806,
|
||||
},
|
||||
TH: {
|
||||
name: 'Thailand',
|
||||
sms: 0.003,
|
||||
},
|
||||
TJ: {
|
||||
name: 'Tajikistan',
|
||||
sms: 0.2626,
|
||||
},
|
||||
TL: {
|
||||
name: 'Timor-Leste',
|
||||
sms: 0.0675,
|
||||
},
|
||||
TM: {
|
||||
name: 'Turkmenistan',
|
||||
sms: 0.1677,
|
||||
},
|
||||
TN: {
|
||||
name: 'Tunisia',
|
||||
sms: 0.225,
|
||||
},
|
||||
TO: {
|
||||
name: 'Tonga',
|
||||
sms: 0.0902,
|
||||
},
|
||||
TR: {
|
||||
name: 'Turkey',
|
||||
sms: 0.0008,
|
||||
},
|
||||
TT: {
|
||||
name: 'Trinidad and Tobago',
|
||||
sms: 0.1556,
|
||||
},
|
||||
TW: {
|
||||
name: 'Taiwan',
|
||||
sms: 0.0165,
|
||||
},
|
||||
TZ: {
|
||||
name: 'Tanzania',
|
||||
sms: 0.2753,
|
||||
},
|
||||
UA: {
|
||||
name: 'Ukraine',
|
||||
sms: 0.094,
|
||||
},
|
||||
UG: {
|
||||
name: 'Uganda',
|
||||
sms: 0.1707,
|
||||
},
|
||||
US: {
|
||||
name: 'United States',
|
||||
sms: 0.0043,
|
||||
},
|
||||
UY: {
|
||||
name: 'Uruguay',
|
||||
sms: 0.0174,
|
||||
},
|
||||
UZ: {
|
||||
name: 'Uzbekistan',
|
||||
sms: 0.292,
|
||||
},
|
||||
VC: {
|
||||
name: 'Saint Vincent and The Grenadines',
|
||||
sms: 0.1216,
|
||||
},
|
||||
VE: {
|
||||
name: 'Venezuela',
|
||||
sms: 0.0427,
|
||||
},
|
||||
VG: {
|
||||
name: 'British Virgin Islands',
|
||||
sms: 0.083,
|
||||
},
|
||||
VI: {
|
||||
name: 'Virgin Island',
|
||||
sms: 0.0017,
|
||||
},
|
||||
VN: {
|
||||
name: 'Vietnam',
|
||||
sms: 0.0885,
|
||||
},
|
||||
VU: {
|
||||
name: 'Vanuatu',
|
||||
sms: 0.1347,
|
||||
},
|
||||
WF: {
|
||||
name: 'Wallis and Futuna',
|
||||
sms: 0.09,
|
||||
},
|
||||
WS: {
|
||||
name: 'Samoa',
|
||||
sms: 0.2,
|
||||
},
|
||||
XK: {
|
||||
name: 'Kosovo',
|
||||
sms: 0.1394,
|
||||
},
|
||||
YE: {
|
||||
name: 'Yemen',
|
||||
sms: 0.1486,
|
||||
},
|
||||
YT: {
|
||||
name: 'Mayotte',
|
||||
sms: 0.06,
|
||||
},
|
||||
ZA: {
|
||||
name: 'South Africa',
|
||||
sms: 0.039,
|
||||
},
|
||||
ZM: {
|
||||
name: 'Zambia',
|
||||
sms: 0.21,
|
||||
},
|
||||
ZW: {
|
||||
name: 'Zimbabwe',
|
||||
sms: 0.13,
|
||||
},
|
||||
};
|
||||
@@ -56,6 +56,7 @@ export class AppController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
const apps = await this.appDriver.select({
|
||||
@@ -71,6 +72,7 @@ export class AppController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
const name = req.query?.name;
|
||||
@@ -185,6 +187,7 @@ export class AppController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
const raw = req.params.name;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -51,6 +51,7 @@ export class DesktopController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { url, color, fit } = req.body ?? {};
|
||||
@@ -105,6 +106,7 @@ export class DesktopController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { items } = req.body ?? {};
|
||||
@@ -130,6 +132,7 @@ export class DesktopController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, layout } = req.body ?? {};
|
||||
@@ -156,6 +159,7 @@ export class DesktopController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, sort_by, sort_order } =
|
||||
|
||||
@@ -19,9 +19,16 @@
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
InMemorySpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
} from '@opentelemetry/sdk-trace-base';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { actorUid, type Actor } from '../../core/actor.js';
|
||||
import type { DriverMethodLifecycleEvent } from '../../clients/event/types.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -288,6 +295,157 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// A lifecycle hook *exists* on every driver (inherited from PuterDriver),
|
||||
// so the old `typeof driver[method] === 'function'` dispatch would have
|
||||
// invoked it. It must not be reachable over /drivers/call.
|
||||
it.each([
|
||||
'onServerStart',
|
||||
'onServerShutdown',
|
||||
'onServerPrepareShutdown',
|
||||
'getReportedCosts',
|
||||
'constructor',
|
||||
'toString',
|
||||
])('rejects the framework method %s with 404', async (method) => {
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq({ interface: 'puter-kvstore', method }, actor);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('admits a bare session ("root" token) actor on the AI drivers', async () => {
|
||||
// Privileged ("godmode") apps call AI with the user's own session
|
||||
// token, so the AI drivers no longer set `noUserSession`: the
|
||||
// session actor reaches the permission scan (403 forbidden — no
|
||||
// perms granted) instead of the credential-shape rejection.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'complete',
|
||||
args: { messages: [] },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('admits app and access-token actors on the AI drivers (they fail later on permission, not credential shape)', async () => {
|
||||
const base = await makeUserActor();
|
||||
const delegatedActors: Actor[] = [
|
||||
{ ...base, app: { uid: `app-${uuidv4()}` } },
|
||||
{
|
||||
...base,
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer: base,
|
||||
fullAccess: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const actor of delegatedActors) {
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'complete',
|
||||
args: { messages: [] },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
// Fresh actors hold no service:* perms, so the call still
|
||||
// 403s — but at the permission scan, proving the
|
||||
// credential-shape gate let the delegated actor through.
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('admits a user-scoped worker session on the AI drivers', async () => {
|
||||
// Workers deployed without an app binding authenticate as a user
|
||||
// actor whose session row is kind='worker' — they must keep their
|
||||
// AI access (they fail later on permission here, not on
|
||||
// credential shape).
|
||||
const base = await makeUserActor();
|
||||
const actor: Actor = {
|
||||
...base,
|
||||
session: { uid: `sess-${uuidv4()}`, kind: 'worker' },
|
||||
};
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'complete',
|
||||
args: { messages: [] },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('still allows bare session actors on drivers without the noUserSession flag', async () => {
|
||||
// puter-kvstore doesn't set the flag; the session actor reaches
|
||||
// the permission scan (403 forbidden — no perms granted) instead
|
||||
// of being rejected for its credential shape.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key: 'x', value: 'y' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects with 403 when the actor lacks the service permission', async () => {
|
||||
// A fresh user has no service:* perms → permission.check returns
|
||||
// false → 403.
|
||||
@@ -460,6 +618,129 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriverController driver-method lifecycle events', () => {
|
||||
let routes: Captured;
|
||||
beforeAll(() => {
|
||||
routes = captureRoutes(controller);
|
||||
});
|
||||
|
||||
const grantKvPerm = async (actor: Actor) => {
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
actor.user!.id!,
|
||||
'service:puter-kvstore:ii:puter-kvstore',
|
||||
{
|
||||
permission: 'service:puter-kvstore:ii:puter-kvstore',
|
||||
deleted: false,
|
||||
issuer_user_id: actor.user!.id!,
|
||||
} as never,
|
||||
);
|
||||
};
|
||||
|
||||
it('emits before then after around a successful driver method', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await grantKvPerm(actor);
|
||||
|
||||
// Correlate on the actor: each test creates a fresh user, so this
|
||||
// isolates our call from any other traffic on the shared event bus.
|
||||
const who = actorUid(actor);
|
||||
const before: DriverMethodLifecycleEvent[] = [];
|
||||
const after: DriverMethodLifecycleEvent[] = [];
|
||||
server.clients.event.on(
|
||||
'driver.puter-kvstore.set.before',
|
||||
(_k, data) => {
|
||||
if (data.actorUid === who) before.push(data);
|
||||
},
|
||||
);
|
||||
server.clients.event.on(
|
||||
'driver.puter-kvstore.set.after',
|
||||
(_k, data) => {
|
||||
if (data.actorUid === who) after.push(data);
|
||||
},
|
||||
);
|
||||
|
||||
const key = `lc-${uuidv4()}`;
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key, value: 'v' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(before).toHaveLength(1);
|
||||
expect(before[0]).toMatchObject({
|
||||
phase: 'before',
|
||||
iface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
actor,
|
||||
actorUid: who,
|
||||
});
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0].phase).toBe('after');
|
||||
expect(after[0].args).toMatchObject({ key, value: 'v' });
|
||||
expect(typeof after[0].durationMs).toBe('number');
|
||||
});
|
||||
|
||||
it('emits reject and answers 403 when a before listener vetoes', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await grantKvPerm(actor);
|
||||
|
||||
// Scope the veto to a sentinel key so it doesn't affect other calls
|
||||
// sharing this server's event bus.
|
||||
const key = `veto-${uuidv4()}`;
|
||||
const reject: DriverMethodLifecycleEvent[] = [];
|
||||
server.clients.event.on(
|
||||
'driver.puter-kvstore.set.before',
|
||||
(_k, data) => {
|
||||
if ((data.args as { key?: string })?.key === key) {
|
||||
data.allow = false;
|
||||
data.rejectReason = 'blocked in test';
|
||||
}
|
||||
},
|
||||
);
|
||||
server.clients.event.on(
|
||||
'driver.puter-kvstore.set.reject',
|
||||
(_k, data) => {
|
||||
if (data.rejectReason === 'blocked in test') reject.push(data);
|
||||
},
|
||||
);
|
||||
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key, value: 'v' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
expect(reject).toHaveLength(1);
|
||||
expect(reject[0]).toMatchObject({
|
||||
phase: 'reject',
|
||||
method: 'set',
|
||||
args: { key, value: 'v' },
|
||||
rejectReason: 'blocked in test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriverController.#handleListInterfaces', () => {
|
||||
let routes: Captured;
|
||||
beforeAll(() => {
|
||||
@@ -491,4 +772,65 @@ describe('DriverController.registerRoutes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriverController.#handleCall tracing', () => {
|
||||
let routes: Captured;
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new BasicTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
routes = captureRoutes(controller);
|
||||
trace.setGlobalTracerProvider(provider);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await provider.shutdown();
|
||||
trace.disable();
|
||||
});
|
||||
|
||||
it('emits driver + downstream spans around a successful call', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
actor.user!.id!,
|
||||
'service:puter-kvstore:ii:puter-kvstore',
|
||||
{
|
||||
permission: 'service:puter-kvstore:ii:puter-kvstore',
|
||||
deleted: false,
|
||||
issuer_user_id: actor.user!.id!,
|
||||
} as never,
|
||||
);
|
||||
exporter.reset();
|
||||
|
||||
const res = makeRes();
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key: `k-${uuidv4()}`, value: 'v' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
routes['POST /call'](req, res as unknown as Response, () => {}),
|
||||
);
|
||||
expect((res.body as { success: boolean }).success).toBe(true);
|
||||
|
||||
const names = exporter.getFinishedSpans().map((s) => s.name);
|
||||
const driverSpan = exporter
|
||||
.getFinishedSpans()
|
||||
.find((s) => s.name === 'driver.puter-kvstore.set');
|
||||
expect(driverSpan).toBeDefined();
|
||||
expect(driverSpan!.attributes).toMatchObject({
|
||||
driver: 'puter-kvstore',
|
||||
'driver.method': 'set',
|
||||
'driver.name': 'puter-kvstore',
|
||||
});
|
||||
// The call path exercises the permission gate and the KV store's
|
||||
// dynamo writes — both should have produced spans of their own.
|
||||
expect(names).toContain('permission.scan');
|
||||
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
void vi; // unused but reserved for future expansion
|
||||
@@ -3,24 +3,27 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { actorUid } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { Controller } from '../../core/http/decorators.js';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import { assertNotUserSession } from '../../core/http/middleware/gates.js';
|
||||
import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
@@ -29,12 +32,15 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
resolveDriverMeta,
|
||||
resolveDriverMethodConcurrent,
|
||||
resolveDriverMethodRateLimit,
|
||||
} from '../../drivers/meta.js';
|
||||
import type { PermissionService } from '../../services/permission/PermissionService.js';
|
||||
import { PermissionUtil } from '../../services/permission/permissionUtil.js';
|
||||
import type { WithLifecycle } from '../../types';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
type DriverInstance = WithLifecycle & Record<string, unknown>;
|
||||
@@ -111,15 +117,23 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
|
||||
@Controller('/drivers')
|
||||
export class DriverController extends PuterController {
|
||||
/** iface → Map<driverName, driverInstance> */
|
||||
/** Iface → Map<driverName, driverInstance> */
|
||||
#drivers = new Map<string, Map<string, DriverInstance>>();
|
||||
/** iface → default driver name */
|
||||
/** Iface → default driver name */
|
||||
#defaults = new Map<string, string>();
|
||||
/**
|
||||
* driver instance → resolved meta. Cached so the per-call rate-limit
|
||||
* lookup doesn't have to walk prototype chains on every request.
|
||||
* Driver instance → resolved meta. Cached so the per-call rate-limit lookup
|
||||
* doesn't have to walk prototype chains on every request.
|
||||
*/
|
||||
#meta = new WeakMap<DriverInstance, DriverMeta>();
|
||||
/**
|
||||
* Driver instance → the set of method names callable via `/drivers/call`.
|
||||
* Resolved once at registration (server startup) via
|
||||
* `resolveCallableMethods`; the request path only does a `Set.has` lookup.
|
||||
* This is what stops framework/lifecycle methods (`onServerStart`, etc.)
|
||||
* and `Object.prototype` members from being invoked by remote callers.
|
||||
*/
|
||||
#callableMethods = new WeakMap<DriverInstance, Set<string>>();
|
||||
|
||||
constructor(...args: ConstructorParameters<typeof PuterController>) {
|
||||
super(...args);
|
||||
@@ -198,14 +212,19 @@ export class DriverController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const fn = driver[method];
|
||||
if (typeof fn !== 'function') {
|
||||
// Only methods in the pre-resolved callable set are dispatchable.
|
||||
// This excludes framework/lifecycle hooks (onServerStart, etc.),
|
||||
// inherited base methods, and Object.prototype members, none of
|
||||
// which are part of any interface's RPC contract.
|
||||
const callable = this.#callableMethods.get(driver);
|
||||
if (!callable?.has(method)) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
`Method '${method}' not found on driver '${ifaceName}'`,
|
||||
{ legacyCode: 'not_found' },
|
||||
);
|
||||
}
|
||||
const fn = driver[method];
|
||||
|
||||
// Resolve the concrete driver name for permission keys, falling
|
||||
// back through prototype metadata → instance field → requested name.
|
||||
@@ -216,12 +235,35 @@ export class DriverController extends PuterController {
|
||||
requestedDriver ??
|
||||
'unknown';
|
||||
|
||||
const driverMeta = this.#meta.get(driver);
|
||||
|
||||
// Drivers flagged `noUserSession` refuse the bare
|
||||
// account-session ("root") token: callers must present an app or
|
||||
// worker token, or an API token minted from the dashboard. This is
|
||||
// the per-driver counterpart of the `noUserSession` route option —
|
||||
// `/drivers/call` is one shared route, so the flag has to live on
|
||||
// the driver rather than in `RouteOptions`. Checked before the
|
||||
// permission scan so a session-token caller always gets the
|
||||
// credential-shape message, not a permission error.
|
||||
if (driverMeta?.noUserSession) {
|
||||
assertNotUserSession(req.actor);
|
||||
}
|
||||
|
||||
if (req.actor) {
|
||||
const permService = this.services.permission as unknown as
|
||||
| PermissionService
|
||||
| undefined;
|
||||
if (permService) {
|
||||
const permKey = `service:${resolvedDriverName}:ii:${ifaceName}`;
|
||||
// Build via PermissionUtil.join so any `:` in a driver or
|
||||
// interface name is escaped — raw interpolation would let a
|
||||
// crafted name shift permission-segment boundaries and match
|
||||
// a broader/narrower parent than intended in the scan logic.
|
||||
const permKey = PermissionUtil.join(
|
||||
'service',
|
||||
String(resolvedDriverName),
|
||||
'ii',
|
||||
ifaceName,
|
||||
);
|
||||
const hasPermission = await permService.check(
|
||||
req.actor,
|
||||
permKey,
|
||||
@@ -245,7 +287,6 @@ export class DriverController extends PuterController {
|
||||
// — 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 driverMeta = this.#meta.get(driver);
|
||||
const rateLimitSpec = resolveDriverMethodRateLimit(
|
||||
driverMeta?.rateLimit,
|
||||
method,
|
||||
@@ -324,14 +365,102 @@ export class DriverController extends PuterController {
|
||||
// a stale value from a prior call.
|
||||
Context.set('driverName', requestedDriver);
|
||||
|
||||
// Per-method lifecycle events, scoped to `driver.<iface>.<method>`.
|
||||
// Subscribers can listen on `driver.*`, `driver.<iface>.*`, or the
|
||||
// exact key. `before` is emitted via `emitAndWait` so a listener may
|
||||
// veto the call by setting `allow = false` (emits `reject`, throws
|
||||
// 403); otherwise `after`/`error` carry the result/error + duration.
|
||||
const actor = req.actor ? actorUid(req.actor) : undefined;
|
||||
const resolved = String(resolvedDriverName);
|
||||
const beforeEvent = {
|
||||
phase: 'before' as const,
|
||||
iface: ifaceName,
|
||||
method,
|
||||
driver: resolved,
|
||||
actor: req.actor,
|
||||
actorUid: actor,
|
||||
args,
|
||||
allow: true as boolean,
|
||||
rejectReason: undefined as string | undefined,
|
||||
};
|
||||
await this.clients.event?.emitAndWait(
|
||||
`driver.${ifaceName}.${method}.before`,
|
||||
beforeEvent,
|
||||
{},
|
||||
);
|
||||
if (beforeEvent.allow === false) {
|
||||
this.clients.event?.emit(
|
||||
`driver.${ifaceName}.${method}.reject`,
|
||||
{
|
||||
phase: 'reject',
|
||||
iface: ifaceName,
|
||||
method,
|
||||
driver: resolved,
|
||||
actor: req.actor,
|
||||
actorUid: actor,
|
||||
args,
|
||||
rejectReason: beforeEvent.rejectReason,
|
||||
},
|
||||
{},
|
||||
);
|
||||
throw new HttpError(
|
||||
403,
|
||||
beforeEvent.rejectReason ??
|
||||
`Blocked by policy: ${ifaceName}:${method}`,
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
// Drivers read actor/context via the Context API — no drilled args.
|
||||
// The span ends when the method returns; for streamed results that
|
||||
// is stream start, not stream drain (same window the lifecycle
|
||||
// events below report as durationMs).
|
||||
const startedAt = Date.now();
|
||||
let result;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result = await (fn as (...x: unknown[]) => any).call(driver, args);
|
||||
result = await withSpan(
|
||||
`driver.${ifaceName}.${method}`,
|
||||
{
|
||||
driver: ifaceName,
|
||||
'driver.method': method,
|
||||
'driver.name': resolved,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (fn as (...x: unknown[]) => any).call(driver, args),
|
||||
);
|
||||
} catch (e) {
|
||||
this.clients.event?.emit(
|
||||
`driver.${ifaceName}.${method}.error`,
|
||||
{
|
||||
phase: 'error',
|
||||
iface: ifaceName,
|
||||
method,
|
||||
driver: resolved,
|
||||
actor: req.actor,
|
||||
actorUid: actor,
|
||||
args,
|
||||
error: e,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
{},
|
||||
);
|
||||
throw translateProviderError(e);
|
||||
}
|
||||
this.clients.event?.emit(
|
||||
`driver.${ifaceName}.${method}.after`,
|
||||
{
|
||||
phase: 'after',
|
||||
iface: ifaceName,
|
||||
method,
|
||||
driver: resolved,
|
||||
actor: req.actor,
|
||||
actorUid: actor,
|
||||
args,
|
||||
result,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
if (isDriverStreamResult(result)) {
|
||||
res.setHeader('Content-Type', result.content_type);
|
||||
@@ -399,6 +528,11 @@ export class DriverController extends PuterController {
|
||||
// Cache the resolved meta so the request hot-path can read the
|
||||
// per-method rate-limit spec without re-walking the prototype.
|
||||
this.#meta.set(instance, meta);
|
||||
// Resolve the callable RPC surface once, at startup. The request
|
||||
// path checks membership against this set instead of reflecting on
|
||||
// the live instance, so lifecycle hooks / inherited framework
|
||||
// methods can never be dispatched.
|
||||
this.#callableMethods.set(instance, resolveCallableMethods(instance));
|
||||
// Register each alias pointing at the same instance. Legacy puter-js
|
||||
// calls that pass a provider id in the `driver` slot (e.g. the TTS
|
||||
// module sends `aws-polly` / `openai-tts` / `elevenlabs-tts` instead
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
* Route-level coverage for `GET /fs/readdir`. The unit tests call the handler
|
||||
* directly, which cannot catch a route that was never registered or a gate that
|
||||
* rejects the request — so this suite drives real HTTP over a listening server,
|
||||
* and authenticates purely through `?auth_token=` (no request headers), which is
|
||||
* the whole point of offering the read as a GET.
|
||||
*/
|
||||
describe('GET /fs/readdir over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const readdirUrl = (params: Record<string, string>) => {
|
||||
const url = new URL('/fs/readdir', env.apiOrigin);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
it('lists a directory authenticated only by a query token', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(
|
||||
readdirUrl({ path: `/${username}`, auth_token: token }),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as Array<{
|
||||
name: string;
|
||||
uuid: string;
|
||||
}>;
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
// Default provisioned home directories.
|
||||
expect(body.map((e) => e.name)).toContain('Documents');
|
||||
for (const entry of body) {
|
||||
expect(entry.uuid).toEqual(expect.any(String));
|
||||
}
|
||||
});
|
||||
|
||||
it('paginates via query params', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(
|
||||
readdirUrl({
|
||||
path: `/${username}`,
|
||||
auth_token: token,
|
||||
limit: '2',
|
||||
cursor: '',
|
||||
includeTotal: 'true',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const page = (await response.json()) as {
|
||||
items: unknown[];
|
||||
cursor?: string;
|
||||
total?: number;
|
||||
};
|
||||
expect(page.items.length).toBe(2);
|
||||
expect(typeof page.total).toBe('number');
|
||||
expect(page.cursor).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated request', async () => {
|
||||
const { username } = env.users.user;
|
||||
const response = await fetch(readdirUrl({ path: `/${username}` }));
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('does not leak internal ids or storage columns over the wire', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(
|
||||
readdirUrl({ path: `/${username}`, auth_token: token }),
|
||||
);
|
||||
const raw = await response.text();
|
||||
const body = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
// Checked per-entry rather than by scanning the raw body: the nested
|
||||
// `associatedApp` payload legitimately carries its own `id` (v1 has
|
||||
// always exposed it), so a substring scan would false-alarm.
|
||||
for (const entry of body) {
|
||||
for (const field of [
|
||||
'id',
|
||||
'parentId',
|
||||
'userId',
|
||||
'associatedAppId',
|
||||
'bucket',
|
||||
'bucketRegion',
|
||||
'publicToken',
|
||||
'fileRequestToken',
|
||||
]) {
|
||||
expect(entry).not.toHaveProperty(field);
|
||||
}
|
||||
}
|
||||
// No user-identifying data (emails, owner records) anywhere.
|
||||
expect(raw).not.toContain('@');
|
||||
expect(raw).not.toMatch(/"(email|owner|user_id|userId)"/);
|
||||
});
|
||||
|
||||
it('lists a nested subtree recursively over GET', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const base = `/${username}/Documents/http-recursive-${Date.now()}`;
|
||||
for (const path of [base, `${base}/a`, `${base}/a/b`]) {
|
||||
const created = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, auth_token: token }),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
readdirUrl({
|
||||
path: base,
|
||||
auth_token: token,
|
||||
recursive: 'true',
|
||||
depth: '5',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const page = (await response.json()) as {
|
||||
items: Array<{ path: string }>;
|
||||
};
|
||||
expect(
|
||||
page.items.map((e) => e.path.slice(base.length + 1)).sort(),
|
||||
).toEqual(['a', 'a/b']);
|
||||
});
|
||||
|
||||
it('mkdir does not return internal ids, storage columns or tokens', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: `/${username}/Documents/http-mkdir-${Date.now()}`,
|
||||
auth_token: token,
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const entry = (await response.json()) as Record<string, unknown>;
|
||||
expect(entry.uuid).toEqual(expect.any(String));
|
||||
expect(entry.isDir).toBe(true);
|
||||
for (const field of [
|
||||
'id',
|
||||
'parentId',
|
||||
'userId',
|
||||
'associatedAppId',
|
||||
'bucket',
|
||||
'bucketRegion',
|
||||
'publicToken',
|
||||
'fileRequestToken',
|
||||
]) {
|
||||
expect(entry).not.toHaveProperty(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('startBatchWrite does not expose storage internals', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(
|
||||
new URL('/fs/startBatchWrite', env.apiOrigin),
|
||||
{
|
||||
method: 'POST',
|
||||
// Array body, so there is no `auth_token` field for the auth
|
||||
// probe to read — authenticate via the header instead.
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/http-signed-${Date.now()}.bin`,
|
||||
size: 4,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const [target] = (await response.json()) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
// What a client actually needs to upload.
|
||||
expect(target!.sessionId).toEqual(expect.any(String));
|
||||
expect(typeof target!.url).toBe('string');
|
||||
// What it does not: where the bytes physically live.
|
||||
for (const field of ['bucket', 'bucketRegion', 'objectKey']) {
|
||||
expect(target).not.toHaveProperty(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('still serves the POST form for existing callers', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(new URL('/fs/readdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: `/${username}`, auth_token: token }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as Array<{ name: string }>;
|
||||
expect(body.map((e) => e.name)).toContain('Documents');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import Busboy from 'busboy';
|
||||
@@ -37,15 +38,27 @@ import {
|
||||
runWithConcurrencyLimit,
|
||||
runWithConcurrencyLimitSettled,
|
||||
} from '../../util/concurrency.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { FS_COSTS } from './costs.js';
|
||||
import {
|
||||
assertAccess as assertLegacyAccess,
|
||||
fsEntryMimeType,
|
||||
loadLegacyAssociatedApps,
|
||||
signEntryThumbnail,
|
||||
toLegacyEntry,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
ClientCompleteWriteResponse,
|
||||
ClientFSEntry,
|
||||
ClientReaddirEntry,
|
||||
ClientSignedWriteResponse,
|
||||
ClientSignMultipartPartsResponse,
|
||||
ClientWriteResponse,
|
||||
CompleteWriteRequest,
|
||||
CompleteWriteResponse,
|
||||
SignedWriteRequest,
|
||||
SignedWriteResponse,
|
||||
SignMultipartPartsRequest,
|
||||
SignMultipartPartsResponse,
|
||||
WriteGuiMetadata,
|
||||
WriteRequest,
|
||||
WriteResponse,
|
||||
@@ -59,7 +72,6 @@ import type {
|
||||
ThumbnailUploadPrepareItem,
|
||||
ThumbnailUploadPreparePayload,
|
||||
} from './types.js';
|
||||
import { toLegacyEntry } from './legacyFsHelpers.js';
|
||||
class UploadProgressTracker implements UploadProgressTrackerLike {
|
||||
total = 0;
|
||||
progress = 0;
|
||||
@@ -88,6 +100,8 @@ class UploadProgressTracker implements UploadProgressTrackerLike {
|
||||
}
|
||||
|
||||
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
|
||||
// Hard cap on how many levels below the target a recursive readdir descends.
|
||||
const MAX_READDIR_DEPTH = 10;
|
||||
const DEFAULT_BATCH_ACL_CHECK_CONCURRENCY = 32;
|
||||
const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8;
|
||||
|
||||
@@ -105,7 +119,7 @@ export class FSController extends PuterController {
|
||||
@Post('/startWrite', { subdomain: 'api', requireVerified: true })
|
||||
async startWrite(
|
||||
req: Request<RouteParams, null, SignedWriteRequest>,
|
||||
res: Response<SignedWriteResponse>,
|
||||
res: Response<ClientSignedWriteResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
@@ -118,6 +132,8 @@ export class FSController extends PuterController {
|
||||
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
|
||||
requestBody.fileMetadata,
|
||||
requestBody,
|
||||
undefined,
|
||||
userId,
|
||||
);
|
||||
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
|
||||
pathAlreadyNormalized: true,
|
||||
@@ -153,13 +169,15 @@ export class FSController extends PuterController {
|
||||
}
|
||||
}, 'emitStartWriteDirectoryEvents');
|
||||
}
|
||||
res.json(response);
|
||||
res.json(
|
||||
this.#withoutStorageInternals(this.#withClientFsEntry(response)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/startBatchWrite', { subdomain: 'api', requireVerified: true })
|
||||
async startBatchWrites(
|
||||
req: Request<RouteParams, null, SignedWriteRequest[]>,
|
||||
res: Response<SignedWriteResponse[]>,
|
||||
res: Response<ClientSignedWriteResponse[]>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
@@ -182,6 +200,7 @@ export class FSController extends PuterController {
|
||||
normalizedRequestBody.fileMetadata,
|
||||
normalizedRequestBody,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
);
|
||||
return normalizedRequestBody;
|
||||
}),
|
||||
@@ -248,13 +267,17 @@ export class FSController extends PuterController {
|
||||
}
|
||||
}, 'emitStartBatchWriteDirectoryEvents');
|
||||
}
|
||||
res.json(responses);
|
||||
res.json(
|
||||
responses.map((r) =>
|
||||
this.#withoutStorageInternals(this.#withClientFsEntry(r)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/completeWrite', { subdomain: 'api', requireVerified: true })
|
||||
async completeWrite(
|
||||
req: Request<RouteParams, null, CompleteWriteRequest>,
|
||||
res: Response<CompleteWriteResponse>,
|
||||
res: Response<ClientCompleteWriteResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const requestBody = this.#withGuiMetadata(req.body, req.body);
|
||||
@@ -274,13 +297,18 @@ export class FSController extends PuterController {
|
||||
},
|
||||
requestBody.guiMetadata,
|
||||
);
|
||||
res.json({ ...response, fsEntry: writeResponse.fsEntry });
|
||||
res.json(
|
||||
this.#withRequiredClientFsEntry({
|
||||
...response,
|
||||
fsEntry: writeResponse.fsEntry,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/completeBatchWrite', { subdomain: 'api', requireVerified: true })
|
||||
async completeBatchWrites(
|
||||
req: Request<RouteParams, null, CompleteWriteRequest[]>,
|
||||
res: Response<CompleteWriteResponse[]>,
|
||||
res: Response<ClientCompleteWriteResponse[]>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const requests = Array.isArray(req.body)
|
||||
@@ -315,7 +343,9 @@ export class FSController extends PuterController {
|
||||
return { ...writeResponse, fsEntry: withSideEffects.fsEntry };
|
||||
},
|
||||
);
|
||||
res.json(updatedResponse);
|
||||
res.json(
|
||||
updatedResponse.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/abortWrite', { subdomain: 'api', requireVerified: true })
|
||||
@@ -337,20 +367,20 @@ export class FSController extends PuterController {
|
||||
@Post('/signMultipartParts', { subdomain: 'api', requireVerified: true })
|
||||
async signMultipartParts(
|
||||
req: Request<RouteParams, null, SignMultipartPartsRequest>,
|
||||
res: Response<SignMultipartPartsResponse>,
|
||||
res: Response<ClientSignMultipartPartsResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const response = await this.services.fs.signMultipartParts(
|
||||
userId,
|
||||
req.body,
|
||||
);
|
||||
res.json(response);
|
||||
res.json(this.#withoutStorageInternals(response));
|
||||
}
|
||||
|
||||
@Post('/write', { subdomain: 'api', requireVerified: true })
|
||||
async write(
|
||||
req: Request<RouteParams, null, WriteRequest>,
|
||||
res: Response<WriteResponse>,
|
||||
res: Response<ClientWriteResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
@@ -363,6 +393,8 @@ export class FSController extends PuterController {
|
||||
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
|
||||
requestBody.fileMetadata,
|
||||
requestBody,
|
||||
undefined,
|
||||
userId,
|
||||
);
|
||||
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
|
||||
pathAlreadyNormalized: true,
|
||||
@@ -388,13 +420,13 @@ export class FSController extends PuterController {
|
||||
response,
|
||||
requestBody.guiMetadata,
|
||||
);
|
||||
res.json(updatedResponse);
|
||||
res.json(this.#withRequiredClientFsEntry(updatedResponse));
|
||||
}
|
||||
|
||||
@Post('/batchWrite', { subdomain: 'api', requireVerified: true })
|
||||
async batchWrites(
|
||||
req: Request<RouteParams, null, WriteRequest[]>,
|
||||
res: Response<WriteResponse[]>,
|
||||
res: Response<ClientWriteResponse[]>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
@@ -496,6 +528,7 @@ export class FSController extends PuterController {
|
||||
item.fileMetadata,
|
||||
item,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
),
|
||||
})),
|
||||
),
|
||||
@@ -695,7 +728,9 @@ export class FSController extends PuterController {
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(updatedResponses);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -717,6 +752,7 @@ export class FSController extends PuterController {
|
||||
normalizedRequestBody.fileMetadata,
|
||||
normalizedRequestBody,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
);
|
||||
return normalizedRequestBody;
|
||||
}),
|
||||
@@ -814,7 +850,9 @@ export class FSController extends PuterController {
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(updatedResponses);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Read-side routes ------------------------------------------------
|
||||
@@ -837,17 +875,135 @@ export class FSController extends PuterController {
|
||||
await this.services.suggestedApps.getSuggestedApps(entry);
|
||||
|
||||
res.json({
|
||||
...entry,
|
||||
...this.#toClientEntry(entry),
|
||||
...(subtreeSize !== undefined ? { size: subtreeSize } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip backend-internal fields before returning an entry to a client.
|
||||
* Storage location (bucket/region), the owner's numeric id, and the
|
||||
* capability-token columns are never used by clients and must not leak to
|
||||
* callers who only hold `see`/`list` on the entry — a share recipient, or
|
||||
* (with public folders enabled) any authenticated user. The legacy read
|
||||
* path already curates its output; this does the same for the v2 routes.
|
||||
*/
|
||||
#toClientEntry(entry: FSEntry): ClientFSEntry {
|
||||
// Allowlist, not a denylist: a denylist silently ships every column
|
||||
// added to `fsentries` later. Omits the numeric primary keys (`id`,
|
||||
// `parentId`, `associatedAppId`), the storage columns, the owning
|
||||
// `userId`, and the `publicToken`/`fileRequestToken` capability tokens.
|
||||
//
|
||||
// Tolerant of partially-hydrated entries: write/mkdir paths return a
|
||||
// freshly-built entry that hasn't been through a subdomain join.
|
||||
const subdomains = entry.subdomains ?? [];
|
||||
return {
|
||||
uuid: entry.uuid,
|
||||
uid: entry.uid ?? entry.uuid,
|
||||
parentUid: entry.parentUid ?? null,
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
isDir: entry.isDir,
|
||||
isShortcut: entry.isShortcut,
|
||||
shortcutTo: entry.shortcutTo ?? null,
|
||||
isSymlink: entry.isSymlink,
|
||||
symlinkPath: entry.symlinkPath ?? null,
|
||||
isPublic: entry.isPublic ?? null,
|
||||
immutable: entry.immutable,
|
||||
metadata: entry.metadata ?? null,
|
||||
modified: entry.modified,
|
||||
created: entry.created ?? null,
|
||||
accessed: entry.accessed ?? null,
|
||||
size: entry.size ?? null,
|
||||
layout: entry.layout ?? null,
|
||||
subdomains,
|
||||
workers: entry.workers ?? [],
|
||||
hasWebsite: entry.hasWebsite ?? subdomains.length > 0,
|
||||
suggestedApps: entry.suggestedApps ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the `fsEntry` a write response carries, leaving the rest of the
|
||||
* envelope (session id, presigned upload targets) untouched — those fields
|
||||
* are the point of the response. Applied at every `res.json` on the write
|
||||
* paths, which previously serialized the raw database row.
|
||||
*
|
||||
* The return type is the sanitized counterpart, so a caller cannot keep
|
||||
* treating the result as though it still held a full `FSEntry`.
|
||||
*/
|
||||
#withClientFsEntry<T extends { fsEntry?: FSEntry }>(
|
||||
response: T,
|
||||
): Omit<T, 'fsEntry'> & { fsEntry?: ClientFSEntry } {
|
||||
const { fsEntry, ...rest } = response;
|
||||
return {
|
||||
...rest,
|
||||
...(fsEntry ? { fsEntry: this.#toClientEntry(fsEntry) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the storage internals from a presigned-upload envelope. The client
|
||||
* uploads to the presigned URLs, which already encode bucket and key, so
|
||||
* naming the physical location of a user's bytes buys the caller nothing.
|
||||
*/
|
||||
#withoutStorageInternals<
|
||||
T extends { bucket: string; bucketRegion: string; objectKey: string },
|
||||
>(response: T): Omit<T, 'bucket' | 'bucketRegion' | 'objectKey'> {
|
||||
const { bucket, bucketRegion, objectKey, ...rest } = response;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** Same, for the responses whose `fsEntry` is always present. */
|
||||
#withRequiredClientFsEntry<T extends { fsEntry: FSEntry }>(
|
||||
response: T,
|
||||
): Omit<T, 'fsEntry'> & { fsEntry: ClientFSEntry } {
|
||||
return {
|
||||
...response,
|
||||
fsEntry: this.#toClientEntry(response.fsEntry),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /fs/readdir` — same contract as the POST form, with parameters in
|
||||
* the query string instead of the body. A read as a GET is cacheable and
|
||||
* can authenticate via `?auth_token=`, which lets callers fetch a listing
|
||||
* without a JSON body. Every value arrives as a string, so parameter
|
||||
* parsing goes through the same coercion helpers the POST path uses.
|
||||
*/
|
||||
@Get('/readdir', { subdomain: 'api', requireVerified: true })
|
||||
async readdirEntriesViaGet(req: Request, res: Response) {
|
||||
return this.readdirEntries(req, res);
|
||||
}
|
||||
|
||||
@Post('/readdir', { subdomain: 'api', requireVerified: true })
|
||||
async readdirEntries(req: Request, res: Response) {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#toObjectRecord(req.body);
|
||||
// GET carries its parameters in the query string; POST in the body.
|
||||
const body = this.#toObjectRecord(
|
||||
req.method === 'GET' ? req.query : req.body,
|
||||
);
|
||||
|
||||
// Presence of `cursor` (null/empty means "first page") or
|
||||
// `includeTotal` opts into the paginated `{items, cursor?, total?}`
|
||||
// envelope. Legacy limit/offset requests keep the bare-array response.
|
||||
const includeTotal = this.#toBoolean(body.includeTotal) === true;
|
||||
const paginated =
|
||||
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
|
||||
includeTotal;
|
||||
|
||||
// Undocumented: `recursive` lists descendants (prefix scan) up to
|
||||
// `depth` levels below the target. Always paginated; sorts by path.
|
||||
const recursive = this.#toBoolean(body.recursive) === true;
|
||||
|
||||
if (this.#isRootPathRef(body)) {
|
||||
if (recursive) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'recursive listing is not supported at the root',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const { listRootEntries } =
|
||||
await import('../../services/fs/rootListing.js');
|
||||
const rootChildren = await listRootEntries(
|
||||
@@ -865,54 +1021,155 @@ export class FSController extends PuterController {
|
||||
child.suggestedApps = rootSuggestions[index] ?? [];
|
||||
}
|
||||
}
|
||||
res.json(rootChildren);
|
||||
const rootItems = await this.#toReaddirEntries(rootChildren);
|
||||
if (paginated) {
|
||||
res.json({
|
||||
items: rootItems,
|
||||
...(includeTotal ? { total: rootItems.length } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(rootItems);
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = await this.#resolveEntryForRequest(body);
|
||||
if (!parent.isDir) {
|
||||
throw new HttpError(400, 'Target is not a directory', {
|
||||
legacyCode: 'bad_request',
|
||||
legacyCode: 'dest_is_not_a_directory',
|
||||
});
|
||||
}
|
||||
await this.#assertAccess(actor, parent.path, 'list');
|
||||
// Use the legacy access assertion so this endpoint stays behaviorally
|
||||
// identical to the `/readdir` route the SDK moved off of — same error
|
||||
// codes and the same app-actor 404 masking on denial.
|
||||
await assertLegacyAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
actor,
|
||||
parent.path,
|
||||
'list',
|
||||
);
|
||||
|
||||
const limit = this.#toNumberOrUndefined(body.limit);
|
||||
const offset = this.#toNumberOrUndefined(body.offset);
|
||||
const sortByRaw =
|
||||
typeof body.sort_by === 'string'
|
||||
? body.sort_by.toLowerCase()
|
||||
typeof (body.sortBy ?? body.sort_by) === 'string'
|
||||
? String(body.sortBy ?? body.sort_by).toLowerCase()
|
||||
: undefined;
|
||||
const sortBy =
|
||||
(['name', 'modified', 'type', 'size'] as const).find(
|
||||
(v) => v === sortByRaw,
|
||||
) ?? null;
|
||||
const sortOrderRaw =
|
||||
typeof body.sort_order === 'string'
|
||||
? body.sort_order.toLowerCase()
|
||||
typeof (body.sortOrder ?? body.sort_order) === 'string'
|
||||
? String(body.sortOrder ?? body.sort_order).toLowerCase()
|
||||
: undefined;
|
||||
const sortOrder =
|
||||
(['asc', 'desc'] as const).find((v) => v === sortOrderRaw) ?? null;
|
||||
|
||||
if (recursive) {
|
||||
const requestedDepth = this.#toNumberOrUndefined(body.depth);
|
||||
const maxDepth = Math.min(
|
||||
MAX_READDIR_DEPTH,
|
||||
Math.max(1, Math.floor(requestedDepth ?? MAX_READDIR_DEPTH)),
|
||||
);
|
||||
const page = await this.services.fs.listDirectoryTreePage(
|
||||
parent.userId,
|
||||
parent.path,
|
||||
{
|
||||
limit,
|
||||
cursor:
|
||||
typeof body.cursor === 'string'
|
||||
? body.cursor
|
||||
: undefined,
|
||||
maxDepth,
|
||||
},
|
||||
);
|
||||
await this.#attachSuggestedApps(page.entries);
|
||||
const total = includeTotal
|
||||
? await this.services.fs.countDirectoryTree(
|
||||
parent.userId,
|
||||
parent.path,
|
||||
maxDepth,
|
||||
)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: await this.#toReaddirEntries(page.entries),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (paginated) {
|
||||
const page = await this.services.fs.listDirectoryPage(parent.uuid, {
|
||||
limit,
|
||||
cursor:
|
||||
typeof body.cursor === 'string' ? body.cursor : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
await this.#attachSuggestedApps(page.entries);
|
||||
const total = includeTotal
|
||||
? await this.services.fs.countDirectory(parent.uuid)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: await this.#toReaddirEntries(page.entries),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const children = await this.services.fs.listDirectory(parent.uuid, {
|
||||
limit,
|
||||
offset,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
await this.#attachSuggestedApps(children);
|
||||
res.json(await this.#toReaddirEntries(children));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape readdir entries in the v2 (camelCase) response shape, enriched with
|
||||
* the three fields the SDK cannot reconstruct on its own so it can rebuild
|
||||
* the v1 shape: `type` (MIME), a signed `thumbnail`, and `associatedApp`.
|
||||
*/
|
||||
async #toReaddirEntries(entries: FSEntry[]): Promise<ClientReaddirEntry[]> {
|
||||
const appsById = await loadLegacyAssociatedApps(
|
||||
this.stores.app,
|
||||
entries,
|
||||
);
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => ({
|
||||
...this.#toClientEntry(entry),
|
||||
// Fields the client cannot derive on its own.
|
||||
type: fsEntryMimeType(entry),
|
||||
thumbnail: await signEntryThumbnail(
|
||||
this.clients.event,
|
||||
entry.uuid,
|
||||
entry.thumbnail,
|
||||
),
|
||||
associatedApp:
|
||||
entry.associatedAppId !== null
|
||||
? (appsById.get(entry.associatedAppId) ?? null)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async #attachSuggestedApps(entries: FSEntry[]): Promise<void> {
|
||||
const suggestions =
|
||||
await this.services.suggestedApps.getSuggestedAppsForEntries(
|
||||
children,
|
||||
entries,
|
||||
);
|
||||
for (let index = 0; index < children.length; index++) {
|
||||
const child = children[index];
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
const child = entries[index];
|
||||
if (child) {
|
||||
child.suggestedApps = suggestions[index] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
res.json(children);
|
||||
}
|
||||
|
||||
@Post('/search', { subdomain: 'api', requireVerified: true })
|
||||
@@ -964,8 +1221,13 @@ export class FSController extends PuterController {
|
||||
range,
|
||||
});
|
||||
|
||||
if (download.contentType)
|
||||
if (download.contentType) {
|
||||
res.setHeader('Content-Type', download.contentType);
|
||||
// Stored type is uploader-controlled and served inline on the
|
||||
// api origin — sandbox active-document types (HTML/SVG/XML) so
|
||||
// embedded scripts can't run here. Inert types are untouched.
|
||||
applyInlineContentSecurity(res, download.contentType);
|
||||
}
|
||||
if (download.contentLength !== null)
|
||||
res.setHeader('Content-Length', String(download.contentLength));
|
||||
if (download.contentRange)
|
||||
@@ -1055,7 +1317,7 @@ export class FSController extends PuterController {
|
||||
) ?? false,
|
||||
});
|
||||
this.#emitGuiItemAdded(entry);
|
||||
res.json(entry);
|
||||
res.json(this.#toClientEntry(entry));
|
||||
}
|
||||
|
||||
@Post('/touch', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1091,7 +1353,7 @@ export class FSController extends PuterController {
|
||||
createMissingParents:
|
||||
this.#toBoolean(body.create_missing_parents) ?? false,
|
||||
});
|
||||
res.json(entry);
|
||||
res.json(this.#toClientEntry(entry));
|
||||
}
|
||||
|
||||
@Post('/rename', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1109,7 +1371,7 @@ export class FSController extends PuterController {
|
||||
|
||||
const renamed = await this.services.fs.rename(entry, newName);
|
||||
this.#emitGuiItemUpdated(renamed);
|
||||
res.json(renamed);
|
||||
res.json(this.#toClientEntry(renamed));
|
||||
}
|
||||
|
||||
@Post('/delete', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1155,7 +1417,7 @@ export class FSController extends PuterController {
|
||||
this.#toBoolean(body.dedupe_name ?? body.change_name) ?? false,
|
||||
});
|
||||
this.#emitGuiItemMoved(source, moved);
|
||||
res.json(moved);
|
||||
res.json(this.#toClientEntry(moved));
|
||||
}
|
||||
|
||||
@Post('/copy', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1183,7 +1445,7 @@ export class FSController extends PuterController {
|
||||
this.#toBoolean(body.dedupe_name ?? body.change_name) ?? true,
|
||||
});
|
||||
this.#emitGuiItemAdded(copy);
|
||||
res.json(copy);
|
||||
res.json(this.#toClientEntry(copy));
|
||||
}
|
||||
|
||||
@Post('/mkshortcut', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1212,7 +1474,7 @@ export class FSController extends PuterController {
|
||||
dedupeName: this.#toBoolean(body.dedupe_name) ?? true,
|
||||
});
|
||||
this.#emitGuiItemAdded(shortcut);
|
||||
res.json(shortcut);
|
||||
res.json(this.#toClientEntry(shortcut));
|
||||
}
|
||||
|
||||
// -- Read-side helpers -----------------------------------------------
|
||||
@@ -1267,12 +1529,12 @@ export class FSController extends PuterController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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);
|
||||
@@ -1689,6 +1951,7 @@ export class FSController extends PuterController {
|
||||
fileMetadata: FSEntryWriteInput,
|
||||
fallbackSource?: unknown,
|
||||
appUidLookupCache?: Map<string, Promise<number | null>>,
|
||||
actorUserId?: number,
|
||||
): Promise<FSEntryWriteInput> {
|
||||
const metadataRecord = this.#toObjectRecord(fileMetadata);
|
||||
const fallbackRecord = this.#toObjectRecord(fallbackSource);
|
||||
@@ -1702,10 +1965,11 @@ export class FSController extends PuterController {
|
||||
),
|
||||
);
|
||||
if (associatedAppId !== undefined) {
|
||||
return {
|
||||
...fileMetadata,
|
||||
return this.#withAssociatedAppId(
|
||||
fileMetadata,
|
||||
associatedAppId,
|
||||
};
|
||||
actorUserId,
|
||||
);
|
||||
}
|
||||
|
||||
const appUid = this.#firstDefined(
|
||||
@@ -1739,9 +2003,46 @@ export class FSController extends PuterController {
|
||||
if (resolvedAppId === null) {
|
||||
return fileMetadata;
|
||||
}
|
||||
return this.#withAssociatedAppId(
|
||||
fileMetadata,
|
||||
resolvedAppId,
|
||||
actorUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind `associatedAppId` onto the write input only when the actor is
|
||||
* entitled to reference that app. `associatedAppId` is client-supplied and
|
||||
* never trusted for authz, but it's echoed back in legacy FS responses — so
|
||||
* an attacker could plant another tenant's private app id to confirm the
|
||||
* row exists (an enumeration oracle) and harvest its metadata. Allow
|
||||
* binding to public apps (their existence isn't secret) or to apps the
|
||||
* actor owns; drop the association otherwise so the file simply carries no
|
||||
* associated app.
|
||||
*
|
||||
* `#resolveWriteFileMetadata` has already copied the raw client value onto
|
||||
* `fileMetadata`, so a dropped association must be actively stripped — not
|
||||
* merely left unset.
|
||||
*/
|
||||
async #withAssociatedAppId(
|
||||
fileMetadata: FSEntryWriteInput,
|
||||
appId: number,
|
||||
actorUserId?: number,
|
||||
): Promise<FSEntryWriteInput> {
|
||||
const app = await this.stores.app.getById(appId);
|
||||
const isPrivate =
|
||||
!!app && (Boolean(app.is_private) || Boolean(app.protected));
|
||||
const isOwner =
|
||||
actorUserId !== undefined &&
|
||||
this.#toNumber(app?.owner_user_id) === actorUserId;
|
||||
if (!app || (isPrivate && !isOwner)) {
|
||||
const { associatedAppId: _dropped, ...rest } =
|
||||
fileMetadata as unknown as Record<string, unknown>;
|
||||
return rest as unknown as FSEntryWriteInput;
|
||||
}
|
||||
return {
|
||||
...fileMetadata,
|
||||
associatedAppId: resolvedAppId,
|
||||
associatedAppId: appId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1758,8 +2059,7 @@ export class FSController extends PuterController {
|
||||
// the ActorUser type. Access via the escape hatch until a proper
|
||||
// storage-quota mechanism is in place.
|
||||
const actorUser = req.actor?.user as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
const candidates = [
|
||||
this.#toStorageCapacityCandidate(actorUser?.free_storage),
|
||||
|
||||
@@ -18,12 +18,15 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { signFile } from '../../util/fileSigning.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { LegacyFSController } from './LegacyFSController.js';
|
||||
|
||||
@@ -688,6 +691,59 @@ describe('LegacyFSController.readdir', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('pages children with cursors and reports totals', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
for (const name of ['p1', 'p2', 'p3']) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents/${name}` },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const readdir = async (body: Record<string, unknown>) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdir(makeReq({ body, actor }), res),
|
||||
);
|
||||
return captured.body;
|
||||
};
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
let total: number | undefined;
|
||||
do {
|
||||
const page = (await readdir({
|
||||
path: `/${username}/Documents`,
|
||||
limit: 2,
|
||||
cursor,
|
||||
includeTotal: true,
|
||||
})) as {
|
||||
items: Array<{ name: string }>;
|
||||
cursor?: string;
|
||||
total?: number;
|
||||
};
|
||||
seen.push(...page.items.map((e) => e.name));
|
||||
total = page.total;
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(seen).toEqual(['p1', 'p2', 'p3']);
|
||||
expect(total).toBe(3);
|
||||
|
||||
// Legacy limit-only requests keep the bare array response.
|
||||
const bare = await readdir({
|
||||
path: `/${username}/Documents`,
|
||||
limit: 2,
|
||||
});
|
||||
expect(Array.isArray(bare)).toBe(true);
|
||||
expect((bare as unknown[]).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── copy ────────────────────────────────────────────────────────────
|
||||
@@ -720,13 +776,9 @@ describe('LegacyFSController.copy', () => {
|
||||
copied: { path: string; name: string };
|
||||
}>;
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].copied.path).toBe(
|
||||
`/${username}/Pictures/src-folder`,
|
||||
);
|
||||
expect(body[0].copied.path).toBe(`/${username}/Pictures/src-folder`);
|
||||
// The original still exists; the copy lives under Pictures.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(src),
|
||||
).not.toBeNull();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures/src-folder`,
|
||||
@@ -761,9 +813,120 @@ describe('LegacyFSController.copy', () => {
|
||||
);
|
||||
|
||||
const body = captured.body as Array<{ copied: { path: string } }>;
|
||||
expect(body[0].copied.path).toBe(
|
||||
`/${username}/Pictures/renamed-copy`,
|
||||
expect(body[0].copied.path).toBe(`/${username}/Pictures/renamed-copy`);
|
||||
});
|
||||
|
||||
it('copies an empty file (no backing S3 object) without erroring', async () => {
|
||||
// Empty files created via /touch have size 0 and no S3 object —
|
||||
// bucket is null. Copy must clone them as empty-file entries rather
|
||||
// than issuing a CopyObject, which would throw S3 NoSuchKey.
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/empty.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: src }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.copy(
|
||||
makeReq({
|
||||
body: { source: src, destination: `/${username}/Pictures` },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
const body = captured.body as Array<{ copied: { path: string } }>;
|
||||
expect(body[0].copied.path).toBe(`/${username}/Pictures/empty.txt`);
|
||||
const copied = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures/empty.txt`,
|
||||
);
|
||||
expect(copied).not.toBeNull();
|
||||
expect(copied!.size).toBe(0);
|
||||
// Original is untouched.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('copying a ghost file (S3 object missing) 404s and cleans up the orphan', async () => {
|
||||
// A real file whose backing S3 object has vanished keeps a non-null
|
||||
// bucket, so it isn't an empty file. CopyObject would throw S3
|
||||
// NoSuchKey; copy must surface a clean 404 and remove the orphan row
|
||||
// rather than bubbling a 500. Mirrors readContent's ghost handling.
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/ghost.txt`;
|
||||
const content = Buffer.from('i will be deleted from s3');
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: src,
|
||||
size: content.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
|
||||
// Delete the backing S3 object directly, leaving the DB row behind.
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
|
||||
await server.stores.s3Object.deleteObject(
|
||||
server.stores.s3Object.resolveBucket(entry.bucket),
|
||||
entry.uuid,
|
||||
server.stores.s3Object.resolveRegion(entry.bucketRegion),
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.copy(
|
||||
makeReq({
|
||||
body: {
|
||||
source: src,
|
||||
destination: `/${username}/Pictures`,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
|
||||
// The ghost handler removed the orphaned source row.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).toBeNull();
|
||||
// No partial copy was left behind.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures/ghost.txt`,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reads an empty file as empty content without deleting it', async () => {
|
||||
// Reading an empty file (no S3 object) must not throw NoSuchKey nor
|
||||
// trip the ghost-file cleanup, which would delete the entry.
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/readme-empty.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: src }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
|
||||
|
||||
const download = await server.services.fs.readContent(entry);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of download.body) {
|
||||
chunks.push(Buffer.from(chunk as Uint8Array));
|
||||
}
|
||||
expect(Buffer.concat(chunks).byteLength).toBe(0);
|
||||
expect(download.contentLength).toBe(0);
|
||||
// The entry must still exist — the ghost handler must NOT have run.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -876,10 +1039,7 @@ describe('LegacyFSController.search', () => {
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.search(
|
||||
makeReq({ body: { query: needle }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.search(makeReq({ body: { query: needle }, actor }), res),
|
||||
);
|
||||
const results = captured.body as Array<{ name: string }>;
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
@@ -931,9 +1091,7 @@ describe('LegacyFSController.search', () => {
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
results.some(
|
||||
(r) => r.path === `/${username}/Documents/${needle}`,
|
||||
),
|
||||
results.some((r) => r.path === `/${username}/Documents/${needle}`),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1013,10 +1171,7 @@ describe('LegacyFSController.sign', () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.sign(
|
||||
makeReq({ body: { items: [] }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.sign(makeReq({ body: { items: [] }, actor }), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
@@ -1171,6 +1326,113 @@ describe('LegacyFSController.writeFile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── writeFile (write IDOR: authorized subject must equal write target) ─
|
||||
//
|
||||
// Holding a write signature + write ACL on a single file must not let the
|
||||
// caller redirect the upload to an attacker-named sibling via the `name`
|
||||
// field. The write has to land on the signed file itself.
|
||||
|
||||
describe('LegacyFSController.writeFile (write IDOR)', () => {
|
||||
const signingCfgOf = () => {
|
||||
const cfg = (
|
||||
controller as unknown as {
|
||||
config: {
|
||||
api_base_url?: string;
|
||||
url_signature_secret?: string;
|
||||
};
|
||||
}
|
||||
).config;
|
||||
cfg.api_base_url = cfg.api_base_url ?? 'http://api.test.local';
|
||||
cfg.url_signature_secret =
|
||||
cfg.url_signature_secret ?? 'test-signing-secret';
|
||||
return {
|
||||
secret: cfg.url_signature_secret,
|
||||
apiBaseUrl: cfg.api_base_url,
|
||||
};
|
||||
};
|
||||
|
||||
const multipartWriteReq = (opts: {
|
||||
actor: Actor;
|
||||
uid: string;
|
||||
expires: string;
|
||||
signature: string;
|
||||
name?: string;
|
||||
content: string;
|
||||
}): Request => {
|
||||
const boundary = '----lfsWriteIdorBoundary';
|
||||
const payload =
|
||||
`--${boundary}\r\n` +
|
||||
'Content-Disposition: form-data; name="file"; ' +
|
||||
'filename="upload.bin"\r\n' +
|
||||
'Content-Type: application/octet-stream\r\n\r\n' +
|
||||
`${opts.content}\r\n` +
|
||||
`--${boundary}--\r\n`;
|
||||
const req = Readable.from([Buffer.from(payload)]) as unknown as Request;
|
||||
Object.assign(req, {
|
||||
headers: {
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
query: {
|
||||
uid: opts.uid,
|
||||
expires: opts.expires,
|
||||
signature: opts.signature,
|
||||
operation: 'write',
|
||||
},
|
||||
body: opts.name !== undefined ? { name: opts.name } : {},
|
||||
actor: opts.actor,
|
||||
});
|
||||
return req;
|
||||
};
|
||||
|
||||
it('writes to the signed file, not an attacker-named sibling', async () => {
|
||||
const victim = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const dir = `/${victim.actor.user!.username}/Documents`;
|
||||
const target = `${dir}/secret.txt`;
|
||||
const sibling = `${dir}/passwords.txt`;
|
||||
|
||||
// Victim owns a single file; attacker gets write on just that file.
|
||||
await withActor(victim.actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: target }, actor: victim.actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(target);
|
||||
expect(entry).toBeTruthy();
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
victim.actor,
|
||||
attacker.actor.user!.username!,
|
||||
`fs:${entry!.uuid}:write`,
|
||||
{},
|
||||
);
|
||||
|
||||
const writeUrl = new URL(signFile(entry!, signingCfgOf()).write_url!);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(attacker.actor, () =>
|
||||
controller.writeFile(
|
||||
multipartWriteReq({
|
||||
actor: attacker.actor,
|
||||
uid: writeUrl.searchParams.get('uid')!,
|
||||
expires: writeUrl.searchParams.get('expires')!,
|
||||
signature: writeUrl.searchParams.get('signature')!,
|
||||
name: 'passwords.txt',
|
||||
content: 'attacker-bytes',
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
// The write landed on the signed file …
|
||||
expect((captured.body as { path?: string }).path).toBe(target);
|
||||
// … and never created the attacker-named sibling.
|
||||
const siblingEntry =
|
||||
await server.stores.fsEntry.getEntryByPath(sibling);
|
||||
expect(siblingEntry).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── file (validation paths) ─────────────────────────────────────────
|
||||
|
||||
describe('LegacyFSController.file', () => {
|
||||
@@ -1318,10 +1580,7 @@ describe('LegacyFSController.requestAppRootDir', () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.requestAppRootDir(
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
controller.requestAppRootDir(makeReq({ body: {}, actor }), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
@@ -1497,10 +1756,7 @@ describe('LegacyFSController.down', () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.down(
|
||||
makeReq({ query: { path: '/' }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.down(makeReq({ query: { path: '/' }, actor }), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
@@ -1557,9 +1813,9 @@ describe('LegacyFSController.mkdir additional branches', () => {
|
||||
}),
|
||||
actor: undefined,
|
||||
} as unknown as Request;
|
||||
await expect(
|
||||
controller.mkdir(req, res),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
await expect(controller.mkdir(req, res)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1579,17 +1835,12 @@ describe('LegacyFSController.delete additional branches', () => {
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.delete(
|
||||
makeReq({ body: { path: target }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.delete(makeReq({ body: { path: target }, actor }), res),
|
||||
);
|
||||
const body = captured.body as { ok: boolean; uid: string };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(typeof body.uid).toBe('string');
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).toBeNull();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards descendants_only into fs.remove', async () => {
|
||||
@@ -1720,9 +1971,9 @@ describe('LegacyFSController.df actor gate', () => {
|
||||
...makeReq({ body: {}, actor }),
|
||||
actor: undefined,
|
||||
} as unknown as Request;
|
||||
await expect(
|
||||
controller.df(req, makeRes().res),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
await expect(controller.df(req, makeRes().res)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1739,7 +1990,8 @@ describe('LegacyFSController.down file streaming', () => {
|
||||
ended: false,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Writable } = require('node:stream') as typeof import('node:stream');
|
||||
const { Writable } =
|
||||
require('node:stream') as typeof import('node:stream');
|
||||
const writable = new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
captured.bodyChunks.push(chunk);
|
||||
@@ -1785,18 +2037,19 @@ describe('LegacyFSController.down file streaming', () => {
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.down(
|
||||
makeReq({ query: { path: target }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.down(makeReq({ query: { path: target }, actor }), res),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
// /down forces octet-stream regardless of the entry's true type.
|
||||
expect(captured.headers['Content-Type']).toBe('application/octet-stream');
|
||||
expect(captured.headers['Content-Type']).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
expect(captured.headers['Content-Disposition']).toMatch(/^attachment;/);
|
||||
expect(captured.headers['Content-Length']).toBe(String(body.byteLength));
|
||||
expect(captured.headers['Content-Length']).toBe(
|
||||
String(body.byteLength),
|
||||
);
|
||||
// The piped bytes match the file contents.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
if (captured.bodyChunks.length > 0) {
|
||||
@@ -1843,7 +2096,8 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
bodyChunks: [] as Buffer[],
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Writable } = require('node:stream') as typeof import('node:stream');
|
||||
const { Writable } =
|
||||
require('node:stream') as typeof import('node:stream');
|
||||
const writable = new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
captured.bodyChunks.push(chunk);
|
||||
@@ -1886,18 +2140,19 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.read(
|
||||
makeReq({ query: { file: target }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.read(makeReq({ query: { file: target }, actor }), res),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
// The v1 contract is octet-stream regardless of real mime; /fs/read
|
||||
// (v2) is the type-aware variant. This is documented in the controller.
|
||||
expect(captured.headers['Content-Type']).toBe('application/octet-stream');
|
||||
expect(captured.headers['Content-Length']).toBe(String(body.byteLength));
|
||||
expect(captured.headers['Content-Type']).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
expect(captured.headers['Content-Length']).toBe(
|
||||
String(body.byteLength),
|
||||
);
|
||||
});
|
||||
|
||||
it('honors options.realMime by forwarding the real content-type from mime-types', async () => {
|
||||
@@ -1921,7 +2176,10 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
controller.read(
|
||||
makeReq({ query: { file: target }, actor }),
|
||||
res,
|
||||
{ realMime: true } as never,
|
||||
undefined,
|
||||
{
|
||||
realMime: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(captured.headers['Content-Type']).toMatch(/text\/html/);
|
||||
@@ -2027,10 +2285,7 @@ describe('LegacyFSController.search fallback fields', () => {
|
||||
);
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.search(
|
||||
makeReq({ body: { text: needle }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.search(makeReq({ body: { text: needle }, actor }), res),
|
||||
);
|
||||
const results = captured.body as Array<{ name: string }>;
|
||||
expect(results.some((r) => r.name === needle)).toBe(true);
|
||||
@@ -2145,7 +2400,9 @@ describe('LegacyFSController.batch additional operations', () => {
|
||||
),
|
||||
);
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const body = captured.body as { results: Array<Record<string, unknown>> };
|
||||
const body = captured.body as {
|
||||
results: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(body.results).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -2173,12 +2430,10 @@ describe('LegacyFSController.batch additional operations', () => {
|
||||
),
|
||||
);
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).toBeNull();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
|
||||
});
|
||||
|
||||
it("runs a `shortcut` op pointing at an existing target uid", async () => {
|
||||
it('runs a `shortcut` op pointing at an existing target uid', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const target = `/${username}/Documents/batch-sc-target`;
|
||||
@@ -2326,10 +2581,7 @@ describe('LegacyFSController.suggestApps', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const spy = vi.spyOn(
|
||||
server.services.suggestedApps,
|
||||
'getSuggestedApps',
|
||||
);
|
||||
const spy = vi.spyOn(server.services.suggestedApps, 'getSuggestedApps');
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await withActor(appActor, () =>
|
||||
@@ -2361,10 +2613,7 @@ describe('LegacyFSController.suggestApps', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const spy = vi.spyOn(
|
||||
server.services.suggestedApps,
|
||||
'getSuggestedApps',
|
||||
);
|
||||
const spy = vi.spyOn(server.services.suggestedApps, 'getSuggestedApps');
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
@@ -2417,10 +2666,7 @@ describe('LegacyFSController.readdirSubdomains', () => {
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdirSubdomains(
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
controller.readdirSubdomains(makeReq({ body: {}, actor }), res),
|
||||
);
|
||||
const rows = captured.body as Array<{ subdomain: string }>;
|
||||
expect(rows.some((r) => r.subdomain === sd)).toBe(true);
|
||||
@@ -2498,3 +2744,170 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
|
||||
expect(typeof body.thumbnail).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /get-launch-apps ────────────────────────────────────────────
|
||||
//
|
||||
// `recent` is a launch-metadata producer: it returns `index_url` for
|
||||
// apps the user has opened before. The taskbar happens to launch these
|
||||
// by name (so AppDriver's hosted-backing guard applies), but the field
|
||||
// is served regardless, so the guard is applied here too.
|
||||
|
||||
describe('LegacyFSController GET /get-launch-apps', () => {
|
||||
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
|
||||
|
||||
// The route is an inline lambda in `registerRoutes`, so pull it off a
|
||||
// freshly-registered router rather than calling a named method.
|
||||
const getLaunchAppsHandler = () => {
|
||||
const router = new PuterRouter();
|
||||
(
|
||||
controller as unknown as {
|
||||
registerRoutes: (r: PuterRouter) => void;
|
||||
}
|
||||
).registerRoutes(router);
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === '/get-launch-apps',
|
||||
);
|
||||
if (!route) throw new Error('No GET /get-launch-apps route');
|
||||
return route.handler;
|
||||
};
|
||||
|
||||
const recordOpen = async (userId: number, appUid: string) => {
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
|
||||
[appUid, userId, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
};
|
||||
|
||||
const fetchRecent = async (
|
||||
actor: Actor,
|
||||
): Promise<Array<Record<string, unknown>>> => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, async () => {
|
||||
await getLaunchAppsHandler()(makeReq({ actor }), res, () => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
});
|
||||
});
|
||||
return (captured.body as { recent: Array<Record<string, unknown>> })
|
||||
.recent;
|
||||
};
|
||||
|
||||
const makeHostedApp = async (userId: number, sub: string) => {
|
||||
const name = `launch-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const app = await server.stores.app.create(
|
||||
{ name, title: 'Launchable', index_url: hostedUrl(sub) },
|
||||
{ ownerUserId: userId },
|
||||
);
|
||||
return app as { uid: string };
|
||||
};
|
||||
|
||||
it('returns index_url while the hosted subdomain is owned', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = `live-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.stores.subdomain.create({ userId, subdomain: sub });
|
||||
const app = await makeHostedApp(userId, sub);
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry).toBeDefined();
|
||||
expect(String(entry?.index_url)).toContain(sub);
|
||||
expect(entry?.privateAccess).toBeUndefined();
|
||||
});
|
||||
|
||||
it('nulls index_url and denies launch once the subdomain is deleted', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = `gone-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
const app = await makeHostedApp(userId, sub);
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId },
|
||||
);
|
||||
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.index_url).toBeNull();
|
||||
expect(entry?.privateAccess).toMatchObject({
|
||||
hasAccess: false,
|
||||
reason: 'hosted_backing_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('nulls index_url once the subdomain is reclaimed by another user', async () => {
|
||||
const owner = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const sub = `reclaim-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId: owner.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
const app = await makeHostedApp(owner.userId, sub);
|
||||
await recordOpen(owner.userId, app.uid);
|
||||
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId: owner.userId },
|
||||
);
|
||||
await server.stores.subdomain.create({
|
||||
userId: attacker.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
|
||||
const entry = (await fetchRecent(owner.actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry?.index_url).toBeNull();
|
||||
expect(entry?.privateAccess).toMatchObject({
|
||||
hasAccess: false,
|
||||
reason: 'hosted_backing_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the subdomain lookup errors', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = `flaky-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.stores.subdomain.create({ userId, subdomain: sub });
|
||||
const app = await makeHostedApp(userId, sub);
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
const spy = vi
|
||||
.spyOn(server.stores.subdomain, 'getBySubdomain')
|
||||
.mockRejectedValue(new Error('db down'));
|
||||
try {
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry?.index_url).toBeNull();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves non-hosted index_urls untouched', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const name = `ext-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const app = (await server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'External',
|
||||
index_url: 'https://dev-owned-domain.example/',
|
||||
},
|
||||
{ ownerUserId: userId },
|
||||
)) as { uid: string };
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry?.index_url).toBe('https://dev-owned-domain.example/');
|
||||
expect(entry?.privateAccess).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,32 +3,43 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import Busboy from 'busboy';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { effectiveActorApp, isAccessTokenActor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertVerifiedAccount,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { ACLService } from '../../services/acl/ACLService.js';
|
||||
import type { SignedFile } from '../../util/fileSigning.js';
|
||||
import { verifySignature } from '../../util/fileSigning.js';
|
||||
import {
|
||||
buildHostedBackingDenial,
|
||||
hostedIndexUrlBackingIsUnavailable,
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { FS_COSTS } from './costs.js';
|
||||
import {
|
||||
@@ -43,15 +54,6 @@ import {
|
||||
signingConfigFromAppConfig,
|
||||
toLegacyEntry,
|
||||
} from './legacyFsHelpers.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
|
||||
/**
|
||||
* Legacy FS routes, implemented as thin shims over `FSService`.
|
||||
*
|
||||
* Each shim parses the request shape (FSNodeParam-style `{ path, uid, id }`
|
||||
* or `{ parent, name }`), invokes the service method, and returns the
|
||||
* snake_case response clients expect.
|
||||
*/
|
||||
|
||||
type RouterCache = Map<string, RequestHandler | null>;
|
||||
|
||||
@@ -146,6 +148,11 @@ export class LegacyFSController extends PuterController {
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
// The user's own credential may download their files; apps and
|
||||
// scoped tokens stay blocked. antiCsrf still protects the
|
||||
// cookie-authed GUI path — a bearer-token PAT is exempt below
|
||||
// (CSRF can't forge a header-credentialed request).
|
||||
allowFullAccessToken: true,
|
||||
requireVerified: true,
|
||||
antiCsrf: true,
|
||||
},
|
||||
@@ -160,8 +167,7 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
router.get('/get-launch-apps', apiOptions, async (req, res) => {
|
||||
const recommendedSvc = this.services.recommendedApps as unknown as
|
||||
| { getRecommendedApps?: () => Promise<unknown[]> }
|
||||
| undefined;
|
||||
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
|
||||
const recommended = recommendedSvc?.getRecommendedApps
|
||||
? await recommendedSvc.getRecommendedApps()
|
||||
: [];
|
||||
@@ -188,6 +194,18 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
).getByUid(uid);
|
||||
if (app) {
|
||||
// Don't hand out an index_url whose puter-hosted
|
||||
// backing is gone or reclaimed. The taskbar launches
|
||||
// recents by name (so AppDriver's guard applies), but
|
||||
// this list is a launch-metadata producer like any
|
||||
// other — a future consumer reading index_url
|
||||
// straight off it shouldn't inherit a stale origin.
|
||||
const backingGone =
|
||||
await hostedIndexUrlBackingIsUnavailable({
|
||||
app,
|
||||
subdomainStore: this.stores.subdomain,
|
||||
config: this.config,
|
||||
}).catch(() => true);
|
||||
apps.push({
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
@@ -195,7 +213,15 @@ export class LegacyFSController extends PuterController {
|
||||
icon: app.icon ?? null,
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
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 === '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -310,6 +336,13 @@ export class LegacyFSController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = asRecord(req.body);
|
||||
|
||||
// Presence of `cursor` (null means "first page") or `includeTotal`
|
||||
// opts into the paginated `{items, cursor?, total?}` envelope.
|
||||
// Requests without pagination params keep the bare-array response.
|
||||
const paginated =
|
||||
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
|
||||
body.includeTotal === true;
|
||||
|
||||
if (this.#isRootPathRef(body)) {
|
||||
const { listRootEntries } =
|
||||
await import('../../services/fs/rootListing.js');
|
||||
@@ -339,6 +372,15 @@ export class LegacyFSController extends PuterController {
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (paginated) {
|
||||
res.json({
|
||||
items: shaped,
|
||||
...(body.includeTotal === true
|
||||
? { total: shaped.length }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(shaped);
|
||||
return;
|
||||
}
|
||||
@@ -357,10 +399,37 @@ export class LegacyFSController extends PuterController {
|
||||
'list',
|
||||
);
|
||||
|
||||
const children = await this.services.fs.listDirectory(parent.uuid, {
|
||||
sortBy: this.#parseSortBy(body),
|
||||
sortOrder: this.#parseSortOrder(body),
|
||||
});
|
||||
const sortBy = this.#parseSortBy(body);
|
||||
const sortOrder = this.#parseSortOrder(body);
|
||||
const limit =
|
||||
typeof body.limit === 'number' || typeof body.limit === 'string'
|
||||
? Number(body.limit)
|
||||
: undefined;
|
||||
const offset =
|
||||
typeof body.offset === 'number' || typeof body.offset === 'string'
|
||||
? Number(body.offset)
|
||||
: undefined;
|
||||
|
||||
let children;
|
||||
let cursor: string | undefined;
|
||||
if (paginated) {
|
||||
const page = await this.services.fs.listDirectoryPage(parent.uuid, {
|
||||
limit,
|
||||
cursor:
|
||||
typeof body.cursor === 'string' ? body.cursor : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
children = page.entries;
|
||||
cursor = page.cursor;
|
||||
} else {
|
||||
children = await this.services.fs.listDirectory(parent.uuid, {
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
offset: Number.isFinite(offset) ? offset : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
const suggestions =
|
||||
await this.services.suggestedApps.getSuggestedAppsForEntries(
|
||||
@@ -383,6 +452,19 @@ export class LegacyFSController extends PuterController {
|
||||
toLegacyEntry(this.clients.event, c, { appsById }),
|
||||
),
|
||||
);
|
||||
|
||||
if (paginated) {
|
||||
const total =
|
||||
body.includeTotal === true
|
||||
? await this.services.fs.countDirectory(parent.uuid)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: shaped,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(shaped);
|
||||
};
|
||||
|
||||
@@ -557,9 +639,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
@@ -826,7 +906,12 @@ export class LegacyFSController extends PuterController {
|
||||
res.json(shaped);
|
||||
};
|
||||
|
||||
read = async (req: Request, res: Response, options = {}): Promise<void> => {
|
||||
read = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next?: NextFunction,
|
||||
options: { realMime?: boolean } = {},
|
||||
) => {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = asRecord(req.query);
|
||||
|
||||
@@ -867,7 +952,10 @@ export class LegacyFSController extends PuterController {
|
||||
// GUI) expect the raw-Blob shape. Use `/fs/read` for type-aware
|
||||
// streaming.
|
||||
if (options.realMime) {
|
||||
res.setHeader('Content-Type', contentTypeFromMime(entry.name));
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
contentTypeFromMime(entry.name) as string,
|
||||
);
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
}
|
||||
@@ -934,20 +1022,25 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
req.actor = actor;
|
||||
// This endpoint authenticates the token by hand and never runs the
|
||||
// route gate chain, so the suspension and pending-verification checks
|
||||
// that guard every other authenticated FS route have to run here.
|
||||
assertNotSuspended(actor!.user);
|
||||
assertVerifiedAccount(actor!.user);
|
||||
|
||||
req.actor = actor!;
|
||||
Context.set('actor', actor);
|
||||
|
||||
// Forward back to regular read after setting actor
|
||||
return this.read(req, res, { realMime: true });
|
||||
return this.read(req, res, undefined, { realMime: true });
|
||||
};
|
||||
|
||||
// -- Signed-URL + meta routes ----------------------------------------
|
||||
|
||||
/**
|
||||
* POST /sign
|
||||
* Body: `{ items: [{ uid?, path?, action }], app_uid? }`. Returns
|
||||
* `{ signatures: [...], token? }`. Apps may only sign files under their
|
||||
* own AppData subtree.
|
||||
* POST /sign Body: `{ items: [{ uid?, path?, action }], app_uid? }`.
|
||||
* Returns `{ signatures: [...], token? }`. Apps may only sign files under
|
||||
* their own AppData subtree.
|
||||
*/
|
||||
sign = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
@@ -975,8 +1068,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1073,10 +1165,10 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /writeFile?uid=<uid>&operation=<op>
|
||||
* Signature-authenticated multipart upload. `operation` dispatches to one
|
||||
* of write/copy/move/mkdir/delete/rename/trash. Signature must be valid
|
||||
* for `write` action on `uid`.
|
||||
* POST /writeFile?uid=<uid>&operation=<op> Signature-authenticated
|
||||
* multipart upload. `operation` dispatches to one of
|
||||
* write/copy/move/mkdir/delete/rename/trash. Signature must be valid for
|
||||
* `write` action on `uid`.
|
||||
*/
|
||||
writeFile = async (req: Request, res: Response): Promise<void> => {
|
||||
const query = asRecord(req.query);
|
||||
@@ -1127,35 +1219,35 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// ACL re-check: require an authenticated actor with write
|
||||
// permission. A valid write signature alone is not sufficient —
|
||||
// the caller must also pass ACL, preventing exploitation of
|
||||
// leaked write URLs by read-only share recipients.
|
||||
const callerActor = this.#requireActor(req);
|
||||
if (operation === 'write') {
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetEntry.path,
|
||||
'write',
|
||||
);
|
||||
}
|
||||
if (operation === 'write') {
|
||||
const body = asRecord(req.body);
|
||||
const parentEntry = targetEntry.isDir
|
||||
? targetEntry
|
||||
: await this.#resolveParentOfEntry(targetEntry);
|
||||
const name =
|
||||
typeof body.name === 'string'
|
||||
? body.name
|
||||
: targetEntry.isDir
|
||||
? `upload-${Date.now()}`
|
||||
: targetEntry.name;
|
||||
const targetPath =
|
||||
parentEntry.path === '/'
|
||||
? `/${name}`
|
||||
: `${parentEntry.path}/${name}`;
|
||||
let targetPath: string;
|
||||
if (targetEntry.isDir) {
|
||||
const name =
|
||||
typeof body.name === 'string'
|
||||
? body.name
|
||||
: `upload-${Date.now()}`;
|
||||
targetPath =
|
||||
targetEntry.path === '/'
|
||||
? `/${name}`
|
||||
: `${targetEntry.path}/${name}`;
|
||||
await assertCanCreate(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetPath,
|
||||
);
|
||||
} else {
|
||||
targetPath = targetEntry.path;
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetPath,
|
||||
'write',
|
||||
);
|
||||
}
|
||||
|
||||
// Parse multipart and pipe the first `file` part into fsService.write.
|
||||
const uploadResult = await this.#multipartWrite(
|
||||
@@ -1295,10 +1387,9 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /file?uid=<uid>&signature=...&expires=...
|
||||
* Signature-authenticated file read. Directories return a signed listing
|
||||
* of children; files stream bytes (with Range support when `download`
|
||||
* isn't requested).
|
||||
* GET /file?uid=<uid>&signature=...&expires=... Signature-authenticated
|
||||
* file read. Directories return a signed listing of children; files stream
|
||||
* bytes (with Range support when `download` isn't requested).
|
||||
*/
|
||||
file = async (req: Request, res: Response): Promise<void> => {
|
||||
const query = asRecord(req.query);
|
||||
@@ -1352,8 +1443,15 @@ export class LegacyFSController extends PuterController {
|
||||
query.download === '1' ||
|
||||
query.download === true;
|
||||
|
||||
if (download.contentType)
|
||||
if (download.contentType) {
|
||||
res.setHeader('Content-Type', download.contentType);
|
||||
// Uploader-controlled type served inline on the file origin —
|
||||
// sandbox active-document types so an uploaded HTML/SVG can't
|
||||
// execute scripts here. Mirrors FSController /fs/read.
|
||||
if (!wantsAttachment) {
|
||||
applyInlineContentSecurity(res, download.contentType);
|
||||
}
|
||||
}
|
||||
if (download.contentLength !== null)
|
||||
res.setHeader('Content-Length', String(download.contentLength));
|
||||
if (download.contentRange)
|
||||
@@ -1386,9 +1484,9 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /open_item — resolve an entry, grant the default suggested app
|
||||
* write access to it, and return a signed URL + user-app token so the
|
||||
* launched app can read/write the file via its app-under-user token.
|
||||
* POST /open_item — resolve an entry, grant the default suggested app write
|
||||
* access to it, and return a signed URL + user-app token so the launched
|
||||
* app can read/write the file via its app-under-user token.
|
||||
*
|
||||
* Matches v1 semantics: permission is always granted as `write` — the
|
||||
* underlying user's permission check still caps the effective access
|
||||
@@ -1462,8 +1560,8 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /auth/request-app-root-dir — an app-under-user requests stat on
|
||||
* its own app root directory. The app must own itself.
|
||||
* POST /auth/request-app-root-dir — an app-under-user requests stat on its
|
||||
* own app root directory. The app must own itself.
|
||||
*/
|
||||
requestAppRootDir = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
@@ -1510,10 +1608,7 @@ export class LegacyFSController extends PuterController {
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -1618,17 +1713,6 @@ export class LegacyFSController extends PuterController {
|
||||
download.body.pipe(res);
|
||||
};
|
||||
|
||||
// Helpers for writeFile
|
||||
// -- GUI event emission -------------------------------------------
|
||||
//
|
||||
// Fire-and-forget `outer.gui.item.*` events so SocketService,
|
||||
// BroadcastService, WorkerDriver (hot-reload), and cache-invalidation
|
||||
// listeners pick up mutations made through the legacy (bare-path) routes.
|
||||
// FSController (v2-native /fs/* routes) emits these from its own handlers;
|
||||
// LegacyFSController delegates to the same FSService but needs its
|
||||
// own emissions because the service layer deliberately doesn't emit GUI
|
||||
// events (that's a controller concern).
|
||||
|
||||
async #emitGuiEvent(
|
||||
eventName:
|
||||
| 'outer.gui.item.added'
|
||||
@@ -1662,14 +1746,6 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
async #resolveParentOfEntry(entry: { path: string; userId: number }) {
|
||||
const parentPath = pathPosix.dirname(entry.path);
|
||||
const parent = await resolveV1Selector(this.stores.fsEntry, {
|
||||
path: parentPath,
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
|
||||
async #multipartWrite(
|
||||
req: Request,
|
||||
userId: number,
|
||||
@@ -2188,12 +2264,21 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
#serializeBatchError(err: unknown): Record<string, unknown> {
|
||||
if (err instanceof HttpError) {
|
||||
return {
|
||||
const payload: Record<string, unknown> = {
|
||||
error: true,
|
||||
status: err.statusCode,
|
||||
message: err.message,
|
||||
code: err.legacyCode ?? err.code,
|
||||
};
|
||||
// Same as the terminal errorHandler: extra fields (e.g.
|
||||
// `entry_name` on item_with_same_name_exists) ride along
|
||||
// without clobbering the canonical slots.
|
||||
if (err.fields) {
|
||||
for (const [k, v] of Object.entries(err.fields)) {
|
||||
if (!(k in payload)) payload[k] = v;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { error: true, status: 500, message: err.message };
|
||||
@@ -2202,120 +2287,6 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// -- Helpers ---------------------------------------------------------
|
||||
|
||||
#parsePositiveIntegerQuery(
|
||||
query: Record<string, unknown>,
|
||||
key: string,
|
||||
message: string,
|
||||
): number | undefined {
|
||||
const value = query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
#parseNonNegativeIntegerQuery(
|
||||
query: Record<string, unknown>,
|
||||
key: string,
|
||||
message: string,
|
||||
): number | undefined {
|
||||
const value = query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
#normalizeRangeHeader(rangeHeader: string): string | undefined {
|
||||
const firstRange = rangeHeader.includes(',')
|
||||
? rangeHeader.split(',')[0]?.trim()
|
||||
: rangeHeader.trim();
|
||||
if (!firstRange) return undefined;
|
||||
|
||||
const matches = firstRange.match(/^bytes=(\d+)-(\d*)$/);
|
||||
if (!matches) return undefined;
|
||||
|
||||
const [, start, end] = matches;
|
||||
return end ? `bytes=${start}-${end}` : `bytes=${start}-`;
|
||||
}
|
||||
|
||||
#pipeLimitedLines(
|
||||
source: NodeJS.ReadableStream,
|
||||
res: Response,
|
||||
lineCount: number,
|
||||
): void {
|
||||
let remainingLines = lineCount;
|
||||
let isClosed = false;
|
||||
|
||||
const closeSource = () => {
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
if ('destroy' in source && typeof source.destroy === 'function') {
|
||||
source.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
source.on('error', (err) => {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
res.destroy(err);
|
||||
}
|
||||
});
|
||||
|
||||
res.on('close', () => {
|
||||
closeSource();
|
||||
});
|
||||
|
||||
source.on('data', (chunk: Buffer | string) => {
|
||||
if (isClosed) return;
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
let endIndex = buffer.length;
|
||||
|
||||
for (let index = 0; index < buffer.length; index++) {
|
||||
if (buffer[index] !== 0x0a) continue;
|
||||
remainingLines -= 1;
|
||||
if (remainingLines === 0) {
|
||||
endIndex = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex > 0) {
|
||||
const canContinue = res.write(buffer.subarray(0, endIndex));
|
||||
if (!canContinue) {
|
||||
source.pause();
|
||||
res.once('drain', () => {
|
||||
if (!isClosed) {
|
||||
source.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex !== buffer.length) {
|
||||
res.end();
|
||||
closeSource();
|
||||
}
|
||||
});
|
||||
|
||||
source.on('end', () => {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#requireActor(req: Request) {
|
||||
const actor = req.actor;
|
||||
if (!actor) {
|
||||
@@ -2355,7 +2326,7 @@ export class LegacyFSController extends PuterController {
|
||||
#parseSortBy(
|
||||
body: Record<string, unknown>,
|
||||
): 'name' | 'modified' | 'type' | 'size' | null {
|
||||
const raw = getString(body, 'sort_by');
|
||||
const raw = getString(body, 'sortBy') || getString(body, 'sort_by');
|
||||
if (!raw) return null;
|
||||
const normalized = raw.toLowerCase();
|
||||
return (
|
||||
@@ -2366,7 +2337,8 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
#parseSortOrder(body: Record<string, unknown>): 'asc' | 'desc' | null {
|
||||
const raw = getString(body, 'sort_order');
|
||||
const raw =
|
||||
getString(body, 'sortOrder') || getString(body, 'sort_order');
|
||||
if (!raw) return null;
|
||||
const normalized = raw.toLowerCase();
|
||||
return (['asc', 'desc'] as const).find((v) => v === normalized) ?? null;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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 { loadLegacyAssociatedApps } from './legacyFsHelpers.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
|
||||
const entryWithApp = (associatedAppId: number): FSEntry =>
|
||||
({ associatedAppId }) as unknown as FSEntry;
|
||||
|
||||
const appRow = (overrides: Record<string, unknown>): Record<string, unknown> => ({
|
||||
id: 1,
|
||||
uid: 'app-1',
|
||||
owner_user_id: 99,
|
||||
app_owner: 99,
|
||||
icon: 'icon',
|
||||
name: 'an-app',
|
||||
title: 'An App',
|
||||
description: 'desc',
|
||||
godmode: 1,
|
||||
maximize_on_start: 1,
|
||||
index_url: 'https://an-app.puter.site/',
|
||||
background: 1,
|
||||
metadata: { secret: true },
|
||||
is_private: 0,
|
||||
protected: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fakeStore = (row: Record<string, unknown>) => ({
|
||||
getByIds: async (ids: number[]) =>
|
||||
new Map(ids.map((id) => [id, { ...row, id }])),
|
||||
});
|
||||
|
||||
describe('loadLegacyAssociatedApps associated_app redaction', () => {
|
||||
it('never leaks owner identifiers, even for public apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 0 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app).not.toHaveProperty('owner_user_id');
|
||||
expect(app).not.toHaveProperty('app_owner');
|
||||
});
|
||||
|
||||
it('passes through hosting + capability fields for public apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 0, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app.index_url).toBe('https://an-app.puter.site/');
|
||||
expect(app.godmode).toBe(1);
|
||||
expect(app.maximize_on_start).toBe(1);
|
||||
});
|
||||
|
||||
it('redacts hosting + capability fields for private apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 1, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
// Existence + display fields still surface...
|
||||
expect(app.uid).toBe('app-1');
|
||||
expect(app.name).toBe('an-app');
|
||||
expect(app.is_private).toBe(1);
|
||||
// ...but the sensitive bits are stripped.
|
||||
expect(app.index_url).toBeNull();
|
||||
expect(app.godmode).toBe(0);
|
||||
expect(app.maximize_on_start).toBe(0);
|
||||
expect(app.background).toBe(0);
|
||||
expect(app.metadata).toBeNull();
|
||||
expect(app).not.toHaveProperty('owner_user_id');
|
||||
});
|
||||
|
||||
it('redacts the same fields for protected apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 1, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app.protected).toBe(1);
|
||||
expect(app.index_url).toBeNull();
|
||||
expect(app.godmode).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
@@ -43,9 +44,8 @@ import type { IConfig } from '../../types.js';
|
||||
/**
|
||||
* Shared helpers used by the legacy FS route shims (LegacyFSController).
|
||||
*
|
||||
* Legacy clients speak snake_case and expect specific response shapes —
|
||||
* these helpers encapsulate that translation so the route handlers stay
|
||||
* terse.
|
||||
* Legacy clients speak snake_case and expect specific response shapes — these
|
||||
* helpers encapsulate that translation so the route handlers stay terse.
|
||||
*/
|
||||
|
||||
// -- Body parsing -----------------------------------------------------
|
||||
@@ -220,15 +220,15 @@ export async function assertAccess(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
* On failure, delegates to `assertAccess` on the parent so the error shape
|
||||
* stays identical to the previous parent-only check.
|
||||
*/
|
||||
export async function assertCanCreate(
|
||||
aclService: ACLService,
|
||||
@@ -272,23 +272,36 @@ const toIntBool = (v: unknown): number => (v ? 1 : 0);
|
||||
/**
|
||||
* Convert an AppStore-normalized app row into the v1 `associated_app` shape
|
||||
* embedded in legacy FS entries. Booleans round-trip back to integers (0/1)
|
||||
* because the v1 wire contract emits them that way and existing clients key
|
||||
* off it. Other columns pass through as-is — `metadata` is already parsed.
|
||||
* because the v1 wire contract emits them that way and existing clients key off
|
||||
* it. Other columns pass through as-is — `metadata` is already parsed.
|
||||
*
|
||||
* Two redactions, because `associatedAppId` can name an app the actor does not
|
||||
* own (the column is client-writable display metadata, never trusted for authz
|
||||
* — see FSController) and may point cross-tenant:
|
||||
*
|
||||
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted — they're
|
||||
* internal user references with no client-side display use.
|
||||
* - For private or protected apps, the direct hosting URL and launch capability
|
||||
* flags (`index_url`, `godmode`, `maximize_on_start`, `background`,
|
||||
* `metadata`) are dropped. This mirrors AppDriver, which withholds
|
||||
* `index_url` from callers without read entitlement. Display fields (name,
|
||||
* icon, title) still pass through so the GUI can label the file's associated
|
||||
* app.
|
||||
*/
|
||||
function mapAppForLegacyAssociatedApp(
|
||||
app: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const gated = Boolean(app.is_private) || Boolean(app.protected);
|
||||
return {
|
||||
id: app.id,
|
||||
uid: app.uid,
|
||||
owner_user_id: app.owner_user_id,
|
||||
icon: app.icon,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
description: app.description,
|
||||
godmode: toIntBool(app.godmode),
|
||||
maximize_on_start: toIntBool(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
godmode: gated ? 0 : toIntBool(app.godmode),
|
||||
maximize_on_start: gated ? 0 : toIntBool(app.maximize_on_start),
|
||||
index_url: gated ? null : app.index_url,
|
||||
approved_for_listing: toIntBool(app.approved_for_listing),
|
||||
approved_for_opening_items: toIntBool(app.approved_for_opening_items),
|
||||
approved_for_incentive_program: toIntBool(
|
||||
@@ -297,9 +310,8 @@ function mapAppForLegacyAssociatedApp(
|
||||
timestamp: app.timestamp ?? null,
|
||||
last_review: app.last_review ?? null,
|
||||
tags: app.tags ?? null,
|
||||
app_owner: app.app_owner ?? null,
|
||||
background: toIntBool(app.background),
|
||||
metadata: app.metadata ?? null,
|
||||
background: gated ? 0 : toIntBool(app.background),
|
||||
metadata: gated ? null : (app.metadata ?? null),
|
||||
protected: toIntBool(app.protected),
|
||||
is_private: toIntBool(app.is_private),
|
||||
};
|
||||
@@ -308,13 +320,15 @@ function mapAppForLegacyAssociatedApp(
|
||||
/**
|
||||
* Batch-load `associated_app` payloads for a set of entries. Dedupes app ids
|
||||
* across the input, hands them to `AppStore.getByIds` (one pipelined Redis
|
||||
* MGET + a single `id IN (…)` query for any cache misses), and returns a map
|
||||
* keyed by app id holding the v1-shaped embed. Callers pass the result to
|
||||
* `toLegacyEntry` via `opts.appsById` so each entry hydrates without a
|
||||
* second round-trip.
|
||||
* MGET
|
||||
*
|
||||
* Empty input short-circuits — readdir on a directory of plain files makes
|
||||
* zero extra calls.
|
||||
* - A single `id IN (…)` query for any cache misses), and returns a map keyed by
|
||||
* app id holding the v1-shaped embed. Callers pass the result to
|
||||
* `toLegacyEntry` via `opts.appsById` so each entry hydrates without a second
|
||||
* round-trip.
|
||||
*
|
||||
* Empty input short-circuits — readdir on a directory of plain files makes zero
|
||||
* extra calls.
|
||||
*/
|
||||
export async function loadLegacyAssociatedApps(
|
||||
appStore: AppRowLookup,
|
||||
@@ -337,12 +351,55 @@ export async function loadLegacyAssociatedApps(
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the snake_case entry shape legacy clients expect. If `thumbnail`
|
||||
* is set, asks the thumbnail extension (via `thumbnail.read` event) to swap
|
||||
* an S3 URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate
|
||||
* `is_empty` (directories) and `owner` — both are required fields per
|
||||
* the legacy stat contract but need extra DB lookups. Pass `appsById`
|
||||
* (built via `loadLegacyAssociatedApps`) to populate `associated_app`.
|
||||
* The v1 `type` field: a MIME content-type (e.g. "image/png; charset=utf-8")
|
||||
* for files, or "folder" for directories. The GUI's icon lookup keys off
|
||||
* `type.startsWith('image/')`, so a bare extension breaks icon selection.
|
||||
*/
|
||||
export function fsEntryMimeType(entry: {
|
||||
isDir: boolean;
|
||||
name: string;
|
||||
}): string | null {
|
||||
return entry.isDir ? 'folder' : contentTypeFromMime(entry.name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap an S3 thumbnail key for a signed URL via the thumbnail extension
|
||||
* (`thumbnail.read` event). Returns the input unchanged when there's no
|
||||
* thumbnail or no event client; returns null if signing yields nothing.
|
||||
*/
|
||||
export async function signEntryThumbnail(
|
||||
eventClient: EventClient | undefined,
|
||||
uuid: string,
|
||||
thumbnail: string | null,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
typeof thumbnail !== 'string' ||
|
||||
thumbnail.length === 0 ||
|
||||
!eventClient
|
||||
) {
|
||||
return thumbnail ?? null;
|
||||
}
|
||||
const thumbnailEntry = { uuid, thumbnail };
|
||||
try {
|
||||
// emitAndWait — listener mutates `thumbnail` on the payload; plain
|
||||
// `emit` is fire-and-forget and would drop the rewrite.
|
||||
await eventClient.emitAndWait('thumbnail.read', thumbnailEntry, {});
|
||||
} catch {
|
||||
// ignore — non-critical.
|
||||
}
|
||||
return typeof thumbnailEntry.thumbnail === 'string' &&
|
||||
thumbnailEntry.thumbnail.length > 0
|
||||
? thumbnailEntry.thumbnail
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the snake_case entry shape legacy clients expect. If `thumbnail` is
|
||||
* set, asks the thumbnail extension (via `thumbnail.read` event) to swap an S3
|
||||
* URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate `is_empty`
|
||||
* (directories) and `owner` — both are required fields per the legacy stat
|
||||
* contract but need extra DB lookups. Pass `appsById` (built via
|
||||
* `loadLegacyAssociatedApps`) to populate `associated_app`.
|
||||
*/
|
||||
export async function toLegacyEntry(
|
||||
eventClient: EventClient | undefined,
|
||||
@@ -356,13 +413,7 @@ export async function toLegacyEntry(
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const dirname = pathPosix.dirname(entry.path);
|
||||
// v1 contract: `type` is a MIME content-type (e.g. "image/png; charset=utf-8")
|
||||
// for files, or "folder" for directories. The GUI's icon lookup does
|
||||
// `type.startsWith('image/')` etc., so a bare extension breaks every
|
||||
// banner that falls through the name-extension ladder in item_icon.js.
|
||||
const mimeType = entry.isDir
|
||||
? 'folder'
|
||||
: contentTypeFromMime(entry.name) || null;
|
||||
const mimeType = fsEntryMimeType(entry);
|
||||
|
||||
const pathComponents = entry.path.split('/');
|
||||
const appdata_app =
|
||||
@@ -433,28 +484,11 @@ export async function toLegacyEntry(
|
||||
}
|
||||
|
||||
// Let the thumbnail extension swap an s3:// key for a signed URL.
|
||||
if (
|
||||
typeof response.thumbnail === 'string' &&
|
||||
(response.thumbnail as string).length > 0 &&
|
||||
eventClient
|
||||
) {
|
||||
const thumbnailEntry = {
|
||||
uuid: entry.uuid,
|
||||
thumbnail: response.thumbnail as string,
|
||||
};
|
||||
try {
|
||||
// emitAndWait — listener mutates `thumbnail` on the payload;
|
||||
// plain `emit` is fire-and-forget and would drop the rewrite.
|
||||
await eventClient.emitAndWait('thumbnail.read', thumbnailEntry, {});
|
||||
} catch {
|
||||
// ignore — non-critical.
|
||||
}
|
||||
response.thumbnail =
|
||||
typeof thumbnailEntry.thumbnail === 'string' &&
|
||||
thumbnailEntry.thumbnail.length > 0
|
||||
? thumbnailEntry.thumbnail
|
||||
: null;
|
||||
}
|
||||
response.thumbnail = await signEntryThumbnail(
|
||||
eventClient,
|
||||
entry.uuid,
|
||||
response.thumbnail as string | null,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -464,8 +498,8 @@ export { normalizeAbsolutePath };
|
||||
// -- Signing ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pull the signing config off the app config. Throws if either value is
|
||||
* missing — these are required for signed URL routes to function.
|
||||
* Pull the signing config off the app config. Throws if either value is missing
|
||||
* — these are required for signed URL routes to function.
|
||||
*/
|
||||
export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
|
||||
const secret = config.url_signature_secret;
|
||||
@@ -487,9 +521,7 @@ export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
|
||||
return { secret, apiBaseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: turn an FSEntry into a signed-file response object.
|
||||
*/
|
||||
/** Convenience wrapper: turn an FSEntry into a signed-file response object. */
|
||||
export function signEntry(
|
||||
entry: {
|
||||
uuid: string;
|
||||
|
||||
@@ -3,22 +3,27 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { FSEntry, FSEntryWriteInput } from '../../stores/fs/FSEntry.js';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type {
|
||||
FSEntry,
|
||||
FSEntrySubdomain,
|
||||
FSEntryWriteInput,
|
||||
} from '../../stores/fs/FSEntry.js';
|
||||
|
||||
export type UploadMode = 'single' | 'multipart';
|
||||
|
||||
@@ -129,3 +134,100 @@ export interface WriteResponse {
|
||||
requestedThumbnail?: string | null;
|
||||
contentHashSha256?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An `FSEntry` as it is safe to hand to a client. Built by an allowlist, so the
|
||||
* `fsentries` primary key (`id`) and its `parentId`/`associatedAppId`
|
||||
* references, the storage columns (`bucket`, `bucketRegion`), the owning
|
||||
* `userId`, and the `publicToken`/`fileRequestToken` capability tokens never
|
||||
* reach the wire. Entries are addressed by `uuid`.
|
||||
*
|
||||
* `shortcutTo` is the one numeric row reference that stays: the v1 contract has
|
||||
* always exposed it as `shortcut_to` and the desktop resolves shortcuts through
|
||||
* it, so dropping it would break them.
|
||||
*
|
||||
* The `?: never` members below are guards, not fields: they make a raw
|
||||
* `FSEntry` fail to typecheck wherever a `ClientFSEntry` is expected. Without
|
||||
* them this type is just a structural subset of `FSEntry`, so an unsanitized
|
||||
* row would be silently assignable and the distinction would buy nothing.
|
||||
*
|
||||
* Checked by `tsc -p tsconfig.json` (the strict config). Note that
|
||||
* `tsconfig.build.json` sets `noCheck: true` — it only transpiles, so it will
|
||||
* not catch a violation here.
|
||||
*/
|
||||
export interface ClientFSEntry {
|
||||
uuid: string;
|
||||
uid: string;
|
||||
parentUid: string | null;
|
||||
path: string;
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
isShortcut: boolean;
|
||||
shortcutTo: number | null;
|
||||
isSymlink: boolean;
|
||||
symlinkPath: string | null;
|
||||
isPublic: boolean | null;
|
||||
immutable: boolean;
|
||||
metadata: string | null;
|
||||
modified: number;
|
||||
created: number | null;
|
||||
accessed: number | null;
|
||||
size: number | null;
|
||||
layout: string | null;
|
||||
subdomains: FSEntrySubdomain[];
|
||||
workers: FSEntrySubdomain[];
|
||||
hasWebsite: boolean;
|
||||
suggestedApps: unknown[];
|
||||
|
||||
id?: never;
|
||||
userId?: never;
|
||||
parentId?: never;
|
||||
associatedAppId?: never;
|
||||
bucket?: never;
|
||||
bucketRegion?: never;
|
||||
publicToken?: never;
|
||||
fileRequestToken?: never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire counterparts of the write responses. The bare `…Response` types describe
|
||||
* what the service produces internally (a real `FSEntry`); these describe what
|
||||
* the controller sends after sanitizing. Keeping them distinct is what makes
|
||||
* "did this response get sanitized?" a question the compiler answers.
|
||||
*/
|
||||
/**
|
||||
* The presigned-upload envelope minus the storage internals. A client uploads
|
||||
* to the presigned `url` / `multipartPartUrls`, which already carry everything
|
||||
* S3 needs, so `bucket`, `bucketRegion`, and `objectKey` are ours to keep —
|
||||
* they name where a user's bytes physically live.
|
||||
*/
|
||||
export type ClientSignedWriteResponse = Omit<
|
||||
SignedWriteResponse,
|
||||
'fsEntry' | 'bucket' | 'bucketRegion' | 'objectKey'
|
||||
> & { fsEntry?: ClientFSEntry };
|
||||
|
||||
export type ClientSignMultipartPartsResponse = Omit<
|
||||
SignMultipartPartsResponse,
|
||||
'bucket' | 'bucketRegion' | 'objectKey'
|
||||
>;
|
||||
|
||||
export type ClientCompleteWriteResponse = Omit<
|
||||
CompleteWriteResponse,
|
||||
'fsEntry'
|
||||
> & { fsEntry: ClientFSEntry };
|
||||
|
||||
export type ClientWriteResponse = Omit<WriteResponse, 'fsEntry'> & {
|
||||
fsEntry: ClientFSEntry;
|
||||
};
|
||||
|
||||
/**
|
||||
* A directory-listing entry: a sanitized entry plus the three fields a client
|
||||
* cannot derive on its own — the MIME `type`, a _signed_ `thumbnail` URL (the
|
||||
* stored value is an S3 key, which is useless to a client), and the resolved
|
||||
* `associatedApp`.
|
||||
*/
|
||||
export type ClientReaddirEntry = ClientFSEntry & {
|
||||
type: string | null;
|
||||
thumbnail: string | null;
|
||||
associatedApp: Record<string, unknown> | null;
|
||||
};
|
||||
@@ -165,6 +165,43 @@ describe('HomepageController shell routes', () => {
|
||||
expect(html).toContain('Puter');
|
||||
});
|
||||
|
||||
it('exposes disable_temp_users to the GUI when signups are disabled', async () => {
|
||||
const homepageConfig = server.controllers.homepage.config as {
|
||||
disable_user_signup?: boolean;
|
||||
};
|
||||
const prev = homepageConfig.disable_user_signup;
|
||||
homepageConfig.disable_user_signup = true;
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute('get', '/', makeReq({ path: '/' }), res);
|
||||
} finally {
|
||||
homepageConfig.disable_user_signup = prev;
|
||||
}
|
||||
expect(String(captured.body)).toContain('"disable_temp_users":true');
|
||||
});
|
||||
|
||||
it('keeps an operator-set gui_params.disable_temp_users when the flag is off', async () => {
|
||||
const homepageConfig = server.controllers.homepage.config as {
|
||||
disable_user_signup?: boolean;
|
||||
gui_params?: Record<string, unknown>;
|
||||
};
|
||||
const prevFlag = homepageConfig.disable_user_signup;
|
||||
const prevGuiParams = homepageConfig.gui_params;
|
||||
homepageConfig.disable_user_signup = false;
|
||||
homepageConfig.gui_params = {
|
||||
...prevGuiParams,
|
||||
disable_temp_users: true,
|
||||
};
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute('get', '/', makeReq({ path: '/' }), res);
|
||||
} finally {
|
||||
homepageConfig.disable_user_signup = prevFlag;
|
||||
homepageConfig.gui_params = prevGuiParams;
|
||||
}
|
||||
expect(String(captured.body)).toContain('"disable_temp_users":true');
|
||||
});
|
||||
|
||||
it('still serves the shell when an authenticated actor is present', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -173,12 +210,14 @@ describe('HomepageController shell routes', () => {
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
});
|
||||
|
||||
it('serves the shell on /settings, /dashboard, /action, /@:username', async () => {
|
||||
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
|
||||
for (const path of [
|
||||
'/settings',
|
||||
'/settings/*splat',
|
||||
'/dashboard',
|
||||
'/dashboard/',
|
||||
'/desktop',
|
||||
'/desktop/',
|
||||
'/action/*splat',
|
||||
'/@:username',
|
||||
]) {
|
||||
@@ -238,6 +277,43 @@ describe('HomepageController GET /app/:name', () => {
|
||||
expect(html).toMatch(/<!DOCTYPE html>/i);
|
||||
expect(html).toContain('Cool App');
|
||||
});
|
||||
|
||||
it('does not leak a private app index_url or owner id to an anonymous visitor', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `priv-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const secretUrl = `https://secret-${name}.example.com/`;
|
||||
// `is_private` is a READ_ONLY_COLUMN, so it can't be set through the
|
||||
// store's create/update allow-list — insert the private row directly.
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`description\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
`app-${uuidv4()}`,
|
||||
name,
|
||||
'Private App',
|
||||
'a private app row',
|
||||
secretUrl,
|
||||
userId,
|
||||
1,
|
||||
],
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/app/:name',
|
||||
makeReq({ params: { name }, path: `/app/${name}` }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const html = String(captured.body);
|
||||
// Public meta (title) is fine to render...
|
||||
expect(html).toContain('Private App');
|
||||
// ...but the private hosting URL and owner id must be redacted.
|
||||
expect(html).not.toContain(secretUrl);
|
||||
expect(html).not.toContain('owner_user_id');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /show/* ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -82,6 +82,9 @@ export class HomepageController extends PuterController {
|
||||
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/desktop', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/@:username', {}, (req, res) => sendShell(req, res));
|
||||
@@ -98,6 +101,18 @@ export class HomepageController extends PuterController {
|
||||
? safeJsonParse(app.metadata)
|
||||
: (app.metadata as Record<string, unknown> | null)) ??
|
||||
{};
|
||||
// Never bake the raw store row into the shell — it exposes
|
||||
// `index_url`, `owner_user_id`, and admin flags. Run it
|
||||
// through the authoritative serializer, which redacts to a
|
||||
// safe field subset and drops `index_url` for a private app
|
||||
// this actor (possibly anonymous) isn't entitled to.
|
||||
const actor =
|
||||
(req as express.Request & { actor?: unknown }).actor ??
|
||||
null;
|
||||
const clientApp = await this.drivers.apps.toClientView(
|
||||
app,
|
||||
actor,
|
||||
);
|
||||
await sendShell(req, res, {
|
||||
title: String(app.title ?? name),
|
||||
description: String(app.description ?? ''),
|
||||
@@ -107,7 +122,7 @@ export class HomepageController extends PuterController {
|
||||
typeof metadata.social_image === 'string'
|
||||
? metadata.social_image
|
||||
: undefined,
|
||||
app: app as Record<string, unknown>,
|
||||
app: clientApp as Record<string, unknown>,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export class HostingController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
requireVerified: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
|
||||
@@ -38,7 +38,11 @@ export class NotificationController extends PuterController {
|
||||
* POST /notif/mark-ack — user dismissed a notification.
|
||||
* Sets `acknowledged` timestamp; pushes ack event to sockets.
|
||||
*/
|
||||
@Post('/mark-ack', { subdomain: 'api', requireUserActor: true })
|
||||
@Post('/mark-ack', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
})
|
||||
async markAck(req: Request, res: Response): Promise<void> {
|
||||
const uid = req.body?.uid;
|
||||
if (typeof uid !== 'string' || uid.length === 0) {
|
||||
@@ -77,7 +81,11 @@ export class NotificationController extends PuterController {
|
||||
* POST /notif/mark-read — user saw a notification.
|
||||
* Sets `shown` timestamp; pushes ack event to sockets.
|
||||
*/
|
||||
@Post('/mark-read', { subdomain: 'api', requireUserActor: true })
|
||||
@Post('/mark-read', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
})
|
||||
async markRead(req: Request, res: Response): Promise<void> {
|
||||
const uid = req.body?.uid;
|
||||
if (typeof uid !== 'string' || uid.length === 0) {
|
||||
|
||||
@@ -52,6 +52,12 @@ const TEST_ORIGIN = 'http://test.local';
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
// Stand-in for the abuse extension's signup veto. EventClient has no off(),
|
||||
// so one shared listener is installed in beforeAll and tests swap the
|
||||
// override in and out (same pattern as AuthController.test.ts).
|
||||
type SignupValidateOverride = (data: Record<string, unknown>) => void;
|
||||
let signupValidateOverride: SignupValidateOverride | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
origin: TEST_ORIGIN,
|
||||
@@ -75,6 +81,14 @@ beforeAll(async () => {
|
||||
} as never);
|
||||
router = new PuterRouter();
|
||||
server.controllers.oidc.registerRoutes(router);
|
||||
server.clients.event.on(
|
||||
'puter.signup.validate',
|
||||
(_k: unknown, data: unknown) => {
|
||||
if (signupValidateOverride) {
|
||||
signupValidateOverride(data as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -83,6 +97,7 @@ afterAll(async () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
signupValidateOverride = null;
|
||||
});
|
||||
|
||||
interface CapturedResponse {
|
||||
@@ -92,6 +107,7 @@ interface CapturedResponse {
|
||||
redirectUrl?: string;
|
||||
headers: Record<string, string>;
|
||||
cookies: Array<{ name: string; value: string; opts?: unknown }>;
|
||||
clearedCookies: Array<{ name: string; opts?: unknown }>;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
@@ -100,6 +116,7 @@ const makeReq = (init: {
|
||||
query?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
cookies?: Record<string, string>;
|
||||
method?: string;
|
||||
}): Request => {
|
||||
return {
|
||||
@@ -107,6 +124,7 @@ const makeReq = (init: {
|
||||
query: init.query ?? {},
|
||||
params: init.params ?? {},
|
||||
headers: init.headers ?? {},
|
||||
cookies: init.cookies ?? {},
|
||||
method: init.method ?? 'GET',
|
||||
} as unknown as Request;
|
||||
};
|
||||
@@ -117,6 +135,7 @@ const makeRes = () => {
|
||||
body: undefined,
|
||||
headers: {},
|
||||
cookies: [],
|
||||
clearedCookies: [],
|
||||
};
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
@@ -155,6 +174,10 @@ const makeRes = () => {
|
||||
captured.cookies.push({ name, value, opts });
|
||||
return res;
|
||||
}),
|
||||
clearCookie: vi.fn((name: string, opts?: unknown) => {
|
||||
captured.clearedCookies.push({ name, opts });
|
||||
return res;
|
||||
}),
|
||||
type: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
@@ -593,6 +616,7 @@ describe('OIDCController login callback', () => {
|
||||
);
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('message=account_suspended');
|
||||
// No session cookie issued for suspended accounts.
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
@@ -674,9 +698,7 @@ describe('OIDCController login callback', () => {
|
||||
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
|
||||
expect(captured.redirectUrl).toContain('msg_id=msg-99');
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
// 'This account is suspended.' is not in ALLOWED_ERRORS, so the
|
||||
// builder falls back to 'unauthorized'.
|
||||
expect(captured.redirectUrl).toContain('message=unauthorized');
|
||||
expect(captured.redirectUrl).toContain('message=account_suspended');
|
||||
expect(captured.redirectUrl).toContain(
|
||||
`opener_origin=${encodeURIComponent('http://opener.test')}`,
|
||||
);
|
||||
@@ -767,6 +789,291 @@ describe('OIDCController login callback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- Browser binding / login-CSRF --------------------------------------
|
||||
|
||||
describe('OIDCController signup veto (abuse harness)', () => {
|
||||
const vetoWithTrail = (email: string, trailId: string) => {
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email !== email) return;
|
||||
data.allow = false;
|
||||
data.trail_id = trailId;
|
||||
};
|
||||
};
|
||||
|
||||
const stubIdp = (sub: string, email: string) => {
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('createUserFromOIDC returns signup_blocked and the trail id as requestCode', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-svc-1');
|
||||
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(false);
|
||||
expect(created.code).toBe('signup_blocked');
|
||||
expect(created.requestCode).toBe('trail-svc-1');
|
||||
});
|
||||
|
||||
it('login callback redirects with signup_blocked + request_code when first sign-in is vetoed', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-login-1');
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('action=login');
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('request_code=trail-login-1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('signup callback redirects with signup_blocked + request_code when vetoed', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-signup-1');
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('action=signup');
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('request_code=trail-signup-1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a veto with no trail id still redirects with signup_blocked and no request_code', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email !== email) return;
|
||||
data.allow = false;
|
||||
};
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).not.toContain('request_code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDCController browser binding', () => {
|
||||
const NONCE_COOKIE = 'puter_oidc_nonce';
|
||||
|
||||
const stubIdP = (sub: string, email: string) => {
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('/start sets an HttpOnly nonce cookie matching the nonce embedded in state', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/:provider/start',
|
||||
makeReq({ params: { provider: 'custom' } }),
|
||||
res,
|
||||
);
|
||||
|
||||
const nonceCookie = captured.cookies.find(
|
||||
(c) => c.name === NONCE_COOKIE,
|
||||
);
|
||||
expect(nonceCookie).toBeTruthy();
|
||||
expect(nonceCookie?.value).toBeTruthy();
|
||||
expect((nonceCookie?.opts as { httpOnly?: boolean })?.httpOnly).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The cookie value must equal the nonce baked into the signed state.
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
const decoded = oidc().verifyState(state!);
|
||||
expect(decoded?.nonce).toBe(nonceCookie?.value);
|
||||
});
|
||||
|
||||
it('completes login when the nonce cookie matches the state nonce', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `bind-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const nonce = 'browser-nonce-match';
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce,
|
||||
});
|
||||
stubIdP(sub, email);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({
|
||||
query: { code: 'c', state },
|
||||
cookies: { [NONCE_COOKIE]: nonce },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
// Session cookie issued; single-use nonce cookie cleared.
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
expect(
|
||||
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects login (no session cookie) when the nonce cookie is absent — the login-CSRF case', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce: 'attacker-flow-nonce',
|
||||
});
|
||||
// If enforcement were missing, this would resolve a user and set a
|
||||
// session cookie for the victim's browser. It must not get that far.
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
// Victim's browser has no nonce cookie for the attacker's flow.
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
// We bail before ever exchanging the code.
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects login when the nonce cookie does not match the state nonce', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce: 'expected-nonce',
|
||||
});
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({
|
||||
query: { code: 'c', state },
|
||||
cookies: { [NONCE_COOKIE]: 'a-different-nonce' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the revalidate callback (400) when the nonce cookie is missing', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
flow: 'revalidate',
|
||||
user_uuid: uuidv4(),
|
||||
nonce: 'reval-nonce',
|
||||
});
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/revalidate',
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(400);
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets legacy nonce-less state through (deploy grace) without touching the nonce cookie', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `legacy-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
// No `nonce` field — mimics a state signed before this shipped.
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
stubIdP(sub, email);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
// Proceeds as before; no nonce cookie is cleared for legacy states.
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
expect(
|
||||
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /auth/oidc/callback/signup ──────────────────────────────────────
|
||||
|
||||
describe('OIDCController signup callback', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
@@ -26,45 +27,63 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
|
||||
const REVALIDATION_EXPIRY_SEC = 300;
|
||||
|
||||
// Companion cookie that binds an OIDC flow to the browser that started it.
|
||||
// Expiry mirrors STATE_EXPIRY_SEC in OIDCService — the state and its
|
||||
// browser-binding cookie must expire together.
|
||||
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
|
||||
const OIDC_NONCE_EXPIRY_SEC = 600;
|
||||
|
||||
const OIDC_ERROR_REDIRECT_MAP: Record<string, Record<string, string>> = {
|
||||
login: { account_not_found: 'signup', other: 'login' },
|
||||
signup: { account_already_exists: 'login', other: 'signup' },
|
||||
};
|
||||
|
||||
const ALLOWED_ERRORS = ['account_suspended', 'unauthorized'] as const;
|
||||
// The `message` query param is clamped to these codes — the GUI maps them to
|
||||
// display text. Free-text errors (which may describe internals or, for vetoed
|
||||
// signups, the block reason) never reach the redirect URL.
|
||||
const ALLOWED_ERRORS = [
|
||||
'account_suspended',
|
||||
'unauthorized',
|
||||
'signup_blocked',
|
||||
] as const;
|
||||
|
||||
function buildErrorRedirectUrl(
|
||||
origin: string,
|
||||
sourceFlow: string,
|
||||
errorCondition: string,
|
||||
message: (typeof ALLOWED_ERRORS)[number],
|
||||
message: string,
|
||||
stateDecoded?: Record<string, unknown>,
|
||||
requestCode?: string,
|
||||
): string {
|
||||
const targetFlow =
|
||||
OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
|
||||
const base = origin.replace(/\/$/, '') || '/';
|
||||
const clamped = (ALLOWED_ERRORS as readonly string[]).includes(message)
|
||||
? message
|
||||
: 'unauthorized';
|
||||
|
||||
let params: URLSearchParams;
|
||||
if (stateDecoded?.embedded_in_popup && stateDecoded?.msg_id != null) {
|
||||
const params = new URLSearchParams({
|
||||
params = new URLSearchParams({
|
||||
embedded_in_popup: 'true',
|
||||
msg_id: String(stateDecoded.msg_id),
|
||||
auth_error: '1',
|
||||
message: ALLOWED_ERRORS.includes(message)
|
||||
? message
|
||||
: 'unauthorized',
|
||||
message: clamped,
|
||||
action: targetFlow,
|
||||
});
|
||||
if (stateDecoded?.opener_origin) {
|
||||
params.set('opener_origin', String(stateDecoded.opener_origin));
|
||||
}
|
||||
return `${base}/?${params.toString()}`;
|
||||
} else {
|
||||
params = new URLSearchParams({
|
||||
action: targetFlow,
|
||||
auth_error: '1',
|
||||
message: clamped,
|
||||
});
|
||||
}
|
||||
if (requestCode) {
|
||||
params.set('request_code', requestCode);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
action: targetFlow,
|
||||
auth_error: '1',
|
||||
message: ALLOWED_ERRORS.includes(message) ? message : 'unauthorized',
|
||||
});
|
||||
return `${base}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -73,6 +92,14 @@ function appendQueryParam(url: string, key: string, value: string): string {
|
||||
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
/** Length-safe constant-time string compare (never throws on mismatch). */
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `target` parses as a URL whose origin equals `origin`. Used to
|
||||
* clamp OIDC redirect targets — `startsWith` would accept
|
||||
@@ -139,6 +166,19 @@ export class OIDCController extends PuterController {
|
||||
|
||||
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
|
||||
|
||||
// Optional GUI return path so login started from /desktop or
|
||||
// /dashboard lands back there. Strict whitelist — never a
|
||||
// client-supplied URL (no open redirect).
|
||||
const rawReturnTo = Array.isArray(req.query.return_to)
|
||||
? req.query.return_to[0]
|
||||
: req.query.return_to;
|
||||
if (
|
||||
(flow === 'login' || flow === 'signup') &&
|
||||
(rawReturnTo === '/desktop' || rawReturnTo === '/dashboard')
|
||||
) {
|
||||
appRedirectUri = `${origin}${rawReturnTo}`;
|
||||
}
|
||||
|
||||
// Popup support
|
||||
const rawPopup = Array.isArray(req.query.embedded_in_popup)
|
||||
? req.query.embedded_in_popup[0]
|
||||
@@ -198,6 +238,17 @@ export class OIDCController extends PuterController {
|
||||
statePayload.flow = 'revalidate';
|
||||
}
|
||||
|
||||
// Bind this flow to the initiating browser: a single-use
|
||||
// nonce lives both in the signed `state` and in an HttpOnly
|
||||
// companion cookie. The callback requires them to match, so a
|
||||
// `state` captured from an attacker's own flow can't be
|
||||
// replayed in a victim's browser (login-CSRF / session
|
||||
// fixation).
|
||||
const browserNonce = crypto
|
||||
.randomBytes(32)
|
||||
.toString('base64url');
|
||||
statePayload.nonce = browserNonce;
|
||||
|
||||
const state = this.services.oidc.signState(statePayload);
|
||||
const url = await this.services.oidc.getAuthorizationUrl(
|
||||
provider,
|
||||
@@ -211,6 +262,15 @@ export class OIDCController extends PuterController {
|
||||
{ legacyCode: 'internal_error' },
|
||||
);
|
||||
|
||||
res.cookie(OIDC_NONCE_COOKIE_NAME, browserNonce, {
|
||||
// Same flags as the session cookie: SameSite=None;Secure
|
||||
// on HTTPS so the cookie survives Apple's cross-site
|
||||
// form_post callback; Lax on plain-HTTP self-host.
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_NONCE_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
res.redirect(302, url);
|
||||
},
|
||||
);
|
||||
@@ -224,7 +284,7 @@ export class OIDCController extends PuterController {
|
||||
|
||||
const loginCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'login');
|
||||
const result = await this.#processCallback(req, res, 'login');
|
||||
if ('error' in result) {
|
||||
console.warn(`OIDC login callback error: ${result.error}`);
|
||||
return res.redirect(
|
||||
@@ -255,8 +315,9 @@ export class OIDCController extends PuterController {
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
resolved.error,
|
||||
resolved.code ?? 'unauthorized',
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -272,7 +333,7 @@ export class OIDCController extends PuterController {
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
'This account is suspended.',
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
@@ -287,7 +348,7 @@ export class OIDCController extends PuterController {
|
||||
|
||||
const signupCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'signup');
|
||||
const result = await this.#processCallback(req, res, 'signup');
|
||||
if ('error' in result) {
|
||||
return res.redirect(
|
||||
302,
|
||||
@@ -308,14 +369,18 @@ export class OIDCController extends PuterController {
|
||||
(stateDecoded.referrer as string) ?? null,
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
`OIDC signup user resolution error: ${resolved.error}`,
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'unauthorized',
|
||||
resolved.code ?? 'unauthorized',
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -352,7 +417,7 @@ export class OIDCController extends PuterController {
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const result = await this.#processCallback(req, 'revalidate');
|
||||
const result = await this.#processCallback(req, res, 'revalidate');
|
||||
if ('error' in result) {
|
||||
res.status(400).send(result.error);
|
||||
return;
|
||||
@@ -451,7 +516,7 @@ if (window.opener) {
|
||||
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
|
||||
referrer?: string | null,
|
||||
): Promise<
|
||||
| { error: string }
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
| {
|
||||
user: import('../../stores/user/UserStore.js').UserRow;
|
||||
origin: 'linked-sub' | 'linked-email' | 'created';
|
||||
@@ -497,13 +562,18 @@ if (window.opener) {
|
||||
referrer,
|
||||
);
|
||||
if (!outcome.success || !outcome.user) {
|
||||
return { error: outcome.error ?? 'Account creation failed.' };
|
||||
return {
|
||||
error: outcome.error ?? 'Account creation failed.',
|
||||
code: outcome.code,
|
||||
requestCode: outcome.requestCode,
|
||||
};
|
||||
}
|
||||
return { user: outcome.user, origin: 'created' };
|
||||
}
|
||||
|
||||
async #processCallback(
|
||||
req: Request,
|
||||
res: Response,
|
||||
flow: string,
|
||||
): Promise<
|
||||
| { error: string }
|
||||
@@ -527,6 +597,28 @@ if (window.opener) {
|
||||
if (!stateDecoded || !stateDecoded.provider)
|
||||
return { error: 'Invalid or expired state.' };
|
||||
|
||||
// Enforce the browser binding set at /start. Every state minted by
|
||||
// the current /start carries a nonce, so this covers all live flows.
|
||||
// States signed before this shipped have no nonce and pass through
|
||||
// until they expire (STATE_EXPIRY, 10 min) so in-flight logins don't
|
||||
// break on deploy — a caller can't forge a nonce-less state because
|
||||
// /start always adds one and the state is server-signed.
|
||||
const expectedNonce =
|
||||
typeof stateDecoded.nonce === 'string' ? stateDecoded.nonce : '';
|
||||
if (expectedNonce) {
|
||||
const cookieNonce = req.cookies?.[OIDC_NONCE_COOKIE_NAME];
|
||||
// Single-use: drop the cookie regardless of the outcome.
|
||||
res.clearCookie(OIDC_NONCE_COOKIE_NAME, { path: '/' });
|
||||
if (
|
||||
typeof cookieNonce !== 'string' ||
|
||||
!constantTimeEqual(cookieNonce, expectedNonce)
|
||||
) {
|
||||
return {
|
||||
error: 'This sign-in could not be verified for your browser. Please start again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const provider = String(stateDecoded.provider);
|
||||
const callbackUrl = this.services.oidc.getCallbackUrl(flow);
|
||||
if (!callbackUrl) return { error: 'Invalid flow.' };
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* 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 type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
@@ -24,6 +25,21 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { PEER_COSTS } from './costs.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -170,7 +186,11 @@ export class PeerController extends PuterController {
|
||||
}
|
||||
const expectedSecret = cfg.internal_auth_secret;
|
||||
const header = req.headers['x-puter-internal-auth'];
|
||||
if (!expectedSecret || header !== expectedSecret) {
|
||||
if (
|
||||
!expectedSecret ||
|
||||
typeof header !== 'string' ||
|
||||
!secretsEqual(header, expectedSecret)
|
||||
) {
|
||||
throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { RouteOptions } from '../../core/http/index.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { PuterAIController } from './PuterAIController.js';
|
||||
@@ -188,10 +190,16 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
// The four upstream-proxy routes are user-only: app actors must
|
||||
// call puter-chat-completion directly. The `requireUserActor`
|
||||
// route option wires up the gate that enforces this, so each
|
||||
// proxy route gets the assertion (not just chat/completions).
|
||||
// The four upstream-proxy routes accept exactly one credential
|
||||
// shape — a full-access API token minted from the dashboard:
|
||||
// `requireUserActor` keeps apps out, `allowFullAccessToken` admits
|
||||
// the PAT, and `noUserSession` rejects the account session ("root")
|
||||
// token. Each route also carries `requireVerified` (fresh accounts
|
||||
// must confirm their email before using the AI wire surface) and
|
||||
// the shared per-tier AI rate-limit / concurrency policy — these
|
||||
// routes bypass the `/drivers/call` dispatch (where the
|
||||
// driver-declared limits are enforced), so without the route gates
|
||||
// they'd be unthrottled.
|
||||
const userOnlyPaths = [
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/openai/v1/completions',
|
||||
@@ -203,6 +211,19 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
expect(route?.opts).toEqual({
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
noUserSession: true,
|
||||
requireVerified: true,
|
||||
rateLimit: {
|
||||
...AI_RATE_LIMIT.default,
|
||||
scope: 'driver:puter-chat-completion:complete',
|
||||
key: expect.any(Function),
|
||||
},
|
||||
concurrent: {
|
||||
...AI_CONCURRENT.default,
|
||||
scope: 'driver:puter-chat-completion:complete',
|
||||
key: expect.any(Function),
|
||||
},
|
||||
});
|
||||
}
|
||||
const modelsRoute = calls.find(
|
||||
@@ -213,6 +234,46 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
requireAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keys the proxy-route AI limits by user uuid so they share the /drivers/call buckets', () => {
|
||||
const calls: Array<{ path: string; opts: RouteOptions }> = [];
|
||||
const router = {
|
||||
post: vi.fn((path: string, opts: RouteOptions) => {
|
||||
calls.push({ path, opts });
|
||||
return router;
|
||||
}),
|
||||
get: vi.fn(() => router),
|
||||
};
|
||||
|
||||
controller.registerRoutes(router as never);
|
||||
|
||||
const route = calls.find(
|
||||
(c) => c.path === '/puterai/openai/v1/chat/completions',
|
||||
);
|
||||
const rateLimit = route?.opts.rateLimit as {
|
||||
key: (req: Request) => string;
|
||||
scope: string;
|
||||
};
|
||||
const concurrent = route?.opts.concurrent as {
|
||||
key: (req: Request) => string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
// The dispatch buckets requests as `driver:<iface>:<method>:<uid>`
|
||||
// where uid is the actor's user uuid; scope + key must compose to the
|
||||
// identical string or wire traffic mints a second per-user budget.
|
||||
const req = makeReq({ actor: makeUserActor() });
|
||||
expect(rateLimit.key(req)).toBe('u-7');
|
||||
expect(concurrent.key(req)).toBe('u-7');
|
||||
expect(rateLimit.scope).toBe('driver:puter-chat-completion:complete');
|
||||
expect(concurrent.scope).toBe('driver:puter-chat-completion:complete');
|
||||
|
||||
// No-actor fallback still yields a usable (fingerprint) key rather
|
||||
// than throwing or bucketing everyone together under undefined.
|
||||
const anonKey = rateLimit.key(makeReq({}));
|
||||
expect(typeof anonKey).toBe('string');
|
||||
expect(anonKey.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /openai/v1/chat/completions ─────────────────────────────────────
|
||||
@@ -1504,6 +1565,212 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Inline compaction ───────────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController inline compaction', () => {
|
||||
// Extract the single `event: compaction\ndata: {...}` SSE frame.
|
||||
const compactionFrame = (out: string): string | null => {
|
||||
const m = out.match(/event: compaction\ndata: [^\n]*\n\n/);
|
||||
return m ? m[0] : null;
|
||||
};
|
||||
|
||||
it('forwards the compaction opt-in to the driver (/responses)', async () => {
|
||||
const spy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { model: 'gpt-test', input: 'hi', compaction: true },
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
makeRes().res,
|
||||
);
|
||||
expect(spy.mock.calls[0]![0].compaction).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips a compaction `input` item into a messages compaction item', async () => {
|
||||
const spy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ack' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
input: [
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
},
|
||||
],
|
||||
},
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
makeRes().res,
|
||||
);
|
||||
expect(spy.mock.calls[0]![0].messages).toContainEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a canonical compaction SSE event and output item (/responses stream)', async () => {
|
||||
stubChatComplete({
|
||||
dataType: 'stream',
|
||||
content_type: 'application/x-ndjson',
|
||||
stream: ndjsonStreamFrom([
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'compaction', id: 'cmpct_9', encrypted_content: 'ENC9' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 1 } },
|
||||
]),
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
input: 'hi',
|
||||
stream: true,
|
||||
compaction: true,
|
||||
},
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
const out = captured.written.join('');
|
||||
expect(compactionFrame(out)).toBe(
|
||||
'event: compaction\ndata: {"type":"compaction","id":"cmpct_9","encrypted_content":"ENC9"}\n\n',
|
||||
);
|
||||
// Native shape also lands in the final response.completed output[].
|
||||
const completed = out
|
||||
.split('event: response.completed\n')[1]
|
||||
?.split('\n\n')[0];
|
||||
expect(completed).toContain('"type":"compaction"');
|
||||
expect(completed).toContain('"encrypted_content":"ENC9"');
|
||||
});
|
||||
|
||||
it('emits the compaction item in non-streaming /responses output', async () => {
|
||||
stubChatComplete({
|
||||
message: { role: 'assistant', content: 'done' },
|
||||
finish_reason: 'stop',
|
||||
compaction: { id: 'cmpct_n', encrypted_content: 'ENCN' },
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { model: 'gpt-test', input: 'hi', compaction: true },
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const body = captured.body as { output: Array<Record<string, unknown>> };
|
||||
expect(body.output).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'compaction',
|
||||
encrypted_content: 'ENCN',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an identical canonical compaction SSE event on the Anthropic surface', async () => {
|
||||
// /responses frame
|
||||
stubChatComplete({
|
||||
dataType: 'stream',
|
||||
content_type: 'application/x-ndjson',
|
||||
stream: ndjsonStreamFrom([
|
||||
{ type: 'compaction', id: 'cmpct_x', encrypted_content: 'ENCX' },
|
||||
]),
|
||||
});
|
||||
const r1 = makeRes();
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { model: 'gpt-test', input: 'hi', stream: true },
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
r1.res,
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
// /anthropic/v1/messages frame
|
||||
stubChatComplete({
|
||||
dataType: 'stream',
|
||||
content_type: 'application/x-ndjson',
|
||||
stream: ndjsonStreamFrom([
|
||||
{ type: 'compaction', id: 'cmpct_x', encrypted_content: 'ENCX' },
|
||||
]),
|
||||
});
|
||||
const r2 = makeRes();
|
||||
await controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'claude-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
},
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
r2.res,
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
const a = compactionFrame(r1.captured.written.join(''));
|
||||
const b = compactionFrame(r2.captured.written.join(''));
|
||||
expect(a).not.toBeNull();
|
||||
expect(a).toBe(b); // byte-identical streaming shape across providers
|
||||
});
|
||||
|
||||
it('renders a native compaction content block in non-streaming /messages', async () => {
|
||||
stubChatComplete({
|
||||
message: { role: 'assistant', content: 'done' },
|
||||
finish_reason: 'stop',
|
||||
compaction: { id: 'cmpct_m', encrypted_content: 'ENCM' },
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'claude-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const body = captured.body as { content: Array<Record<string, unknown>> };
|
||||
expect(body.content).toContainEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_m',
|
||||
encrypted_content: 'ENCM',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit compaction frames for a normal stream (regression)', async () => {
|
||||
stubChatComplete({
|
||||
dataType: 'stream',
|
||||
content_type: 'application/x-ndjson',
|
||||
stream: ndjsonStreamFrom([
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 1 } },
|
||||
]),
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { model: 'gpt-test', input: 'hi', stream: true },
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(captured.written.join('')).not.toContain('event: compaction');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model details listing ───────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController model details', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import type {
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
ICompleteArguments,
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const GEMINI_DOWNLOAD_BASE =
|
||||
@@ -44,14 +46,49 @@ const GEMINI_DOWNLOAD_BASE =
|
||||
* ChatCompletionDriver, and translate the result (or NDJSON stream) back
|
||||
* into the vendor's response / SSE shape.
|
||||
*
|
||||
* All routes live on `subdomain: 'api'` and reject app-under-user actors —
|
||||
* only user actors may proxy.
|
||||
* All routes live on `subdomain: 'api'` and require a full-access API
|
||||
* token minted from the dashboard (user-scoped worker tokens also pass —
|
||||
* workers are never treated as root tokens). Apps, scoped tokens, and
|
||||
* account session ("root") tokens are rejected.
|
||||
*/
|
||||
export class PuterAIController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
/**
|
||||
* The wire routes call the chat driver directly instead of going
|
||||
* through the `/drivers/call` dispatch, so the shared per-tier AI
|
||||
* rate-limit / concurrency policy must be declared as route gates
|
||||
* here. `scope` + `key` reproduce the dispatch's bucket key
|
||||
* (`driver:<iface>:<method>:<uid>`) exactly, so wire traffic and
|
||||
* `/drivers/call` traffic draw from one per-user budget rather than
|
||||
* each surface minting its own.
|
||||
*/
|
||||
const aiPolicyScope = 'driver:puter-chat-completion:complete';
|
||||
const aiPolicyKey = (req: Request): string =>
|
||||
req.actor?.user?.uuid || computeNetworkFingerprint(req);
|
||||
const apiAuthOpts = {
|
||||
subdomain: 'api',
|
||||
// The wire routes want a delegated credential — a full-access
|
||||
// API token minted from the dashboard (or a user-scoped worker
|
||||
// token, which is never treated as a root token).
|
||||
// `requireUserActor` keeps apps out, `allowFullAccessToken`
|
||||
// admits the PAT, and `noUserSession` rejects the account's
|
||||
// session ("root") token — a copied session credential
|
||||
// shouldn't double as an AI API key. `requireVerified` keeps
|
||||
// fresh/unconfirmed accounts out, same as FS writes.
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
noUserSession: true,
|
||||
requireVerified: true,
|
||||
rateLimit: {
|
||||
...AI_RATE_LIMIT.default!,
|
||||
scope: aiPolicyScope,
|
||||
key: aiPolicyKey,
|
||||
},
|
||||
concurrent: {
|
||||
...AI_CONCURRENT.default!,
|
||||
scope: aiPolicyScope,
|
||||
key: aiPolicyKey,
|
||||
},
|
||||
} as RouteOptions;
|
||||
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
|
||||
|
||||
@@ -581,6 +618,15 @@ export class PuterAIController extends PuterController {
|
||||
? { metadata: body.metadata as Record<string, string> }
|
||||
: {}),
|
||||
...(body.conversation ? { conversation: body.conversation } : {}),
|
||||
...(body.context_management !== undefined
|
||||
? { context_management: body.context_management }
|
||||
: {}),
|
||||
...(body.compaction !== undefined
|
||||
? {
|
||||
compaction:
|
||||
body.compaction as ICompleteArguments['compaction'],
|
||||
}
|
||||
: {}),
|
||||
...(body.previous_response_id
|
||||
? { previous_response_id: String(body.previous_response_id) }
|
||||
: {}),
|
||||
@@ -744,6 +790,30 @@ export class PuterAIController extends PuterController {
|
||||
output_index: outputIndex,
|
||||
item,
|
||||
});
|
||||
} else if (ev.type === 'compaction') {
|
||||
// Native shape in the final `output[]`, plus the
|
||||
// canonical SSE event shared with the Anthropic surface.
|
||||
const item = {
|
||||
type: 'compaction',
|
||||
...(ev.id !== undefined ? { id: ev.id } : {}),
|
||||
encrypted_content: ev.encrypted_content,
|
||||
};
|
||||
const outputIndex = output.length;
|
||||
output.push(item);
|
||||
sendEvent({
|
||||
type: 'response.output_item.added',
|
||||
output_index: outputIndex,
|
||||
item,
|
||||
});
|
||||
sendEvent({
|
||||
type: 'response.output_item.done',
|
||||
output_index: outputIndex,
|
||||
item,
|
||||
});
|
||||
writeCompactionEvent(res, {
|
||||
id: ev.id,
|
||||
encrypted_content: ev.encrypted_content,
|
||||
});
|
||||
} else if (ev.type === 'usage') {
|
||||
usage = buildResponsesUsage(
|
||||
ev.usage as Record<string, unknown>,
|
||||
@@ -863,6 +933,15 @@ export class PuterAIController extends PuterController {
|
||||
...(body.max_tokens !== undefined
|
||||
? { max_tokens: Number(body.max_tokens) }
|
||||
: {}),
|
||||
...(body.context_management !== undefined
|
||||
? { context_management: body.context_management }
|
||||
: {}),
|
||||
...(body.compaction !== undefined
|
||||
? {
|
||||
compaction:
|
||||
body.compaction as ICompleteArguments['compaction'],
|
||||
}
|
||||
: {}),
|
||||
...(body.provider
|
||||
? { provider: toStringOrEmpty(body.provider) }
|
||||
: { provider: DEFAULTS.anthropic }),
|
||||
@@ -962,6 +1041,14 @@ export class PuterAIController extends PuterController {
|
||||
},
|
||||
});
|
||||
closeBlock();
|
||||
} else if (ev.type === 'compaction') {
|
||||
// Close any open content block, then emit the canonical
|
||||
// compaction SSE event — byte-identical to /responses.
|
||||
closeBlock();
|
||||
writeCompactionEvent(res, {
|
||||
id: ev.id,
|
||||
encrypted_content: ev.encrypted_content,
|
||||
});
|
||||
} else if (ev.type === 'usage') {
|
||||
usage = ev.usage as Record<string, unknown>;
|
||||
}
|
||||
@@ -1020,6 +1107,18 @@ export class PuterAIController extends PuterController {
|
||||
if (textContent)
|
||||
contentBlocks.push({ type: 'text', text: textContent });
|
||||
contentBlocks.push(...toolUseBlocks);
|
||||
// Native Anthropic-shaped compaction block (non-streaming bodies stay
|
||||
// provider-native, unlike the unified streaming event).
|
||||
const compaction = (
|
||||
messageResult as { compaction?: Record<string, unknown> }
|
||||
).compaction;
|
||||
if (compaction) {
|
||||
contentBlocks.push({
|
||||
type: 'compaction',
|
||||
...(compaction.id !== undefined ? { id: compaction.id } : {}),
|
||||
encrypted_content: compaction.encrypted_content,
|
||||
});
|
||||
}
|
||||
if (contentBlocks.length === 0)
|
||||
contentBlocks.push({ type: 'text', text: '' });
|
||||
|
||||
@@ -1076,6 +1175,25 @@ const setSseHeaders = (res: Response): void => {
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline-compaction is emitted in a single canonical SSE shape that is
|
||||
* byte-identical across the `/responses` and `/anthropic/v1/messages` streaming
|
||||
* surfaces, so a streaming client parses compaction the same way regardless of
|
||||
* which upstream served the request. (Non-streaming bodies stay provider-native.)
|
||||
*/
|
||||
const writeCompactionEvent = (
|
||||
res: Response,
|
||||
compaction: { id?: unknown; encrypted_content?: unknown },
|
||||
): void => {
|
||||
const payload = {
|
||||
type: 'compaction',
|
||||
...(compaction.id !== undefined ? { id: compaction.id } : {}),
|
||||
encrypted_content: compaction.encrypted_content,
|
||||
};
|
||||
res.write(`event: compaction\n`);
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
/**
|
||||
* The chat driver returns either a stream-result envelope or a plain
|
||||
* message result. Proxy routes invoked with `stream: true` expect the
|
||||
@@ -1319,6 +1437,17 @@ const responseInputToMessages = (input: unknown): unknown[] => {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const it = item as Record<string, unknown>;
|
||||
|
||||
if (it.type === 'compaction') {
|
||||
// Round-tripped compaction artifact: preserve it as a bare item so
|
||||
// `normalize_single_message` wraps it into an internal compaction
|
||||
// content block (providers map it back to their native input shape).
|
||||
messages.push({
|
||||
type: 'compaction',
|
||||
...(it.id !== undefined ? { id: it.id } : {}),
|
||||
encrypted_content: it.encrypted_content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (it.type === 'function_call_output') {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
@@ -1410,6 +1539,16 @@ const responseOutputFromResult = (
|
||||
});
|
||||
}
|
||||
|
||||
const compaction = (result as { compaction?: Record<string, unknown> })
|
||||
.compaction;
|
||||
if (compaction) {
|
||||
output.push({
|
||||
id: (compaction.id as string | undefined) || generateId('cmpct'),
|
||||
type: 'compaction',
|
||||
encrypted_content: compaction.encrypted_content,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
|
||||
@@ -37,17 +37,33 @@ export class SystemController extends PuterController {
|
||||
) {
|
||||
// -- Healthcheck ---------------------------------------------
|
||||
// Delegates to ServerHealthService for the real check-based
|
||||
// status. Returns `{ ok: true }` when all registered checks pass,
|
||||
// or `{ ok: false, failed: [...] }` + 503 when any fail or the
|
||||
// status. Returns `{ ok: true }` + 200 when all registered checks
|
||||
// pass, or `{ ok: false, failed: [...] }` + 503 when any fail or the
|
||||
// server is draining.
|
||||
router.get('/healthcheck', { subdomain: '*' }, async (_req, res) => {
|
||||
//
|
||||
// `?ignore=a,b` disregards the named checks for this request only.
|
||||
// `?marked-degraded=a,b` demotes the named checks to a non-fatal
|
||||
// `degraded` list: `ok` stays true but the response is 207 so the
|
||||
// caller can tell the node is running in a degraded state.
|
||||
const parseNames = (value) =>
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
router.get('/healthcheck', { subdomain: '*' }, async (req, res) => {
|
||||
const health = this.services.health;
|
||||
if (!health || typeof health.getStatus !== 'function') {
|
||||
// Fallback for boot ordering / missing service.
|
||||
return res.send('ok');
|
||||
}
|
||||
const status = await health.getStatus();
|
||||
const status = await health.getStatus({
|
||||
ignore: parseNames(req.query.ignore),
|
||||
degrade: parseNames(req.query['marked-degraded']),
|
||||
});
|
||||
if (!status.ok) return res.status(503).json(status);
|
||||
if (status.degraded?.length) return res.status(207).json(status);
|
||||
return res.json(status);
|
||||
});
|
||||
|
||||
@@ -77,6 +93,7 @@ export class SystemController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: {
|
||||
scope: 'contact-us',
|
||||
limit: 10,
|
||||
@@ -136,31 +153,31 @@ export class SystemController extends PuterController {
|
||||
name: 'Puter',
|
||||
version: this.config.version ?? null,
|
||||
environment: this.config.env ?? 'prod',
|
||||
disable_user_signup: Boolean(this.config.disable_user_signup),
|
||||
});
|
||||
});
|
||||
|
||||
// -- GET /lsmod ----------------------------------------------
|
||||
// Enumerates driver interfaces and their implementors.
|
||||
// -- GET|POST /lsmod -----------------------------------------
|
||||
// Enumerates driver interfaces and their implementors. POST is
|
||||
// also routed because puter.js `drivers.list()` sends POST.
|
||||
|
||||
router.get(
|
||||
'/lsmod',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
(_req, res) => {
|
||||
const interfaces = {};
|
||||
for (const [key, driver] of Object.entries(this.drivers)) {
|
||||
const ifaceName = driver?.driverInterface;
|
||||
if (!ifaceName) continue;
|
||||
const driverName = driver.driverName ?? key;
|
||||
if (!interfaces[ifaceName]) {
|
||||
interfaces[ifaceName] = { implementors: {} };
|
||||
}
|
||||
interfaces[ifaceName].implementors[driverName] = {
|
||||
isDefault: Boolean(driver.isDefault),
|
||||
};
|
||||
const lsmod = (_req, res) => {
|
||||
const interfaces = {};
|
||||
for (const [key, driver] of Object.entries(this.drivers)) {
|
||||
const ifaceName = driver?.driverInterface;
|
||||
if (!ifaceName) continue;
|
||||
const driverName = driver.driverName ?? key;
|
||||
if (!interfaces[ifaceName]) {
|
||||
interfaces[ifaceName] = { implementors: {} };
|
||||
}
|
||||
res.json({ interfaces });
|
||||
},
|
||||
);
|
||||
interfaces[ifaceName].implementors[driverName] = {
|
||||
isDefault: Boolean(driver.isDefault),
|
||||
};
|
||||
}
|
||||
res.json({ interfaces });
|
||||
};
|
||||
router.get('/lsmod', { subdomain: 'api', requireAuth: true }, lsmod);
|
||||
router.post('/lsmod', { subdomain: 'api', requireAuth: true }, lsmod);
|
||||
}
|
||||
|
||||
onServerStart() {}
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { kv } from '../../util/kvSingleton.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -79,10 +80,11 @@ interface CapturedResponse {
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
query?: Record<string, unknown>;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: {},
|
||||
query: init.query ?? {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
@@ -139,6 +141,191 @@ describe('SystemController GET /healthcheck', () => {
|
||||
expect(captured.body).toMatchObject({ ok: true });
|
||||
expect(captured.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('parses ?ignore and ?marked-degraded into trimmed name lists', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockResolvedValue({ ok: true });
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({
|
||||
query: {
|
||||
ignore: 'database-liveness, thumbnailer',
|
||||
'marked-degraded': ' socket-initialized ',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
ignore: ['database-liveness', 'thumbnailer'],
|
||||
degrade: ['socket-initialized'],
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ok:true + 200 when the only failures are ignored', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockImplementation(async ({ ignore = [] } = {}) => {
|
||||
const failed = ['database-liveness'].filter(
|
||||
(name) => !ignore.includes(name),
|
||||
);
|
||||
return failed.length === 0
|
||||
? { ok: true }
|
||||
: { ok: false, failed };
|
||||
});
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { ignore: 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ ok: true });
|
||||
expect(captured.statusCode).toBe(200);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ok:true + 207 when the only failures are marked degraded', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockResolvedValue({ ok: true, degraded: ['database-liveness'] });
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { 'marked-degraded': 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
ok: true,
|
||||
degraded: ['database-liveness'],
|
||||
});
|
||||
expect(captured.statusCode).toBe(207);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still 503s when a non-ignored failure remains', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockImplementation(async ({ ignore = [] } = {}) => {
|
||||
const failed = ['database-liveness', 'socket-initialized'].filter(
|
||||
(name) => !ignore.includes(name),
|
||||
);
|
||||
return failed.length === 0
|
||||
? { ok: true }
|
||||
: { ok: false, failed };
|
||||
});
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { ignore: 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(503);
|
||||
expect(captured.body).toEqual({
|
||||
ok: false,
|
||||
failed: ['socket-initialized'],
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── ServerHealthService.getStatus ignore / degrade filtering ────────
|
||||
//
|
||||
// Exercises the real service by seeding the in-process status cache
|
||||
// (the kv.js singleton) it reads from, so the actual per-request
|
||||
// classification runs — not a stubbed getStatus.
|
||||
|
||||
describe('ServerHealthService.getStatus ignore/degrade filtering', () => {
|
||||
const STATUS_CACHE_KEY = 'server-health:status';
|
||||
|
||||
const seedStatus = (status: unknown) => {
|
||||
kv.set(STATUS_CACHE_KEY, status, { EX: 5 });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
kv.del(STATUS_CACHE_KEY);
|
||||
});
|
||||
|
||||
it('collapses to ok:true when every failure is ignored', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness', 'thumbnailer'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('keeps the non-ignored failures', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: false, failed: ['thumbnailer'] });
|
||||
});
|
||||
|
||||
it('is a no-op for a healthy status', async () => {
|
||||
seedStatus({ ok: true });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('ignores unknown names without affecting real failures', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['not-a-check'],
|
||||
});
|
||||
expect(status).toEqual({ ok: false, failed: ['database-liveness'] });
|
||||
});
|
||||
|
||||
it('demotes marked failures to degraded and stays ok:true', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true, degraded: ['database-liveness'] });
|
||||
});
|
||||
|
||||
it('reports degraded alongside remaining hard failures (ok:false)', async () => {
|
||||
seedStatus({
|
||||
ok: false,
|
||||
failed: ['database-liveness', 'socket-initialized'],
|
||||
});
|
||||
const status = await server.services.health.getStatus({
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({
|
||||
ok: false,
|
||||
failed: ['socket-initialized'],
|
||||
degraded: ['database-liveness'],
|
||||
});
|
||||
});
|
||||
|
||||
it('lets ignore take precedence over degrade for the same name', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /version ────────────────────────────────────────────────────────
|
||||
@@ -233,6 +420,7 @@ describe('SystemController GET /whoarewe', () => {
|
||||
expect(captured.body).toMatchObject({
|
||||
name: 'Puter',
|
||||
environment: 'dev',
|
||||
disable_user_signup: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Request, Response } from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { hash as bcryptHash } from 'bcrypt';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -228,6 +229,169 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending-verification gate', () => {
|
||||
// WebDAV must enforce the same gate every other authenticated route
|
||||
// gets from requireVerifiedAccount — it dispatches off a single use()
|
||||
// with no route options, so the middleware is never wired in and it
|
||||
// has to call assertVerifiedAccount itself. Without it, an account
|
||||
// still pending email/phone/card verification could read/write its
|
||||
// whole filesystem over the `dav` subdomain.
|
||||
const gatedActor = (flags: Record<string, unknown>) => ({
|
||||
user: {
|
||||
id: 1,
|
||||
uuid: 'gated-uuid',
|
||||
username: 'gated',
|
||||
...flags,
|
||||
},
|
||||
});
|
||||
|
||||
it('rejects a session actor pending phone verification with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
actor: gatedActor({ requires_phone_verification: true }),
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a session actor pending card verification with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'GET',
|
||||
actor: gatedActor({ requires_card_verification: true }),
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a session actor with an unconfirmed email with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
actor: gatedActor({
|
||||
requires_email_confirmation: true,
|
||||
email_confirmed: false,
|
||||
}),
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('enforces the gate on the Basic-auth path (flags carried onto the built actor)', async () => {
|
||||
const username = `webdav-gated-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: await bcryptHash('correct-horse', 4),
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await server.stores.user.update(created.id, {
|
||||
requires_phone_verification: 1,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
authorization: basicAuth(username, 'correct-horse'),
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('lets a fully-verified session actor through the gate', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'OPTIONS',
|
||||
actor: gatedActor({
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
requires_email_confirmation: false,
|
||||
}),
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suspension gate', () => {
|
||||
// WebDAV must also enforce the suspension gate every other authenticated
|
||||
// route gets from requireAuthGate. Same single-use() dispatch means that
|
||||
// middleware is never wired in, so #dispatch calls assertNotSuspended
|
||||
// itself. Without it, a suspended account could read/write/delete its
|
||||
// whole filesystem over the `dav` subdomain.
|
||||
it('rejects a suspended session actor with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
actor: {
|
||||
user: {
|
||||
id: 1,
|
||||
uuid: 'suspended-uuid',
|
||||
username: 'suspended',
|
||||
suspended: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a suspended user on the Basic-auth path with 403', async () => {
|
||||
const username = `webdav-suspended-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: await bcryptHash('correct-horse', 4),
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await server.stores.user.update(created.id, {
|
||||
suspended: 1,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
authorization: basicAuth(username, 'correct-horse'),
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported methods', () => {
|
||||
it('returns 405 for unknown HTTP methods', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -23,6 +23,10 @@ import { posix as pathPosix } from 'node:path';
|
||||
import { EventMap } from '../../clients/event/types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertVerifiedAccount,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
@@ -89,6 +93,17 @@ export class WebDAVController extends PuterController {
|
||||
const actor = await this.#resolveActor(req, res);
|
||||
if (!actor) return; // 401 already sent
|
||||
|
||||
// Apply the same suspension + pending-verification gates every other
|
||||
// authenticated route gets from `requireAuthGate` / `requireVerifiedAccount`.
|
||||
// WebDAV dispatches every method off a single `router.use` with no route
|
||||
// options, so that middleware is never inserted into its chain — without
|
||||
// these calls a suspended account (or one still pending email / phone /
|
||||
// card verification) could read, write, and delete its entire filesystem
|
||||
// over the `dav` subdomain, bypassing the gates. Both throw a 403
|
||||
// HttpError, surfaced by the catch in registerRoutes.
|
||||
assertNotSuspended(actor.user);
|
||||
assertVerifiedAccount(actor.user);
|
||||
|
||||
// Expand `~`/`~/...` against the authenticated actor's username.
|
||||
// WebDAV doesn't standardize `~`, but some clients do — and the
|
||||
// pre-existing behaviour silently expanded it via the FS store.
|
||||
@@ -233,6 +248,15 @@ export class WebDAVController extends PuterController {
|
||||
email_confirmed: user.email_confirmed ?? false,
|
||||
requires_email_confirmation:
|
||||
user.requires_email_confirmation ?? false,
|
||||
// Carry the signup-time verification flags so the
|
||||
// assertVerifiedAccount() gate in #dispatch can see them on
|
||||
// the Basic-auth path too (the cookie / `-token` paths get
|
||||
// them from AuthService#actorUserFromRow). Omitting these is
|
||||
// what let phone/card-gated accounts through WebDAV.
|
||||
requires_phone_verification:
|
||||
user.requires_phone_verification ?? false,
|
||||
requires_card_verification:
|
||||
user.requires_card_verification ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ export interface ActorAccessToken {
|
||||
uid: string;
|
||||
issuer: Actor;
|
||||
authorized?: Actor | null;
|
||||
/**
|
||||
* Full-API-access ("personal access token") flag, set from the signed
|
||||
* `full_access` JWT claim. Such a token may exercise everything its issuing
|
||||
* user can do via the API and is admitted past `requireNonAccessTokenGate`
|
||||
* — but is still rejected by `requireUserActor` / web-session gates, so it
|
||||
* can never manage the account. Normal (scoped) access tokens leave this
|
||||
* false and remain blocked from non-`allowAccessToken` routes.
|
||||
*/
|
||||
fullAccess?: boolean;
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
@@ -45,9 +54,11 @@ export interface Actor {
|
||||
* Session reference when authenticated via a session token (user actors)
|
||||
* or an app-under-user token that carries a session. Absent for system,
|
||||
* raw-app, and pure access-token actors. Used for session introspection
|
||||
* and targeted logout.
|
||||
* and targeted logout. `kind` mirrors the session row's kind (e.g.
|
||||
* 'web', 'app', 'worker') so callers can gate on how the credential
|
||||
* was minted without an extra session lookup.
|
||||
*/
|
||||
session?: { uid: string } | null;
|
||||
session?: { uid: string; kind?: string | null } | null;
|
||||
}
|
||||
|
||||
/** UUID of the baked-in system user (see 0025 seed migration). */
|
||||
|
||||
@@ -53,7 +53,8 @@ export type LegacyErrorCodes =
|
||||
| 'password_required'
|
||||
| 'password_mismatch'
|
||||
| 'field_not_allowed_for_create'
|
||||
| 'account_is_not_verified';
|
||||
| 'account_is_not_verified'
|
||||
| 'app_or_api_token_required';
|
||||
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
|
||||
@@ -42,6 +42,13 @@ declare global {
|
||||
|
||||
tokenAuthFailed?: boolean;
|
||||
|
||||
/**
|
||||
* Set when a token authenticated but its app is on the origin
|
||||
* blocklist. The auth probe leaves `actor` unset; gates translate
|
||||
* this into a 403 `app_blocked`.
|
||||
*/
|
||||
appBlocked?: { reason?: string };
|
||||
|
||||
requiresReauth?: {
|
||||
reason: 'token_v1' | 'session_revoked' | 'session_expired';
|
||||
auth_id?: string;
|
||||
@@ -66,6 +73,21 @@ declare global {
|
||||
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
|
||||
is_custom_domain?: boolean;
|
||||
|
||||
/**
|
||||
* Coarse server-derived request fingerprint (IP + UA + accept
|
||||
* headers), populated by the global fingerprint middleware. Always
|
||||
* present; the same value the rate limiter keys on.
|
||||
*/
|
||||
networkFingerprint?: string;
|
||||
|
||||
/**
|
||||
* Client-supplied device fingerprint (ThumbmarkJS hash) from the
|
||||
* body or `x-puter-device-fingerprint` header, populated by the
|
||||
* global fingerprint middleware. Present only when the client sent a
|
||||
* well-shaped value; spoofable but stable per device across IPs.
|
||||
*/
|
||||
deviceFingerprint?: string;
|
||||
|
||||
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
|
||||
cookies?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ export {
|
||||
subdomainGate,
|
||||
} from './middleware/gates';
|
||||
export { createNotFoundHandler } from './middleware/notFoundHandler';
|
||||
export {
|
||||
createStepUpGate,
|
||||
signStepUpToken,
|
||||
STEP_UP_COOKIE_NAME,
|
||||
STEP_UP_PURPOSE,
|
||||
STEP_UP_SCOPE,
|
||||
STEP_UP_TTL_SECONDS,
|
||||
stepUpCookieOptions,
|
||||
verifyStepUpSession,
|
||||
} from './middleware/stepUpSession';
|
||||
export { PuterRouter } from './PuterRouter';
|
||||
export {
|
||||
PREFIX_METADATA_KEY,
|
||||
|
||||
@@ -73,6 +73,19 @@ export const antiCsrf = {
|
||||
export function requireAntiCsrf() {
|
||||
return async (req, _res, next) => {
|
||||
try {
|
||||
// CSRF only applies to ambient credentials the browser attaches
|
||||
// automatically (the session cookie). A full-access personal access
|
||||
// token travels in the Authorization header — it can't be forged
|
||||
// cross-origin — so it needs no anti-CSRF token, which is what makes
|
||||
// routes like `/fs/down` reachable by a PAT. We deliberately scope
|
||||
// this to full-access tokens (the only access tokens routed onto
|
||||
// antiCsrf-protected endpoints): anything else — cookie-authed user
|
||||
// actors, app actors, or a scoped token that somehow reaches here —
|
||||
// still goes through the check below and fails closed.
|
||||
if (req.actor?.accessToken?.fullAccess) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const sessionId = req.actor?.user?.uuid;
|
||||
if (!sessionId) {
|
||||
return next(
|
||||
|
||||
@@ -118,6 +118,51 @@ describe('requireAntiCsrf middleware', () => {
|
||||
expect(arg).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exempts a FULL-ACCESS access token (no anti_csrf token needed)', async () => {
|
||||
// A full-access PAT is header-credentialed (bearer), so it can't be
|
||||
// CSRF-forged — the middleware lets it through without a token. This is
|
||||
// what makes antiCsrf routes like /fs/down reachable by a PAT.
|
||||
const arg = await runMiddleware({
|
||||
actor: {
|
||||
user: { uuid: 'user-uuid-1' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: { user: { uuid: 'user-uuid-1' } },
|
||||
fullAccess: true,
|
||||
},
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
expect(arg).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT exempt a scoped (non-full-access) access token — fails closed', async () => {
|
||||
// Scoped tokens are never deliberately routed onto antiCsrf endpoints;
|
||||
// if one reaches here it must still present a token (which it can't get)
|
||||
// rather than getting a free pass.
|
||||
const arg = await runMiddleware({
|
||||
actor: {
|
||||
user: { uuid: 'user-uuid-1' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: { user: { uuid: 'user-uuid-1' } },
|
||||
},
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
expect(isHttpError(arg)).toBe(true);
|
||||
expect(arg.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('still enforces the token for cookie-authed user actors (no accessToken)', async () => {
|
||||
const arg = await runMiddleware({
|
||||
actor: { user: { uuid: 'user-uuid-1' } },
|
||||
body: {},
|
||||
});
|
||||
expect(isHttpError(arg)).toBe(true);
|
||||
expect(arg.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 401 unauthorized when no actor is attached', async () => {
|
||||
const arg = await runMiddleware({ body: { anti_csrf: 'whatever' } });
|
||||
expect(isHttpError(arg)).toBe(true);
|
||||
|
||||
@@ -545,30 +545,10 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reauth signal + KV counters ─────────────────────────────────────
|
||||
// ── Reauth signal ───────────────────────────────────────────────────
|
||||
|
||||
describe('createAuthProbe — reauth signal', () => {
|
||||
/** Capture KV increments without a real store. */
|
||||
const makeKvStub = () => {
|
||||
const calls: Array<{
|
||||
key: string;
|
||||
pathAndAmountMap: Record<string, number>;
|
||||
}> = [];
|
||||
return {
|
||||
calls,
|
||||
store: {
|
||||
incr: async (args: {
|
||||
key: string;
|
||||
pathAndAmountMap: Record<string, number>;
|
||||
}) => {
|
||||
calls.push(args);
|
||||
return { res: {} as Record<string, number>, usage: 0 };
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('sets requiresReauth and counts v1 + reauth.token_v1 for a legacy v1 token', async () => {
|
||||
it('sets requiresReauth with a signed token for a legacy v1 token', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'token_v1', auth_id: 'u-legacy' },
|
||||
@@ -576,11 +556,7 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
// The probe must still set requiresReauth; gate emits 401.
|
||||
actor: { user: { uuid: 'u-legacy' } },
|
||||
} as never);
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer v1-tok' } }),
|
||||
@@ -590,109 +566,36 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
auth_id: 'u-legacy',
|
||||
reauth_token: 'reauth-jwt:u-legacy',
|
||||
});
|
||||
// Both increments fire under the same day-bucketed key.
|
||||
expect(kv.calls).toHaveLength(1);
|
||||
expect(kv.calls[0].key).toMatch(/^auth-v2:metrics:\d{4}-\d{2}-\d{2}$/);
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.token_v1': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets requiresReauth and counts reauth.session_revoked', async () => {
|
||||
it('sets requiresReauth for a revoked session', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'session_revoked', auth_id: 'u-1' },
|
||||
});
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(req.requiresReauth?.reason).toBe('session_revoked');
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.session_revoked': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets requiresReauth and counts reauth.session_expired', async () => {
|
||||
it('sets requiresReauth for an expired session', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'session_expired', auth_id: 'u-1' },
|
||||
});
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(req.requiresReauth?.reason).toBe('session_expired');
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.session_expired': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts v2 verify (no reauth) for a healthy token', async () => {
|
||||
it('attaches an actor and no reauth for a healthy v2 token', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(req.actor).toBeTruthy();
|
||||
expect(req.requiresReauth).toBeUndefined();
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({ v2: 1 });
|
||||
});
|
||||
|
||||
it('does not increment counters when no token is presented', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
await runProbe(probe, makeReq({}));
|
||||
// Anonymous-OK routes pay no metrics cost.
|
||||
expect(kv.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('absorbs KV failures — never rejects on the hot path', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
// Failing KV: increments throw. Probe must still succeed.
|
||||
const failingKv = {
|
||||
incr: async () => {
|
||||
throw new Error('kv unavailable');
|
||||
},
|
||||
};
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: failingKv,
|
||||
});
|
||||
const { req, next } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.actor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('works without a kvStore (counters become no-ops)', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
// Production may not always pass a KV — fresh installs without
|
||||
// dynamo wired still need a functioning probe.
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
@@ -702,6 +605,17 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
expect(req.requiresReauth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never rejects on the hot path', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req, next } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.actor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('logs `[auth-v2] reauth reason=<r> auth_id=<id>` per event', async () => {
|
||||
// The log line is the human-facing forensic counterpart to the
|
||||
// KV counter. Asserting the exact shape so ops can `grep
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
AuthService,
|
||||
ReauthReason,
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { SystemKVStore } from '../../../stores/systemKv/SystemKVStore';
|
||||
|
||||
// Ensure the `Request.actor` / `Request.token` augmentation is in scope
|
||||
// wherever this middleware is imported.
|
||||
@@ -32,25 +31,8 @@ interface AuthProbeOptions {
|
||||
authService: AuthService;
|
||||
/** Name of the session cookie to inspect. Falls back to `config.cookie_name`. */
|
||||
cookieName?: string;
|
||||
kvStore?: SystemKVStore;
|
||||
}
|
||||
|
||||
const authV2MetricsKey = (): string => {
|
||||
const now = new Date();
|
||||
const yyyy = now.getUTCFullYear();
|
||||
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getUTCDate()).padStart(2, '0');
|
||||
return `auth-v2:metrics:${yyyy}-${mm}-${dd}`;
|
||||
};
|
||||
|
||||
const bumpCounter = (
|
||||
kv: Pick<SystemKVStore, 'incr'> | undefined,
|
||||
pathAndAmountMap: Record<string, number>,
|
||||
): void => {
|
||||
if (!kv) return;
|
||||
kv.incr({ key: authV2MetricsKey(), pathAndAmountMap }).catch(() => {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on
|
||||
* every request, tries to locate a token in the usual places, and — if one
|
||||
@@ -70,7 +52,7 @@ const bumpCounter = (
|
||||
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
|
||||
*/
|
||||
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
const { authService, cookieName, kvStore } = opts;
|
||||
const { authService, cookieName } = opts;
|
||||
return async (req, _res, next): Promise<void> => {
|
||||
// If something upstream already attached an actor, respect it.
|
||||
if (req.actor) {
|
||||
@@ -94,10 +76,6 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
});
|
||||
|
||||
if (result.reauth) {
|
||||
bumpCounter(kvStore, {
|
||||
v1: 1,
|
||||
[`reauth.${result.reauth.reason}`]: 1,
|
||||
});
|
||||
// Bind a short-lived JWT proving the rejected session
|
||||
// identified this auth_id. The GUI echoes this back on
|
||||
// /login or /signup; the raw auth_id is informational
|
||||
@@ -113,8 +91,13 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
console.info(
|
||||
`[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'}`,
|
||||
);
|
||||
} else if (result.actor) {
|
||||
bumpCounter(kvStore, { v2: 1 });
|
||||
}
|
||||
|
||||
if (result.blocked) {
|
||||
// App is on the origin blocklist: leave `actor` unset so gates
|
||||
// reject. `appBlocked` lets the gate emit a clear 403 instead
|
||||
// of the generic "token failed" 401.
|
||||
req.appBlocked = { reason: result.blocked.reason };
|
||||
}
|
||||
|
||||
if (result.actor) {
|
||||
|
||||
@@ -232,3 +232,50 @@ describe('createErrorHandler — when the response has already started streaming
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Database load-shed translation ──────────────────────────────────
|
||||
|
||||
describe('createErrorHandler — dbBatchFailed load-shed errors', () => {
|
||||
const makeDbBatchError = (reason: string) => {
|
||||
const err = new Error('Database operation failed') as Error & {
|
||||
code: string;
|
||||
reason: string;
|
||||
};
|
||||
err.code = 'dbBatchFailed';
|
||||
err.reason = reason;
|
||||
return err;
|
||||
};
|
||||
|
||||
it('maps to 503 + Retry-After instead of a generic 500', () => {
|
||||
const onUnhandled = vi.fn();
|
||||
const handler = createErrorHandler({ onUnhandled });
|
||||
const { out } = runHandler(handler, makeDbBatchError('breakerOpen'));
|
||||
|
||||
expect(out.statusCode).toBe(503);
|
||||
expect(out.headers['Retry-After']).toBe(5);
|
||||
expect(out.body).toMatchObject({
|
||||
code: 'db_unavailable',
|
||||
error: 'Service temporarily unavailable',
|
||||
});
|
||||
// Translated errors are expected degradation, not unhandled bugs.
|
||||
expect(onUnhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates every load-shed reason the batcher emits', () => {
|
||||
const handler = createErrorHandler({ onUnhandled: () => {} });
|
||||
for (const reason of ['breakerOpen', 'queueOverflow', 'connAcquire']) {
|
||||
const { out } = runHandler(handler, makeDbBatchError(reason));
|
||||
expect(out.statusCode).toBe(503);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves unrelated coded errors on the generic 500 path', () => {
|
||||
const onUnhandled = vi.fn();
|
||||
const handler = createErrorHandler({ onUnhandled });
|
||||
const err = new Error('boom') as Error & { code: string };
|
||||
err.code = 'somethingElse';
|
||||
const { out } = runHandler(handler, err);
|
||||
expect(out.statusCode).toBe(500);
|
||||
expect(onUnhandled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,17 @@ export const createErrorHandler = (
|
||||
err = translated;
|
||||
}
|
||||
|
||||
// Database-batcher load-shed (circuit open, queue overflow, or no
|
||||
// connection available): the persistence layer is temporarily
|
||||
// degraded, not a programming bug. Surface as 503 so clients back
|
||||
// off and retry instead of treating it as a hard failure.
|
||||
if ((err as { code?: string } | null)?.code === 'dbBatchFailed') {
|
||||
res.setHeader('Retry-After', 5);
|
||||
err = new HttpError(503, 'Service temporarily unavailable', {
|
||||
code: 'db_unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
if (isHttpError(err)) {
|
||||
opts.onError?.(err, req);
|
||||
if (err.statusCode === 402 || err.statusCode === 413) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 { describe, expect, it, vi } from 'vitest';
|
||||
import { createFingerprintMiddleware } from './fingerprint';
|
||||
|
||||
const VALID_FP = 'dev-fingerprint-abc123';
|
||||
|
||||
const run = (reqPartial: Partial<Request> & Record<string, unknown>) => {
|
||||
const middleware = createFingerprintMiddleware();
|
||||
const req = {
|
||||
headers: {},
|
||||
...reqPartial,
|
||||
} as unknown as Request;
|
||||
const next = vi.fn();
|
||||
middleware(req, {} as Response, next);
|
||||
return { req, next };
|
||||
};
|
||||
|
||||
describe('createFingerprintMiddleware', () => {
|
||||
it('always sets a well-formed networkFingerprint and calls next', () => {
|
||||
const { req, next } = run({ ip: '203.0.113.1' });
|
||||
|
||||
expect(req.networkFingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('networkFingerprint is stable for identical inputs and varies by UA', () => {
|
||||
const headers = { 'user-agent': 'UA/1.0' };
|
||||
const a = run({ ip: '203.0.113.1', headers }).req.networkFingerprint;
|
||||
const b = run({ ip: '203.0.113.1', headers }).req.networkFingerprint;
|
||||
const c = run({
|
||||
ip: '203.0.113.1',
|
||||
headers: { 'user-agent': 'UA/2.0' },
|
||||
}).req.networkFingerprint;
|
||||
|
||||
expect(a).toBe(b);
|
||||
expect(a).not.toBe(c);
|
||||
});
|
||||
|
||||
it('reads a well-shaped device fingerprint from the body', () => {
|
||||
const { req } = run({ body: { fingerprint: VALID_FP } });
|
||||
expect(req.deviceFingerprint).toBe(VALID_FP);
|
||||
});
|
||||
|
||||
it('falls back to the x-puter-device-fingerprint header', () => {
|
||||
const { req } = run({
|
||||
headers: { 'x-puter-device-fingerprint': VALID_FP },
|
||||
});
|
||||
expect(req.deviceFingerprint).toBe(VALID_FP);
|
||||
});
|
||||
|
||||
it('prefers the body fingerprint over the header', () => {
|
||||
const { req } = run({
|
||||
body: { fingerprint: VALID_FP },
|
||||
headers: { 'x-puter-device-fingerprint': 'other-fingerprint-xyz' },
|
||||
});
|
||||
expect(req.deviceFingerprint).toBe(VALID_FP);
|
||||
});
|
||||
|
||||
it('drops a malformed fingerprint to undefined', () => {
|
||||
expect(run({ body: { fingerprint: 'bad fp!' } }).req.deviceFingerprint)
|
||||
.toBeUndefined();
|
||||
expect(run({ body: { fingerprint: 'short' } }).req.deviceFingerprint)
|
||||
.toBeUndefined();
|
||||
expect(
|
||||
run({ body: { fingerprint: 123 as unknown as string } }).req
|
||||
.deviceFingerprint,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves deviceFingerprint undefined when nothing was supplied', () => {
|
||||
const { req } = run({ ip: '203.0.113.1' });
|
||||
expect(req.deviceFingerprint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 599 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user