mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
237
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465d9b35d0 | ||
|
|
61b713438d | ||
|
|
7ceaf69651 | ||
|
|
c00f693ec6 | ||
|
|
dace77c212 | ||
|
|
8e1233d82a | ||
|
|
722c25e0dc | ||
|
|
9e449e635b | ||
|
|
ba7011b5bb | ||
|
|
25d5843d03 | ||
|
|
4e6d655f4c | ||
|
|
bd1dd6564e | ||
|
|
69a3e918a7 | ||
|
|
aded055212 | ||
|
|
e54750492f | ||
|
|
406fe4dcfc | ||
|
|
2e085c0b75 | ||
|
|
b95fb870a5 | ||
|
|
28be1851f9 | ||
|
|
ff83360272 | ||
|
|
b19fde242f | ||
|
|
c2b38786f7 | ||
|
|
7c6c0f2d39 | ||
|
|
5ac41f90b6 | ||
|
|
c024e007aa | ||
|
|
eeedeb817f | ||
|
|
ca79f01edd | ||
|
|
55ba45e159 | ||
|
|
e8688d5025 | ||
|
|
10f10029c2 | ||
|
|
11a94ed608 | ||
|
|
5c35f8d053 | ||
|
|
c5a4b1f3b5 | ||
|
|
179ad990d6 | ||
|
|
ff8dde6895 | ||
|
|
d587ffa428 | ||
|
|
0fe68dae30 | ||
|
|
eb1ad240f2 | ||
|
|
d40cc499f5 | ||
|
|
06ef9506fa | ||
|
|
5513c23042 | ||
|
|
8aade99cc8 | ||
|
|
6f9719fd65 | ||
|
|
352a2fbabd | ||
|
|
59700519a6 | ||
|
|
8482ff2167 | ||
|
|
b42f2acf06 | ||
|
|
1b6de39a13 | ||
|
|
1aa7caf44c | ||
|
|
40253ae12b | ||
|
|
3b4945f018 | ||
|
|
fe58213bb5 | ||
|
|
6c35780d59 | ||
|
|
415c179770 | ||
|
|
feb0937b6d | ||
|
|
f880544ebe | ||
|
|
25a574b57d | ||
|
|
389fd26bee | ||
|
|
d77ebadfe6 | ||
|
|
7e78a4feef | ||
|
|
7aa4d91ae9 | ||
|
|
5f7ab80721 | ||
|
|
b3dbfdbd7b | ||
|
|
60dfff3b0b | ||
|
|
5a83c4a045 | ||
|
|
fe8acf007c | ||
|
|
8010c9fc94 | ||
|
|
bbf375db14 | ||
|
|
fe70e7c6ba | ||
|
|
f0d7a69dd4 | ||
|
|
5ce131517b | ||
|
|
49b38f3482 | ||
|
|
241fb7be8a | ||
|
|
0167a9d10e | ||
|
|
5a879a4346 | ||
|
|
c13bff0563 | ||
|
|
2f94ee1088 | ||
|
|
3068059058 | ||
|
|
7ee0cbfac9 | ||
|
|
dfa6f4effc | ||
|
|
20c63b6282 | ||
|
|
dd44c0704a | ||
|
|
681b9cd463 | ||
|
|
9fb66137a3 | ||
|
|
057aa116a4 | ||
|
|
1cea1b2a0a | ||
|
|
6c78d2f9e9 | ||
|
|
e8aa12ace5 | ||
|
|
f1964778b4 | ||
|
|
c8f7b1ceda | ||
|
|
0411a92f4e | ||
|
|
5cfd4d1f53 | ||
|
|
e1a36215f6 | ||
|
|
6f13b1f457 | ||
|
|
996d85148e | ||
|
|
09da68424e | ||
|
|
7bccde40a8 | ||
|
|
cf5d2c60a5 | ||
|
|
b7a0276337 | ||
|
|
bbc056cc49 | ||
|
|
e2f6c2ed05 | ||
|
|
745b3a4318 | ||
|
|
2dfd93687f | ||
|
|
91554a137e | ||
|
|
52da7b571c | ||
|
|
9e1ccf7ff3 | ||
|
|
8f94514692 | ||
|
|
2ad49e1040 | ||
|
|
57349a6982 | ||
|
|
ac0de9df44 | ||
|
|
7d8711bf5b | ||
|
|
af13c33678 | ||
|
|
5cf13547f2 | ||
|
|
e181a25f95 | ||
|
|
5e376bc0c8 | ||
|
|
fceaee6372 | ||
|
|
47a6122c37 | ||
|
|
9fabd1d5b3 | ||
|
|
32b28c1994 | ||
|
|
21625de22e | ||
|
|
c79b3edf4f | ||
|
|
d366861321 | ||
|
|
f850155411 | ||
|
|
c8998075ea | ||
|
|
759f1a798d | ||
|
|
b57fef8c9d | ||
|
|
7c0cf6f6a1 | ||
|
|
7f93166560 | ||
|
|
21846192d1 | ||
|
|
b9f0c8601d | ||
|
|
24aac2f6d2 | ||
|
|
1eb4e36cf4 | ||
|
|
3160b99f11 | ||
|
|
589a98b12c | ||
|
|
ec3ad1cb2f | ||
|
|
c7cc64e45c | ||
|
|
0c67499dc2 | ||
|
|
34d74d36bd | ||
|
|
0bdc2197b0 | ||
|
|
7759f7afe9 | ||
|
|
68249028ef | ||
|
|
cbcbdc2599 | ||
|
|
3296c1b0d6 | ||
|
|
a10a9d9b93 | ||
|
|
bbb780eb31 | ||
|
|
ad5f9b6196 | ||
|
|
65b966c264 | ||
|
|
c701b8d7c0 | ||
|
|
4b41b9cd54 | ||
|
|
8a55debdae | ||
|
|
ce5f28e844 | ||
|
|
f3e8539eaf | ||
|
|
b3b02b1ebb | ||
|
|
bdc9aa9e93 | ||
|
|
21c1e4142e | ||
|
|
03e483e372 | ||
|
|
9c90c7afde | ||
|
|
97beffb572 | ||
|
|
03eab894a0 | ||
|
|
53c77c8333 | ||
|
|
acd3df8c1b | ||
|
|
4729d733bb | ||
|
|
26d843778e | ||
|
|
80a9cd85ee | ||
|
|
63370fcfa5 | ||
|
|
631b264c1c | ||
|
|
809c303117 | ||
|
|
afad53d3f4 | ||
|
|
8b8dd19eae | ||
|
|
1fdde02f6a | ||
|
|
1627650abc | ||
|
|
4968a69d45 | ||
|
|
aa47dcd3e5 | ||
|
|
c8424f6775 | ||
|
|
b3c85c86e9 | ||
|
|
d22d419c4d | ||
|
|
0760530d0c | ||
|
|
e18a5c2b54 | ||
|
|
75385501a7 | ||
|
|
5b0205f2bf | ||
|
|
3bce3b056e | ||
|
|
f70240784f | ||
|
|
356e7b2b94 | ||
|
|
f02b4a6b73 | ||
|
|
0768978111 | ||
|
|
66093e78f3 | ||
|
|
ed531e3992 | ||
|
|
53e27f3d41 | ||
|
|
deac121675 | ||
|
|
53e6e3179b | ||
|
|
73624bf1d0 | ||
|
|
1a9e43f5e5 | ||
|
|
242a654166 | ||
|
|
41e4b582d3 | ||
|
|
2a49689fad | ||
|
|
b54b76715a | ||
|
|
d978538c9d | ||
|
|
e83e27f306 | ||
|
|
54f542ace4 | ||
|
|
18b96d529c | ||
|
|
f146da16d1 | ||
|
|
dd56d54753 | ||
|
|
e5fd30ccf0 | ||
|
|
517efda38a | ||
|
|
ea3801fb74 | ||
|
|
0786cc7db2 | ||
|
|
592020c899 | ||
|
|
9ff224e22f | ||
|
|
9ad7b99607 | ||
|
|
28f162ee92 | ||
|
|
fa62b3fb76 | ||
|
|
e950a87f29 | ||
|
|
1cb9dd756a | ||
|
|
be16bca1d9 | ||
|
|
2305d50e36 | ||
|
|
352427687e | ||
|
|
145d54cf69 | ||
|
|
471b900462 | ||
|
|
2b254b8892 | ||
|
|
2a15afc570 | ||
|
|
f9860b036b | ||
|
|
76e1f79b5e | ||
|
|
943a3427c6 | ||
|
|
38469a3615 | ||
|
|
a4d4a5595e | ||
|
|
de69269b10 | ||
|
|
8faff74793 | ||
|
|
e5ca303c50 | ||
|
|
c6c0dd5e29 | ||
|
|
e32bfa839d | ||
|
|
ff815f730d | ||
|
|
84aaf005ec | ||
|
|
f6a1e54642 | ||
|
|
d211a25c6d | ||
|
|
2e1bf86d2b | ||
|
|
4401b4c4dc | ||
|
|
62270ff9aa |
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -184,10 +184,12 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
|
||||
- **Description.** Written for someone who has never heard of Puter. Start with "Learn how to", put the action first, and say what the reader's app gains: "Learn how to add file sharing to your app with Puter.js, so users can share files in their Puter account with other people." No API names, no mechanism, no list of example cases, and no "not just …" contrast.
|
||||
- **Intro.** Explain the situation and why you'd do this, tied to something the reader already knows ("`puter.kv` is your database in Puter.js"). No "Use this when" bullet list.
|
||||
- **Structure.** Get to the first working call as early as possible, then add validation, rate limits, and sign-in after it. Explain a Puter concept the first time it appears, such as what `me.puter` is or that `puter.workers.exec()` opens the Puter sign-in window. Split recipes by what the reader needs, not by technique, and give a family of recipes a shared tag.
|
||||
- **Reading order.** A recipe reads top to bottom. Each code block uses only functions and variables from blocks above it, so the reader never has to search further down the page to understand it. Start with a small version that works, and have each section add one case. When a section changes a function from earlier, show the whole new version of that function there. If the code is built up over several sections, end with a `## Full Code` block that has all of it.
|
||||
- **Link to the reference for full option lists.** Mention only the options the recipe uses, then link to the function page for the rest ("All the sort options are listed on [`puter.fs.readdir()`](/FS/readdir/)."). A copied list of options, rules, or scopes goes stale when the reference changes.
|
||||
- **Limits.** Mention a limit only when it's the reason for a pattern the recipe uses, such as one wildcard subscription instead of one per key, and link to [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) or the function page for the number. Leave out limits that code following the recipe never reaches.
|
||||
- **Code.** Platform-neutral: just the code, with top-level `await`. No `<script>` or `<html>` wrapper, no IIFE.
|
||||
- **Tradeoffs.** Justify a choice with one scenario the reader can picture right away, in about two short paragraphs, or fold each point into the section where it matters. The description states the benefit only.
|
||||
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit goes in the section it applies to; other facts belong on the linked reference page.
|
||||
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit the recipe needs goes in the section it applies to, and other facts belong on the linked reference page.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+26
-8
@@ -17,6 +17,21 @@ Out-of-scope:
|
||||
- Denial of Service (DoS), spam, or volumetric attacks.
|
||||
- Physical security issues.
|
||||
|
||||
## Eligibility
|
||||
|
||||
We pay bounties for issues that threaten user security and privacy, or the security and health of the platform. We appreciate every report, but hardening suggestions and correctness bugs are not eligible, even when we fix them. Please open a GitHub issue for those.
|
||||
|
||||
A report must show how an attacker can directly perform the action against another account or the system. It is not eligible if it:
|
||||
|
||||
- Starts from access the attacker should not already have: a stolen session or token, the victim's password, a root or full-access token, or script execution on `puter.com`.
|
||||
- Needs the victim to do more than ordinary use (opening a link or an app), such as publishing their own secrets, sending a verification link to an address they don't control, or approving a prompt that accurately states what it grants.
|
||||
- Gives the attacker nothing they couldn't already get through a supported API.
|
||||
- Only reproduces in a non-default or development setup (SQLite, a `custom` OIDC provider, an unset outbound proxy, cookies or config planted by hand). Findings in the open-source code must affect `puter.com` or a default self-hosted install.
|
||||
|
||||
**Shared state.** Sharing files, KV entries, or cross-app KV access means the sharer trusts the sharee. Exploits that depend on that trust, such as a sharee misusing access the sharer granted, may be treated as hardening or lower severity.
|
||||
|
||||
Hardening we ship in a public PR does not make similar code paths vulnerabilities. They are judged by these same rules.
|
||||
|
||||
## Known Non-Issues (Please Check Before Submitting)
|
||||
|
||||
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
|
||||
@@ -27,11 +42,12 @@ The following have already been reviewed and determined **not to be vulnerabilit
|
||||
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
|
||||
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
|
||||
- **Re-claiming a site or worker name its owner deleted.** A hosted site or worker name that its owner deletes is treated as abandoned by that owner. If one of their apps still points at it, whoever claims the name next serves that app's `index_url` and receives what the app is launched with, and a new app on the same origin may share the old one's identity. Guarding app identities against being recycled this way is ongoing hardening, not a security boundary, so new ways to re-claim an abandoned name, or to inherit an app's tokens or data through one, are not eligible.
|
||||
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
|
||||
- **Concurrency / TOCTOU overspend of credits or storage quota.** Firing concurrent requests to exceed an AI credit hold or a storage quota by some margin is known and accepted; the per-account concurrency and rate-limit gates are the control we rely on, and the residual overage is bounded and billed. Report these only if you can demonstrate _unbounded_ overspend.
|
||||
- **Metering, credit-hold, and quota inaccuracies.** Credit holds and quota checks are up-front estimates, and actual usage is billed afterwards. Overspend that is bounded and still billed is a correctness bug, including concurrent requests that exceed a hold or quota by some margin; the per-account concurrency and rate-limit gates are the control we rely on. Report these only if you can use a service without it being billed, or overspend without bound.
|
||||
- **Existence-only oracles.** A response that tells "exists but forbidden" apart from "not found" for a path or ID the caller already supplied, without revealing anything else about it, is hardening.
|
||||
- **Session and credential lifecycle around 2FA enrollment.** Enrolling in 2FA is not a credential-revocation event: existing authenticated sessions stay valid, and outstanding password-reset links stay valid. Likewise, account-management actions behind the `/user-protected/*` gate are password-gated by design, not TOTP-gated. A recovery code that stops being accepted fails _closed_ — it denies access rather than granting it, so it is a bug report, not a vulnerability.
|
||||
- **Group permissions** (`/auth/revoke-user-group` and siblings). The group-permission surface is unfinished and not exposed anywhere in the product. Findings there are not reachable by any user.
|
||||
- **Correctness bugs with no security boundary crossed.** For example, an API that reports success while silently leaving data behind. We want these reported — please open a GitHub issue — but they are not bounty-eligible.
|
||||
- **Missing Subresource Integrity (SRI) on external scripts/styles.** Accepted as hardening, not as a vulnerability, absent a demonstrated compromise of the CDN in question.
|
||||
- **General "token in a URL" / token-lifetime / token-storage designs** — signed directory URLs exposing children, a write signature implying read, app tokens outliving a web session, or the session token being held in `localStorage` on `puter.com`. These are current intended behaviors. Shorter-lived tokens with a refresh flow are on the roadmap; their absence is not a vulnerability on its own. Show a reproducible attack that does not already presuppose script execution on `puter.com`.
|
||||
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
@@ -52,7 +68,7 @@ To participate, you must:
|
||||
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
|
||||
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
|
||||
5. **Act in good faith**: Make every effort to avoid privacy violations, destruction of data, and interruption or degradation of services.
|
||||
6. **Check the Known Non-Issues list**: Reports matching an item in the "Known Non-Issues" section above are not eligible and will be closed.
|
||||
6. **Check Eligibility and Known Non-Issues**: Reports that fall outside "Eligibility" or match an item in "Known Non-Issues" above are not eligible and will be closed.
|
||||
|
||||
Reports that do not meet these guidelines may not be eligible for a reward.
|
||||
|
||||
@@ -63,14 +79,14 @@ Reports **MUST** demonstrate a **working, reproducible exploit with real impact*
|
||||
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
- The **observed** result versus the **expected** result.
|
||||
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
|
||||
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process. A unit test or in-process harness alone does not count; show it against a running instance.
|
||||
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
|
||||
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting. Reports sent by automated agents are not eligible.
|
||||
|
||||
Please submit **one issue per report**.
|
||||
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
|
||||
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
|
||||
Bundled "audit packs" of many speculative findings will not be eligible for bounty; send each confirmed issue separately.
|
||||
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be eligible for bounty.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
@@ -83,6 +99,8 @@ Include:
|
||||
- Suggested remediation (if available)
|
||||
- Paypal id where we can send the bounty (if eligible)
|
||||
|
||||
Send the full report in your first email. We don't reply to messages that only ask whether we run a bounty or where to report.
|
||||
|
||||
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
|
||||
|
||||
## Reward Structure
|
||||
@@ -95,7 +113,7 @@ We offer monetary rewards based on the severity of the vulnerability, as determi
|
||||
- **Low: \$50 – \$100**
|
||||
|
||||
Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward.
|
||||
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive.
|
||||
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive. Issues we already know about, from internal review, an open ticket, or a fix already in a public commit or PR, count as duplicates.
|
||||
|
||||
## Payments Disclaimer
|
||||
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@
|
||||
# config.default.json, so partial overrides work. Absent file = defaults.
|
||||
|
||||
# ---- Build stage ----
|
||||
FROM node:24-slim AS build
|
||||
FROM node:26-bookworm-slim AS build
|
||||
|
||||
WORKDIR /opt/puter
|
||||
|
||||
@@ -59,7 +59,7 @@ RUN set -e; \
|
||||
wait $pjs_pid
|
||||
|
||||
# ---- Runtime stage (slim — no build tools) ----
|
||||
FROM node:24-slim
|
||||
FROM node:26-bookworm-slim
|
||||
|
||||
WORKDIR /opt/puter
|
||||
|
||||
|
||||
+19
-6
@@ -7,9 +7,8 @@
|
||||
// Each setting lives at exactly one canonical key — there are no fallback
|
||||
// aliases. Values shown are illustrative, not production secrets.
|
||||
//
|
||||
// Keys consumed only by closed-source / hosted-prod extensions (clickhouse,
|
||||
// cacheUpdateHandler, pages, prodMeteringAndBilling, …) are intentionally
|
||||
// omitted.
|
||||
// Keys consumed only by closed-source / hosted-prod extensions are
|
||||
// intentionally omitted.
|
||||
|
||||
// ── Environment / identity ──────────────────────────────────────────
|
||||
"config_name": "template",
|
||||
@@ -35,7 +34,7 @@
|
||||
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
|
||||
// proxy in front, or the proxy reuses connections this server has closed
|
||||
// and its clients see 502s.
|
||||
"keep_alive_timeout": 620000,
|
||||
"keep_alive_timeout": 920000,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
@@ -216,7 +215,9 @@
|
||||
// "bootstrapTables": true,
|
||||
"path": "",
|
||||
// Credentials. NOTE: snake_case here, unlike `s3.s3Config` below.
|
||||
// For dynamodb-local, any non-empty values work.
|
||||
// For dynamodb-local, any non-empty values work. On AWS,
|
||||
// `"useCredentialChain": true` uses the default credential chain
|
||||
// instead of keys.
|
||||
"aws": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
@@ -314,7 +315,8 @@
|
||||
// The tables ship either way and sit inert; this gates whether `/teams`
|
||||
// is registered at all, so with it off the paths do not exist rather than
|
||||
// existing and refusing. It is also the backout: turning it off removes
|
||||
// the feature without touching data.
|
||||
// the feature without touching data. Teams ships as an extension; on,
|
||||
// the server refuses to boot unless one registers it.
|
||||
"teams_enabled": false,
|
||||
//
|
||||
// Live teams one user may own. Soft-deleted ones do not count, so
|
||||
@@ -348,6 +350,9 @@
|
||||
// not, a row past this is swept. Off by default; any value above 0 arms
|
||||
// the sweep.
|
||||
// "notificationRetentionDays": 14,
|
||||
//
|
||||
// Ms a live notification waits for open tabs to acknowledge it.
|
||||
// "notificationAckTimeoutMs": 5000,
|
||||
|
||||
// ── Alarms / alerting ───────────────────────────────────────────────
|
||||
// Where system alarms go. Severity is the routing decision — each
|
||||
@@ -431,6 +436,8 @@
|
||||
"mistral": { "apiKey": "" },
|
||||
"xai": { "apiKey": "" },
|
||||
"moonshot": { "apiKey": "" },
|
||||
// MiniMax. One key powers chat and video generation (H3, Hailuo);
|
||||
// video calls go to the same host, beside the chat API's `/v1`.
|
||||
"minimax": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.minimax.io/v1"
|
||||
@@ -451,6 +458,10 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3"
|
||||
},
|
||||
// Kling AI. One API key (not the legacy AccessKey/SecretKey pair)
|
||||
// powers video generation, image generation and TTS; `apiBaseUrl`
|
||||
// defaults to https://api-singapore.klingai.com.
|
||||
"kling": { "apiKey": "" },
|
||||
"zai": { "apiKey": "" },
|
||||
"alibaba": { "apiKey": "" },
|
||||
"hoonify": {
|
||||
@@ -489,6 +500,8 @@
|
||||
"defaultVoiceId": "",
|
||||
"speechToSpeechModelId": ""
|
||||
},
|
||||
// Both AWS providers also take `"useCredentialChain": true` in place
|
||||
// of keys, like `dynamo.aws`.
|
||||
"aws-polly": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
|
||||
@@ -45,6 +45,12 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
- **A specific verified factor:** `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. Opt-in, and the factor must actually have been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan when `card` is listed (a paying account already has a card on file). Otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on); with none verifiable the gate is inert rather than locking the route on a self-hosted install. A denial carries the first verifiable factor's code plus `factors` (the verifiable ones, in the route's order), so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, e.g. during an SMS-provider outage.
|
||||
- **Metered spend:** an endpoint that spends metered resources on the caller's behalf (moving file content, object-store requests, anything else the account is billed for) declares `requireCredits: true`, which turns an account with no budget left away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to see what it has, clear it, and reach its billing pages. Drivers have no route options, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated.
|
||||
|
||||
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and either the current six-digit authenticator `code` or an unused recovery code in `code`. Recovery codes are consumed atomically. Missing or incorrect codes leave the secret and remaining recovery codes intact. A team's requirement for 2FA still prevents disabling it.
|
||||
|
||||
Email changes through `/user-protected/change-email` require the password or OIDC revalidation cookie, plus a six-digit `code` from the authenticator when 2FA is enabled. Missing or malformed codes return `400 bad_request`; incorrect codes return `401 code_mismatch`. Both include `factor: 'otp'`. Confirmation links are bound to that verified request. A 2FA-enabled account must request a new link if its existing link was issued without second-factor verification.
|
||||
|
||||
### 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).
|
||||
|
||||
+4
-1
@@ -51,7 +51,10 @@ two pages can't be decoded into how fast the table grows; `openCursor` still
|
||||
reads a plain cursor. What a cursor wraps is an implementation detail per
|
||||
store:
|
||||
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`. A kv listing of another
|
||||
namespace (cross-app or through a share handle) resumes after the last entry
|
||||
it returned instead, since the scan can stop on a private one, and its cursor
|
||||
is sealed to that listing and padded to one length.
|
||||
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
|
||||
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
|
||||
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Browser push client
|
||||
|
||||
Puter's desktop shows pushes through `/sw.js`. Core registers it, and shows Settings → Notifications, only when the `pushEnabled` gui param is `true`; core never sets it. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
|
||||
|
||||
Core subscribes the browser to a push service only when the server also publishes `pushWebPushKey` (see [Built-in Web Push client](#built-in-web-push-client)), and it has no `/push/*` routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (`code: 'not_found'`), which `puter.notifications` reports as `push_unavailable` and the Notifications tab shows as unavailable. A device the extension can't find is `device_not_found`.
|
||||
|
||||
## Push payload
|
||||
|
||||
`sw.js` reads the push's JSON body. The message is its `puter` field, or `data.puter` for services that only carry string data, as an object or a JSON string:
|
||||
|
||||
```ts
|
||||
{ uid?: string; title?: string; body?: string; icon?: string; group?: string;
|
||||
target?: { app?: string; path?: string }; data?: unknown; channel?: string;
|
||||
type?: 'challenge'; challenge?: string }
|
||||
```
|
||||
|
||||
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
|
||||
|
||||
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
|
||||
|
||||
## Registering
|
||||
|
||||
The registry is in [src/gui/src/helpers/pushClient.js](../src/gui/src/helpers/pushClient.js). A service script registers its client like this:
|
||||
|
||||
```js
|
||||
service_script(async (api) => {
|
||||
api.use()['push.registerPushClient']?.(client);
|
||||
});
|
||||
```
|
||||
|
||||
`use(id)` throws when nothing is registered under `id`, so the form above also runs on a Puter version without the registry and does nothing there. Any script that runs after the GUI bundle can call `use()['push.registerPushClient']?.(client)` the same way. Registering again replaces the client. Registering dispatches `push-client-registered` on `window`, and an open Notifications tab redraws.
|
||||
|
||||
With no client registered, the tab says push notifications aren't available in this browser.
|
||||
|
||||
### Built-in Web Push client
|
||||
|
||||
When the server also sets the `pushWebPushKey` gui param (a VAPID public key), core registers its own client ([webPushClient.js](../src/gui/src/helpers/webPushClient.js)) unless an extension registered one first. It uses `puter.notifications.enable()` / `disable()`, the same standard Web Push flow apps use on their own origins:
|
||||
|
||||
1. `GET /push/web/key` for the key, then `pushManager.subscribe()` on the `/sw.js` registration.
|
||||
2. `POST /push/web/subscriptions { subscription }`. A subscription the server already has answers `{ status: 'active' }`. A new one answers `{ status: 'pending' }`, and the server sends it a push with `{ type: 'challenge', challenge }`.
|
||||
3. The worker shows that push and posts `{ type: 'puter-push-challenge', challenge }` to the origin's windows; the page sends it to `POST /push/web/subscriptions/verify`. The device is registered once the page hands the confirmation back.
|
||||
4. `DELETE /push/web/subscriptions { endpoint }` removes it.
|
||||
|
||||
Apps on their own origin do the same through `push-sw.js`, which they import into their own worker. A subscription made with an app token is the app's: its pushes are that app's notifications only.
|
||||
|
||||
## Interface
|
||||
|
||||
```ts
|
||||
interface PushClient {
|
||||
enable(): Promise<{ deviceUid: string }>;
|
||||
disable(): Promise<void>;
|
||||
status(): Promise<{
|
||||
permission: 'default' | 'granted' | 'denied';
|
||||
registered: boolean;
|
||||
deviceUid?: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app holds `notifications:push`; the browser does not prompt then. It subscribes with the `/sw.js` registration, registers the device with `POST /push/devices` (`platform: 'web'`), and resolves the device `uid` the server returned. It rejects when permission is refused or the device couldn't be registered.
|
||||
- `disable()` unsubscribes and removes this browser's device with `DELETE /push/devices/:uid`. The tab calls it for Turn off, and for Remove on the device marked as this browser; Remove on any other device is a plain `DELETE`.
|
||||
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
|
||||
|
||||
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
|
||||
|
||||
The tab also reads `GET /push/apps` and `GET`/`POST /push/preferences`.
|
||||
|
||||
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off and by Remove on this browser's device, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.
|
||||
@@ -309,4 +309,41 @@ describe('metering extension — handleMeteringAllCosts', () => {
|
||||
// per request).
|
||||
expect(costsA).toBe(costsB);
|
||||
});
|
||||
|
||||
it('refreshes a warm catalogue at the UTC price boundary', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-11-30T23:59:59Z'));
|
||||
const before = makeRes();
|
||||
await handleMeteringAllCosts(makeReq(), before.res);
|
||||
vi.setSystemTime(new Date('2026-12-01T00:00:00Z'));
|
||||
const after = makeRes();
|
||||
await handleMeteringAllCosts(makeReq(), after.res);
|
||||
expect(
|
||||
(after.captured.body as { costs: unknown[] }).costs,
|
||||
).not.toBe((before.captured.body as { costs: unknown[] }).costs);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// A scheduled price change has to reach the catalogue without a restart.
|
||||
it('collects the catalogue again once the cache expires', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-12-02T00:00:00Z'));
|
||||
const a = makeRes();
|
||||
const b = makeRes();
|
||||
await handleMeteringAllCosts(makeReq(), a.res);
|
||||
vi.setSystemTime(Date.now() + 10 * 60 * 1000 + 1);
|
||||
await handleMeteringAllCosts(makeReq(), b.res);
|
||||
|
||||
const costsA = (a.captured.body as { costs: unknown[] }).costs;
|
||||
const costsB = (b.captured.body as { costs: unknown[] }).costs;
|
||||
expect(costsB).not.toBe(costsA);
|
||||
expect(costsB).toEqual(costsA);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,11 @@ import type { Request, Response } from 'express';
|
||||
const services = extension.import('service');
|
||||
const stores = extension.import('store');
|
||||
|
||||
// Cached on first request — the underlying cost catalogues are baked into
|
||||
// driver/controller source so they only change on deploy.
|
||||
// Catalogues change on deploy or on a scheduled price change, so the cache
|
||||
// expires rather than living as long as the process.
|
||||
const ALL_COSTS_TTL_MS = 10 * 60 * 1000;
|
||||
let cachedAllCosts: Record<string, unknown>[] | null = null;
|
||||
let allCostsExpireAt = 0;
|
||||
|
||||
function collectAllCosts(): Record<string, unknown>[] {
|
||||
const all: Record<string, unknown>[] = [];
|
||||
@@ -187,12 +189,14 @@ export const handleMeteringGlobalUsage = async (
|
||||
res.json(globalUsage);
|
||||
};
|
||||
|
||||
// First hit walks the registries; subsequent hits serve the in-memory cache.
|
||||
// A hit walks the registries when the in-memory cache is missing or expired.
|
||||
export const handleMeteringAllCosts = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
if (!cachedAllCosts) {
|
||||
if (!cachedAllCosts || Date.now() >= allCostsExpireAt) {
|
||||
allCostsExpireAt =
|
||||
(Math.floor(Date.now() / ALL_COSTS_TTL_MS) + 1) * ALL_COSTS_TTL_MS;
|
||||
const raw = collectAllCosts();
|
||||
const multiplier = creditMultiplierFrom(extension.config);
|
||||
// Price the catalogue in the same unit the usage endpoints report:
|
||||
|
||||
@@ -314,6 +314,41 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('keeps read URLs stable within an hour and rotates them at the next hour', async () => {
|
||||
const key = mintedKey();
|
||||
const signedAt = Date.UTC(2026, 9, 26, 10);
|
||||
const read = async () => {
|
||||
const entry: Record<string, unknown> = {
|
||||
thumbnail: `s3://${BUCKET}/${key}`,
|
||||
};
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: BUCKET_ENDPOINT,
|
||||
fsEntry: server.stores.fsEntry,
|
||||
});
|
||||
return entry.thumbnail as string;
|
||||
};
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
vi.setSystemTime(signedAt + 1000);
|
||||
const first = await read();
|
||||
vi.setSystemTime(signedAt + 3599_000);
|
||||
expect(await read()).toBe(first);
|
||||
expect(new URL(first).searchParams.get('X-Amz-Date')).toBe(
|
||||
'20261026T100000Z',
|
||||
);
|
||||
expect(new URL(first).searchParams.get('X-Amz-Expires')).toBe(
|
||||
'604800',
|
||||
);
|
||||
vi.setSystemTime(signedAt + 3600_000);
|
||||
expect(await read()).not.toBe(first);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// Thumbnails stored before keys were bound to entries keep displaying.
|
||||
it.each([
|
||||
['an entry-bound key', () => mintedKey()],
|
||||
|
||||
@@ -307,6 +307,9 @@ export const handleThumbnailRead = async (
|
||||
const thumb = entry.thumbnail;
|
||||
if (typeof thumb !== 'string' || !thumb) return;
|
||||
const presignClient = deps.s3Presign;
|
||||
const signingDate = new Date(
|
||||
Math.floor(Date.now() / 3_600_000) * 3_600_000,
|
||||
);
|
||||
|
||||
if (isStoragePointer(thumb, deps.bucketEndpoint)) {
|
||||
const key = resolveThumbnailKey(thumb);
|
||||
@@ -319,7 +322,7 @@ export const handleThumbnailRead = async (
|
||||
entry.thumbnail = await getSignedUrl(
|
||||
presignClient,
|
||||
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
{ expiresIn: 604800 },
|
||||
{ expiresIn: 604800, signingDate },
|
||||
);
|
||||
} else if (thumb.startsWith('data')) {
|
||||
// Move an inline thumbnail into storage. The row only takes the new
|
||||
@@ -373,7 +376,7 @@ export const handleThumbnailRead = async (
|
||||
entry.thumbnail = await getSignedUrl(
|
||||
presignClient,
|
||||
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
{ expiresIn: 604800 },
|
||||
{ expiresIn: 604800, signingDate },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+88
-25
@@ -1,13 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { installSeatStub } from '@heyputer/backend/testFixtures/seatStub';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { configContainer } from '../src/backend/exports.ts';
|
||||
@@ -49,7 +43,6 @@ beforeAll(async () => {
|
||||
create_shortcut: true,
|
||||
payment_bypass: true,
|
||||
},
|
||||
teams_enabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -83,21 +76,20 @@ describe('whoami extension — handleWhoami', () => {
|
||||
};
|
||||
|
||||
const seatOf = async (teamName: string) => {
|
||||
const owner = await seedUser();
|
||||
const seat = await seedSeat();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id as number,
|
||||
const team = {
|
||||
uid: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
name: teamName,
|
||||
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id as number, {
|
||||
orgOwned: true,
|
||||
};
|
||||
installSeatStub(server as never, [seat.id as number], {
|
||||
teamUid: team.uid,
|
||||
teamName: team.name,
|
||||
});
|
||||
return { seat, team };
|
||||
};
|
||||
|
||||
it('names the team for a seat', async () => {
|
||||
const { seat, team } = await seatOf('Acme Corp');
|
||||
it('names no team where teams is off', async () => {
|
||||
const { seat } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
@@ -105,10 +97,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect((captured.body as { team?: unknown }).team).toEqual({
|
||||
uid: team.uid,
|
||||
name: 'Acme Corp',
|
||||
});
|
||||
// Off, so the seat is never looked up.
|
||||
expect((captured.body as { team?: unknown }).team).toBeUndefined();
|
||||
});
|
||||
|
||||
it('says nothing for an account that is not a seat', async () => {
|
||||
@@ -170,6 +160,81 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(captured.body).toEqual({ error: 'User not found' });
|
||||
});
|
||||
|
||||
describe("an app's view of the email", () => {
|
||||
const appActorFor = async (user: { uuid: string; id: unknown }) => {
|
||||
const app = await (
|
||||
server.stores.app.create as unknown as (
|
||||
fields: Record<string, unknown>,
|
||||
opts: { ownerUserId: number },
|
||||
) => Promise<{ uid: string; id: number }>
|
||||
)(
|
||||
{
|
||||
name: `we-${uuidv4()}`,
|
||||
title: 'Email reader',
|
||||
index_url: `https://${uuidv4()}.example/`,
|
||||
},
|
||||
{ ownerUserId: user.id as number },
|
||||
);
|
||||
return {
|
||||
app,
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: app.uid, id: app.id },
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const whoamiAs = async (actor: Actor) => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('is withheld until the user grants it, then shown', async () => {
|
||||
const user = await seedUser();
|
||||
const { app, actor } = await appActorFor(user);
|
||||
|
||||
const before = (await whoamiAs(actor)).body as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(before).not.toHaveProperty('email');
|
||||
expect(before).not.toHaveProperty('unconfirmed_email');
|
||||
|
||||
await server.services.permission.grantUserAppPermission(
|
||||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||||
app.uid,
|
||||
`user:${user.uuid}:email:read`,
|
||||
);
|
||||
const after = (await whoamiAs(actor)).body as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(after.email).toBe(user.email);
|
||||
expect(after.unconfirmed_email).toBe(user.email);
|
||||
});
|
||||
|
||||
it('answers 404 for a missing account even when the check fails', async () => {
|
||||
const check = vi
|
||||
.spyOn(server.services.permission, 'check')
|
||||
.mockRejectedValue(new Error('scan failed'));
|
||||
try {
|
||||
const captured = await whoamiAs(
|
||||
makeActor({
|
||||
user: { uuid: 'ghost-uuid', id: 99_999_998 },
|
||||
app: { uid: 'app-ghost', id: 1 },
|
||||
}),
|
||||
);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toEqual({ error: 'User not found' });
|
||||
} finally {
|
||||
check.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns full user details for a user actor', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -451,9 +516,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
}),
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
await runWithContext({ actor }, () => handleWhoami(makeReq(), res));
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.phone).toBeUndefined();
|
||||
|
||||
+18
-5
@@ -36,6 +36,18 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const isUser = !actor.effectiveApp;
|
||||
const canReadEmail = async (uuid: unknown) => {
|
||||
try {
|
||||
return await services.permission.check(
|
||||
actor,
|
||||
`user:${uuid}:email:read`,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// Started beside the row read: the actor carries the row's uuid.
|
||||
const emailCheck = isUser ? null : canReadEmail(actor.user.uuid);
|
||||
const user = await stores.user.getById(actor.user.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
@@ -62,10 +74,10 @@ export const handleWhoami = async (
|
||||
|
||||
// Strip sensitive fields for app actors
|
||||
if (!isUser) {
|
||||
const canReadEmail = await services.permission
|
||||
.check(actor, `user:${user.uuid}:email:read`)
|
||||
.catch(() => false);
|
||||
if (!canReadEmail) {
|
||||
const emailReadable = await (user.uuid === actor.user.uuid
|
||||
? emailCheck
|
||||
: canReadEmail(user.uuid));
|
||||
if (!emailReadable) {
|
||||
delete details.email;
|
||||
delete details.unconfirmed_email;
|
||||
}
|
||||
@@ -109,7 +121,8 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
Generated
+111
-99
@@ -16,7 +16,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.14",
|
||||
"libphonenumber-js": "1.13.15",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
@@ -1503,6 +1503,17 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
|
||||
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/JounQin"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -5921,9 +5932,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/busboy": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
|
||||
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.5.tgz",
|
||||
"integrity": "sha512-IwqrfVFxWa23c4WeE7uZswssCqGfvnl5Q7b1pdshbT3HaQDa3UIbJx5BAxtcj9nZYX9tVDArM9TUv9SpvORbpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6526,14 +6537,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
|
||||
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -6547,8 +6558,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
"@vitest/browser": "4.1.12",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -6557,16 +6568,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -6575,13 +6586,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -6602,9 +6613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6615,13 +6626,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -6629,14 +6640,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -6645,9 +6656,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -6655,13 +6666,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -7768,9 +7779,9 @@
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9125,14 +9136,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
|
||||
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz",
|
||||
"integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.1",
|
||||
"synckit": "^0.11.13"
|
||||
"synckit": "^0.12.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -9881,9 +9892,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -11523,9 +11534,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.13.14",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
|
||||
"integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
|
||||
"version": "1.13.15",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.15.tgz",
|
||||
"integrity": "sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
@@ -13363,9 +13374,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.24.5",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
|
||||
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
|
||||
"version": "3.24.6",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz",
|
||||
"integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
@@ -14367,9 +14378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.9",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
|
||||
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
|
||||
"integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -15578,13 +15589,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smtp-server": {
|
||||
"version": "3.19.17",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.17.tgz",
|
||||
"integrity": "sha512-/9RwyNTcR8osMQWOvC8NV8l57ddPyYXvuG+wa1Ua4cD/I41z7zCMsGtXLa9vxlrggjRrfejr50X0WS/9NuVWqQ==",
|
||||
"version": "3.19.24",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.24.tgz",
|
||||
"integrity": "sha512-alzRmwDr5ywR8xo6F2AXqTMWSEjhjC+qB/yHD+zcn2PSEpsJTJ/5O7QdDT4pGVvNNwE+1jhA1ax+hMwleTceWw==",
|
||||
"license": "MIT-0",
|
||||
"dependencies": {
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "10.0.14",
|
||||
"nodemailer": "10.1.4",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15592,9 +15603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
||||
"version": "10.0.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.14.tgz",
|
||||
"integrity": "sha512-eJoLFKg55fesSlzUekmfOv/SBXh4KUYT8oiQc2RLP0tfG0rbNMJBK7wgHJwXTJwqyKlSJMz9UzDZ4v+aHEvo8g==",
|
||||
"version": "10.1.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.4.tgz",
|
||||
"integrity": "sha512-EsMnOR2oRv4LGnWvHgcVYkkQ4O2j8KO4WTw6l+PHR4sOXo8SAgIraxtEnGDmotjbhv0hDsu3IuDIFZm8iXwO4A==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -16003,12 +16014,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
|
||||
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz",
|
||||
"integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dual-bundle/import-meta-resolve": "^4.2.1",
|
||||
"@pkgr/core": "^0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -16607,9 +16619,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.30.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
|
||||
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
|
||||
"version": "7.30.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
|
||||
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -16730,9 +16742,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz",
|
||||
"integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
@@ -16775,9 +16787,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"version": "8.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
|
||||
"integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -16853,19 +16865,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -16893,12 +16905,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -17704,7 +17716,7 @@
|
||||
"lorem-ipsum": "^2.0.8",
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.24.5",
|
||||
"mysql2": "^3.24.6",
|
||||
"nodemailer": "^9.1.1",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
@@ -17712,19 +17724,19 @@
|
||||
"postal-mime": "^3.0.1",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.17",
|
||||
"smtp-server": "^3.19.24",
|
||||
"socket.io": "^4.8.4",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"together-ai": "^0.33.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
"undici": "^7.29.1",
|
||||
"uuid": "^14.0.2",
|
||||
"undici": "^7.30.1",
|
||||
"uuid": "^14.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/busboy": "^1.5.5",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
@@ -17735,7 +17747,7 @@
|
||||
"@types/validator": "^13.15.10",
|
||||
"pgmock": "^1.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.1.5",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.0.14"
|
||||
}
|
||||
},
|
||||
@@ -19087,7 +19099,7 @@
|
||||
},
|
||||
"src/puter-js": {
|
||||
"name": "@heyputer/puter.js",
|
||||
"version": "2.6.4",
|
||||
"version": "2.6.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@heyputer/kv.js": "^0.2.1",
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.14",
|
||||
"libphonenumber-js": "1.13.15",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PuterServer } from './server.ts';
|
||||
import { createTestUser, setupTestServer } from './testUtil.ts';
|
||||
|
||||
/**
|
||||
* Teams is optional, so core has to work when neither the service nor the store
|
||||
* is registered, which is how this test server boots.
|
||||
*/
|
||||
describe('core without a teams registration', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
const makeUser = async (): Promise<{ id: number; username: string }> => {
|
||||
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number };
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
// Without this the allowlist check returns before asking Teams.
|
||||
teams_allowed_email_domains: ['test.local'],
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('has no teams in the registry', () => {
|
||||
expect(server.services.team).toBeUndefined();
|
||||
expect(server.stores.team).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to boot when the config asks for teams', async () => {
|
||||
await expect(
|
||||
setupTestServer({ teams_enabled: true } as never),
|
||||
).rejects.toThrow(/teams_enabled/);
|
||||
});
|
||||
|
||||
it('deletes an account without asking what it was charged for', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
server.services.userAccount.cascadeDelete(user.id),
|
||||
).resolves.not.toThrow();
|
||||
expect(await server.stores.user.getById(user.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('answers the address-claim question signup asks', async () => {
|
||||
const user = await makeUser();
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
// Nothing is a seat, so the holder is returned as-is.
|
||||
await expect(
|
||||
server.services.signup.signupEmailHolder(row.email as string, {
|
||||
releaseSeat: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ id: user.id });
|
||||
});
|
||||
|
||||
it('lists what is shared with an account, folding in no team shares', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
server.services.share.listSharedWithMe({
|
||||
user: { id: user.id },
|
||||
} as never),
|
||||
).resolves.toMatchObject({ items: [] });
|
||||
});
|
||||
|
||||
it('refuses a share aimed at a team, rather than failing', async () => {
|
||||
// A real home directory, so the refusal is the recipient's and not
|
||||
// the entry's.
|
||||
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const creds = await createTestUser(server, {
|
||||
username,
|
||||
password: 'Nt-Pw-12345!',
|
||||
});
|
||||
const row = (await server.stores.user.getByUsername(username))!;
|
||||
const user = { id: row.id, username };
|
||||
const actor = { user: row } as never;
|
||||
expect(creds).toBeTruthy();
|
||||
// The recipient cannot exist, so this is a 404 and not a crash.
|
||||
await expect(
|
||||
server.services.share.share(actor, {
|
||||
recipient: { teamHandle: 'nobody' },
|
||||
mode: 'read',
|
||||
path: `/${user.username}`,
|
||||
} as never),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('serves the homepage shell with the teams tab off', async () => {
|
||||
const user = await makeUser();
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
let html = '';
|
||||
const res = { send: (out: string) => (html = out) };
|
||||
await server.services.homepage.send(
|
||||
{
|
||||
req: { headers: {}, query: {}, get: () => undefined },
|
||||
res,
|
||||
actor: { user: row },
|
||||
} as never,
|
||||
{ title: 'Puter' } as never,
|
||||
{} as never,
|
||||
);
|
||||
const params = JSON.parse(
|
||||
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
|
||||
);
|
||||
expect(params.teams_ui).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,25 @@ describe('AbstractDatabaseClient — unimplemented surface', () => {
|
||||
});
|
||||
|
||||
describe('AbstractDatabaseClient — replica-aware reads', () => {
|
||||
it('avoids a primary read when the replica has rows in sequential mode', async () => {
|
||||
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
|
||||
expect(
|
||||
await client.tryHardRead('SELECT 1', [], { race: false }),
|
||||
).toEqual([{ id: 1 }]);
|
||||
expect(client.preads).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([[], new Error('replica unavailable')])(
|
||||
'falls back once in sequential mode',
|
||||
async (replica) => {
|
||||
const client = new FixtureDatabaseClient(replica, [{ id: 2 }]);
|
||||
expect(
|
||||
await client.tryHardRead('SELECT 1', [], { race: false }),
|
||||
).toEqual([{ id: 2 }]);
|
||||
expect(client.preads).toEqual(['SELECT 1']);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns replica rows without waiting on the primary', async () => {
|
||||
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface WriteResult {
|
||||
insertId: number | bigint;
|
||||
affectedRows: number;
|
||||
anyRowsAffected: boolean;
|
||||
/**
|
||||
* Warnings the statement raised; nonzero means a value may have been stored
|
||||
* differently from how it was sent. Absent when the engine can't vouch that
|
||||
* a clean insert stored exactly what was sent, on a fresh id.
|
||||
*/
|
||||
warningStatus?: number;
|
||||
}
|
||||
|
||||
export interface BatchEntry {
|
||||
@@ -132,25 +138,27 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
|
||||
/**
|
||||
* Like `read()`, but an empty or failed replica read falls back to the
|
||||
* primary, so replication lag can't hide a row that was just written. The
|
||||
* primary read starts alongside the replica one rather than after it.
|
||||
* Without a replica this is a single `read()`.
|
||||
* primary, so replication lag can't hide a row that was just written. By
|
||||
* default the primary read starts alongside the replica one; `race: false`
|
||||
* reads the primary only after the replica returns empty or fails. Without
|
||||
* a replica this is a single `read()`.
|
||||
*/
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
{ race = true }: { race?: boolean } = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (!this.hasReadReplica()) return this.read(query, params);
|
||||
|
||||
const primary = settle(this.pread(query, params));
|
||||
const primary = race ? settle(this.pread(query, params)) : null;
|
||||
try {
|
||||
const rows = await this.replicaRead(query, params);
|
||||
if (rows.length > 0) return rows;
|
||||
} catch {
|
||||
// replica failed — fall through to primary
|
||||
}
|
||||
const result = await primary;
|
||||
const result = await (primary ?? settle(this.pread(query, params)));
|
||||
if ('error' in result) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
@@ -330,6 +338,30 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole `periodSeconds` buckets since the epoch, from a unix-seconds
|
||||
* column. MySQL's `/` yields a decimal, which would split a bucket.
|
||||
*/
|
||||
periodBucket(secondsExpression: string, periodSeconds: number): string {
|
||||
return this.case({
|
||||
mysql: `(${secondsExpression} DIV ${periodSeconds})`,
|
||||
otherwise: `(${secondsExpression} / ${periodSeconds})`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts each (`userExpression`, bucket) pair once. `COUNT(DISTINCT a, b)`
|
||||
* is MySQL-only, so the pair folds into one number — injective while the
|
||||
* bucket stays under the multiplier, which hourly reaches in the 2980s.
|
||||
*/
|
||||
countDistinctUserPeriods(
|
||||
userExpression: string,
|
||||
secondsExpression: string,
|
||||
periodSeconds: number,
|
||||
): string {
|
||||
return `COUNT(DISTINCT ${userExpression} * 10000000 + ${this.periodBucket(secondsExpression, periodSeconds)})`;
|
||||
}
|
||||
|
||||
nullCoalesce(...expressions: readonly string[]): string {
|
||||
if (expressions.length === 0) {
|
||||
throw new Error('nullCoalesce requires at least one expression');
|
||||
|
||||
@@ -435,6 +435,105 @@ describe('MySQLDatabaseClient — query interface', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ method: 'pread', poolIndex: 0, maxTimeInQueue: 30 },
|
||||
{ method: 'read', poolIndex: 1, maxTimeInQueue: 10 },
|
||||
] as const)(
|
||||
'$method: flushes lone reads at once until 8 are in flight, then waits $maxTimeInQueue ms and coalesces',
|
||||
async ({ method, poolIndex, maxTimeInQueue }) => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
const pool = createdPools[poolIndex];
|
||||
const held: Array<() => void> = [];
|
||||
pool.respond = (sql) =>
|
||||
new Promise((resolve) => {
|
||||
const sets = sql
|
||||
.split(';')
|
||||
.slice(0, -1)
|
||||
.map((stmt) => [{ stmt }]);
|
||||
held.push(() => resolve(rowSets(...sets)));
|
||||
});
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const reads: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
reads.push(client[method](`SELECT ${i}`));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
expect(pool.calls).toHaveLength(8);
|
||||
|
||||
reads.push(client[method]('SELECT 8'));
|
||||
await vi.advanceTimersByTimeAsync(maxTimeInQueue - 1);
|
||||
reads.push(client[method]('SELECT 9'));
|
||||
expect(pool.calls).toHaveLength(8);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pool.calls.map((c) => c.sql).at(-1)).toBe(
|
||||
'SELECT 8;SELECT 9; SELECT 1',
|
||||
);
|
||||
|
||||
for (const release of held.splice(0)) release();
|
||||
const rows = await Promise.all(reads);
|
||||
expect(rows.at(-2)).toEqual([{ stmt: 'SELECT 8' }]);
|
||||
expect(rows.at(-1)).toEqual([{ stmt: 'SELECT 9' }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ setup: 'single node', replica: undefined, waits: true },
|
||||
{ setup: 'replica', replica: { host: 'replica' }, waits: false },
|
||||
])(
|
||||
'$setup: 8 primary reads in flight hold back a lone read: $waits',
|
||||
async ({ replica, waits }) => {
|
||||
const client = await startClient(replica ? { replica } : {});
|
||||
const held: Array<() => void> = [];
|
||||
for (const pool of createdPools) {
|
||||
pool.respond = (sql) =>
|
||||
new Promise((resolve) => {
|
||||
const sets = sql
|
||||
.split(';')
|
||||
.slice(0, -1)
|
||||
.map((stmt) => [{ stmt }]);
|
||||
held.push(() => resolve(rowSets(...sets)));
|
||||
});
|
||||
}
|
||||
const sent = () => createdPools.flatMap((pool) => pool.calls);
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const reads: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
reads.push(client.pread(`SELECT ${i}`));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
reads.push(client.read('SELECT 8'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent()).toHaveLength(waits ? 8 : 9);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(sent()).toHaveLength(9);
|
||||
|
||||
for (const release of held.splice(0)) release();
|
||||
await Promise.all(reads);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('sends a write on its own, outside a transaction', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
@@ -472,6 +571,23 @@ describe('MySQLDatabaseClient — query interface', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the warning count through, so callers can tell a clean write', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () =>
|
||||
writeHeader({ insertId: 9, affectedRows: 1, warningStatus: 1 });
|
||||
|
||||
await expect(
|
||||
client.write('INSERT IGNORE INTO `user` (`username`) VALUES (?)', [
|
||||
'ada',
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
insertId: 9,
|
||||
affectedRows: 1,
|
||||
anyRowsAffected: true,
|
||||
warningStatus: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no rows affected when the header omits the counters', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () => writeHeader({});
|
||||
|
||||
@@ -78,8 +78,13 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
// Read batchers on one pool share the cap on immediate flushes.
|
||||
const primaryReadFlushes = { count: 0 };
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(
|
||||
this.primaryPool,
|
||||
primaryReadFlushes,
|
||||
);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -90,7 +95,12 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
this.dbReplica = this.createReplicaBatcher(
|
||||
this.replicaPool,
|
||||
this.configuration === Configuration.SINGLE
|
||||
? primaryReadFlushes
|
||||
: { count: 0 },
|
||||
);
|
||||
|
||||
await this.applyMigrationPaths(
|
||||
() => this.primaryPool.promise().getConnection(),
|
||||
@@ -164,12 +174,14 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
const header = result[0] as {
|
||||
insertId?: number;
|
||||
affectedRows?: number;
|
||||
warningStatus?: number;
|
||||
};
|
||||
const affectedRows = header.affectedRows ?? 0;
|
||||
return {
|
||||
insertId: header.insertId ?? 0,
|
||||
affectedRows,
|
||||
anyRowsAffected: affectedRows > 0,
|
||||
warningStatus: header.warningStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,7 +217,8 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return this.configuration === Configuration.REPLICA;
|
||||
}
|
||||
|
||||
// No failover: `tryHardRead` already has a primary read in flight.
|
||||
// No failover: `tryHardRead` falls back to the primary on an empty result
|
||||
// or error (raced alongside by default, after the replica with `race: false`).
|
||||
protected override async replicaRead(
|
||||
query: string,
|
||||
params: unknown[],
|
||||
@@ -274,9 +287,14 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
|
||||
/** Reads that must see the primary, still coalesced. */
|
||||
private createPrimaryReadBatcher(pool: Pool): SQLBatcher {
|
||||
private createPrimaryReadBatcher(
|
||||
pool: Pool,
|
||||
inFlight: { count: number },
|
||||
): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 8,
|
||||
inFlight,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
readOnly: true,
|
||||
@@ -284,9 +302,14 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
private createReplicaBatcher(pool: Pool): SQLBatcher {
|
||||
private createReplicaBatcher(
|
||||
pool: Pool,
|
||||
inFlight: { count: number },
|
||||
): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 10,
|
||||
maxInFlightImmediate: 8,
|
||||
inFlight,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'replica',
|
||||
readOnly: true,
|
||||
|
||||
@@ -76,6 +76,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export class SQLBatcher {
|
||||
dbPool;
|
||||
maxTimeInQueue;
|
||||
maxInFlightImmediate;
|
||||
maxBatchSize;
|
||||
maxQueueSize;
|
||||
failureThreshold;
|
||||
@@ -84,7 +85,9 @@ export class SQLBatcher {
|
||||
readOnly;
|
||||
acquireTimeoutMs;
|
||||
queue = [];
|
||||
timeouts = [];
|
||||
#flushTimer = null;
|
||||
#flushTimerIsImmediate = false;
|
||||
#inFlight;
|
||||
#consecutiveFailures = 0;
|
||||
#lastFailureAt = 0;
|
||||
#metricAttrs;
|
||||
@@ -93,6 +96,11 @@ export class SQLBatcher {
|
||||
* @param {object} dbPool Mysql2 pool
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxTimeInQueue] Ms an item may wait before flush
|
||||
* @param {number} [opts.maxInFlightImmediate] While fewer flushes than this
|
||||
* are in flight, a lone item flushes on the next turn instead of waiting
|
||||
* maxTimeInQueue; past it, items wait and coalesce to spare connections
|
||||
* @param {{ count: number }} [opts.inFlight] In-flight flush counter the
|
||||
* cap reads; batchers sharing a pool can share one
|
||||
* @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
|
||||
@@ -111,6 +119,8 @@ export class SQLBatcher {
|
||||
dbPool,
|
||||
{
|
||||
maxTimeInQueue = 20,
|
||||
maxInFlightImmediate = 0,
|
||||
inFlight = { count: 0 },
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
@@ -122,6 +132,8 @@ export class SQLBatcher {
|
||||
) {
|
||||
this.dbPool = dbPool;
|
||||
this.maxTimeInQueue = maxTimeInQueue;
|
||||
this.maxInFlightImmediate = maxInFlightImmediate;
|
||||
this.#inFlight = inFlight;
|
||||
this.maxBatchSize = maxBatchSize;
|
||||
this.maxQueueSize = maxQueueSize;
|
||||
this.failureThreshold = failureThreshold;
|
||||
@@ -185,17 +197,34 @@ export class SQLBatcher {
|
||||
|
||||
if (this.queue.length >= this.maxBatchSize) {
|
||||
this.flush(this.queue.splice(0, this.maxBatchSize));
|
||||
} else if (this.queue.length === 1) {
|
||||
this.timeouts.push(
|
||||
setTimeout(() => {
|
||||
this.flush(this.queue.splice(0, this.queue.length));
|
||||
}, this.maxTimeInQueue),
|
||||
);
|
||||
} else if (this.#flushTimer === null) {
|
||||
this.#armFlushTimer();
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
// A lone flush holds a pooled connection for a full round trip, so only
|
||||
// the first maxInFlightImmediate flushes skip the wait.
|
||||
#armFlushTimer() {
|
||||
const run = () => {
|
||||
this.#flushTimer = null;
|
||||
this.flush(this.queue.splice(0, this.queue.length));
|
||||
};
|
||||
this.#flushTimerIsImmediate =
|
||||
this.#inFlight.count < this.maxInFlightImmediate;
|
||||
this.#flushTimer = this.#flushTimerIsImmediate
|
||||
? setImmediate(run)
|
||||
: setTimeout(run, this.maxTimeInQueue);
|
||||
}
|
||||
|
||||
#clearFlushTimer() {
|
||||
if (this.#flushTimer === null) return;
|
||||
if (this.#flushTimerIsImmediate) clearImmediate(this.#flushTimer);
|
||||
else clearTimeout(this.#flushTimer);
|
||||
this.#flushTimer = null;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -252,12 +281,18 @@ export class SQLBatcher {
|
||||
}
|
||||
|
||||
async flush(batch) {
|
||||
const timeout = this.timeouts.shift();
|
||||
if (timeout && !timeout._destroyed) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
this.#clearFlushTimer();
|
||||
if (batch.length === 0) return;
|
||||
|
||||
this.#inFlight.count++;
|
||||
try {
|
||||
await this.#flushBatch(batch);
|
||||
} finally {
|
||||
this.#inFlight.count--;
|
||||
}
|
||||
}
|
||||
|
||||
async #flushBatch(batch) {
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.#acquireConnection();
|
||||
|
||||
@@ -69,6 +69,356 @@ const happyBatch = (sql: string) => {
|
||||
];
|
||||
};
|
||||
|
||||
// Each query on this connection waits until the test releases it, so its
|
||||
// flush stays in flight.
|
||||
const makeHeldConnection = () => {
|
||||
const held: Array<() => void> = [];
|
||||
const conn = makeConnection(
|
||||
(sql) =>
|
||||
new Promise((resolve) => {
|
||||
held.push(() => resolve(happyBatch(sql)));
|
||||
}),
|
||||
);
|
||||
const releaseAll = () => {
|
||||
for (const release of held.splice(0)) release();
|
||||
};
|
||||
const sent = () => conn.query.mock.calls.map(([sql]) => sql);
|
||||
return { conn, releaseAll, sent };
|
||||
};
|
||||
|
||||
const fakeFlushTimers = () =>
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
],
|
||||
});
|
||||
|
||||
describe('SQLBatcher flush timing', () => {
|
||||
it('flushes a lone query on the next turn while under the in-flight cap', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 60_000,
|
||||
maxInFlightImmediate: 2,
|
||||
});
|
||||
|
||||
const pending = batcher.query('SELECT a', []);
|
||||
expect(getConnection).not.toHaveBeenCalled();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(getConnection).toHaveBeenCalledTimes(1);
|
||||
expect((await pending)[0]).toEqual([{ n: 0 }]);
|
||||
});
|
||||
|
||||
it('holds a lone query for maxTimeInQueue without an in-flight cap', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 30,
|
||||
});
|
||||
|
||||
const pending = batcher.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
expect(getConnection).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(getConnection).toHaveBeenCalledTimes(1);
|
||||
expect((await pending)[0]).toEqual([{ n: 0 }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('batches queries from the same turn and sends later ones separately', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxInFlightImmediate: 8,
|
||||
});
|
||||
|
||||
const sameTurn = [
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
batcher.query('SELECT c', []),
|
||||
];
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const nextTurn = batcher.query('SELECT d', []);
|
||||
const results = await Promise.all([...sameTurn, nextTurn]);
|
||||
|
||||
expect(results.map((r) => r[0])).toEqual([
|
||||
[{ n: 0 }],
|
||||
[{ n: 1 }],
|
||||
[{ n: 2 }],
|
||||
[{ n: 0 }],
|
||||
]);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
expect(conn.query.mock.calls.map(([sql]) => sql)).toEqual([
|
||||
'SELECT a;SELECT b;SELECT c; SELECT 1',
|
||||
'SELECT d; SELECT 1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a query enqueued during an in-flight flush its own flush while under the cap', async () => {
|
||||
const { conn, releaseAll, sent } = makeHeldConnection();
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxInFlightImmediate: 2,
|
||||
});
|
||||
const turn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
await turn();
|
||||
const b = batcher.query('SELECT b', []);
|
||||
await turn();
|
||||
expect(sent()).toEqual(['SELECT a; SELECT 1', 'SELECT b; SELECT 1']);
|
||||
|
||||
releaseAll();
|
||||
expect((await a)[0]).toEqual([{ n: 0 }]);
|
||||
expect((await b)[0]).toEqual([{ n: 0 }]);
|
||||
});
|
||||
|
||||
it('holds a lone query for maxTimeInQueue at the in-flight cap and coalesces what follows', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const { conn, releaseAll, sent } = makeHeldConnection();
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 1,
|
||||
});
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent()).toEqual(['SELECT a; SELECT 1']);
|
||||
|
||||
const b = batcher.query('SELECT b', []);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
const c = batcher.query('SELECT c', []);
|
||||
expect(sent()).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(sent()).toEqual([
|
||||
'SELECT a; SELECT 1',
|
||||
'SELECT b;SELECT c; SELECT 1',
|
||||
]);
|
||||
|
||||
releaseAll();
|
||||
expect((await a)[0]).toEqual([{ n: 0 }]);
|
||||
expect((await b)[0]).toEqual([{ n: 0 }]);
|
||||
expect((await c)[0]).toEqual([{ n: 1 }]);
|
||||
|
||||
// Settled flushes free the cap again.
|
||||
const d = batcher.query('SELECT d', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent()).toHaveLength(3);
|
||||
releaseAll();
|
||||
expect((await d)[0]).toEqual([{ n: 0 }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('shares the in-flight cap between batchers given one counter', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const { conn, releaseAll, sent } = makeHeldConnection();
|
||||
const { pool } = makePool(conn);
|
||||
const inFlight = { count: 0 };
|
||||
const opts = {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 1,
|
||||
inFlight,
|
||||
};
|
||||
const first = new SQLBatcher(pool, opts);
|
||||
const second = new SQLBatcher(pool, opts);
|
||||
const own = new SQLBatcher(pool, { ...opts, inFlight: undefined });
|
||||
|
||||
const a = first.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const b = second.query('SELECT b', []);
|
||||
const c = own.query('SELECT c', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent()).toEqual([
|
||||
'SELECT a; SELECT 1',
|
||||
'SELECT c; SELECT 1',
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
expect(sent()).toHaveLength(3);
|
||||
expect(sent()[2]).toBe('SELECT b; SELECT 1');
|
||||
|
||||
releaseAll();
|
||||
await Promise.all([a, b, c]);
|
||||
expect(inFlight.count).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts a flush as in flight until its per-item fallback settles', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const held: Array<() => void> = [];
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
return new Promise((resolve) => {
|
||||
held.push(() => resolve([[{ ok: sql }], undefined]));
|
||||
});
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const sent = () => conn.query.mock.calls.map(([sql]) => sql);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 1,
|
||||
acquireTimeoutMs: 0,
|
||||
});
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
// The batch failed; the per-item retry of `a` is still running.
|
||||
expect(sent()).toEqual(['SELECT a; SELECT 1', 'SELECT a']);
|
||||
|
||||
const b = batcher.query('SELECT b', []);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
expect(sent()).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(sent()).toEqual([
|
||||
'SELECT a; SELECT 1',
|
||||
'SELECT a',
|
||||
'SELECT b; SELECT 1',
|
||||
'SELECT b',
|
||||
]);
|
||||
|
||||
for (const release of held.splice(0)) release();
|
||||
expect((await a)[0]).toEqual([{ ok: 'SELECT a' }]);
|
||||
expect((await b)[0]).toEqual([{ ok: 'SELECT b' }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('flushes a full batch at once past the cap and cancels the armed wait', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const { conn, releaseAll, sent } = makeHeldConnection();
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxBatchSize: 2,
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 1,
|
||||
acquireTimeoutMs: 0,
|
||||
});
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const b = batcher.query('SELECT b', []);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
const c = batcher.query('SELECT c', []);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
// A later lone query waits its own full maxTimeInQueue.
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
const d = batcher.query('SELECT d', []);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(getConnection).toHaveBeenCalledTimes(3);
|
||||
expect(sent()).toEqual([
|
||||
'SELECT a; SELECT 1',
|
||||
'SELECT b;SELECT c; SELECT 1',
|
||||
'SELECT d; SELECT 1',
|
||||
]);
|
||||
|
||||
releaseAll();
|
||||
await Promise.all([a, b, c, d]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the pending next-turn flush when a batch fills up', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxBatchSize: 2,
|
||||
readOnly: true,
|
||||
maxInFlightImmediate: 8,
|
||||
acquireTimeoutMs: 0,
|
||||
});
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
const b = batcher.query('SELECT b', []);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
const c = batcher.query('SELECT c', []);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.all([a, b, c]);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no timer armed after a failed flush, a drop or a breaker rejection', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const getConnection = vi.fn(async () => {
|
||||
throw makeError('ECONNREFUSED');
|
||||
});
|
||||
const batcher = new SQLBatcher(
|
||||
{ promise: () => ({ getConnection }) },
|
||||
{
|
||||
readOnly: true,
|
||||
maxQueueSize: 1,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
maxInFlightImmediate: 8,
|
||||
},
|
||||
);
|
||||
|
||||
const dropped = expect(
|
||||
batcher.query('SELECT a', []),
|
||||
).rejects.toMatchObject({ reason: 'queueOverflow' });
|
||||
const failed = expect(
|
||||
batcher.query('SELECT b', []),
|
||||
).rejects.toMatchObject({ reason: 'connAcquire' });
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await vi.runAllTimersAsync();
|
||||
await dropped;
|
||||
await failed;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
await expect(batcher.query('SELECT c', [])).rejects.toMatchObject({
|
||||
reason: 'breakerOpen',
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(batcher.queue).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQLBatcher', () => {
|
||||
it('resolves each batched item with its own result', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 89;
|
||||
const CURRENT_SCHEMA_VERSION = 93;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -930,6 +930,7 @@ describe('SqliteDatabaseClient — query interface', () => {
|
||||
affectedRows: 1,
|
||||
anyRowsAffected: true,
|
||||
});
|
||||
expect(inserted).not.toHaveProperty('warningStatus');
|
||||
|
||||
const missed = await client.write(
|
||||
'UPDATE `widget` SET `name` = ? WHERE `id` = ?',
|
||||
|
||||
@@ -128,6 +128,10 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[86, ['0091_icloud-dot-significance.sql']],
|
||||
[87, ['0092_share-entry-owner.sql']],
|
||||
[88, ['0093_fsentries-parent-uid-index.sql']],
|
||||
[89, ['0094_deleted-apps.sql']],
|
||||
[90, ['0095_kv-share-handle-levels.sql']],
|
||||
[91, ['0096_kv-share-handle-quota-indexes.sql']],
|
||||
[92, ['0097_user-to-user-holder-permission-index.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
@@ -206,6 +210,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
insertId: info.lastInsertRowid,
|
||||
affectedRows: info.changes,
|
||||
anyRowsAffected: info.changes > 0,
|
||||
// No warningStatus: SQLite raises none, but a new row can reuse a
|
||||
// deleted row's rowid that other tables still name, so a clean
|
||||
// insert doesn't prove the row is all the caller sent.
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
-- Uid and owner of each deleted app. Mirrors SQLite migration 0094.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `deleted_apps` (
|
||||
`uid` VARCHAR(40) NOT NULL,
|
||||
`owner_user_id` INT UNSIGNED DEFAULT NULL,
|
||||
PRIMARY KEY (`uid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Share levels on `kv_share_handles`. See
|
||||
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. No per-file
|
||||
-- applied-state tracking, so every step is guarded and a replay does nothing.
|
||||
|
||||
CALL _puter_add_col('kv_share_handles', 'revoked_by', '`revoked_by` varchar(8) DEFAULT NULL');
|
||||
CALL _puter_add_col('kv_share_handles', 'label', '`label` varchar(512) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_kv_share_handle_indexes()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_grantee'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee` (`grantee_user_id`, `id`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_app'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_app` (`app_uid`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_kv_share_handle_indexes();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Share-handle quota, listing and purge indexes. See
|
||||
-- sqlite/0096_kv-share-handle-quota-indexes.sql for what each serves. No
|
||||
-- per-file applied-state tracking, so every index is guarded and a replay does
|
||||
-- nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_quota_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_kv_share_handle_quota_indexes()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_owner_live'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_owner_live` (`owner_user_id`, `revoked_at`, `app_uid`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_owner_app'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_owner_app` (`owner_user_id`, `app_uid`, `id`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_grantee_app'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee_app` (`grantee_user_id`, `app_uid`, `id`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_pair'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_pair` (`owner_user_id`, `grantee_user_id`, `revoked_at`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_revoked'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_revoked` (`revoked_at`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_kv_share_handle_quota_indexes();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_quota_indexes;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Lets a holder's grant check read only the permissions it names. Guarded, so
|
||||
-- a replay does nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_u2u_holder_permission_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_to_user_permissions'
|
||||
AND INDEX_NAME = 'idx_user_to_user_permissions_holder_permission'
|
||||
) THEN
|
||||
ALTER TABLE `user_to_user_permissions` ADD INDEX `idx_user_to_user_permissions_holder_permission` (`holder_user_id`, `permission`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_u2u_holder_permission_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index;
|
||||
@@ -0,0 +1,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/>.
|
||||
|
||||
-- Uid and owner of each deleted app. Mirrors SQLite migration 0094 / MySQL
|
||||
-- mysql_mig_48.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deleted_apps (
|
||||
uid VARCHAR(40) PRIMARY KEY,
|
||||
owner_user_id INTEGER DEFAULT NULL
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Share levels on kv_share_handles. See
|
||||
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. Idempotent via
|
||||
-- IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS revoked_by VARCHAR(8) DEFAULT NULL;
|
||||
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS label VARCHAR(512) DEFAULT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee
|
||||
ON kv_share_handles (grantee_user_id, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_app
|
||||
ON kv_share_handles (app_uid);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Share-handle quota, listing and purge indexes. See
|
||||
-- sqlite/0096_kv-share-handle-quota-indexes.sql for what each serves.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner_live
|
||||
ON kv_share_handles (owner_user_id, revoked_at, app_uid);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner_app
|
||||
ON kv_share_handles (owner_user_id, app_uid, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee_app
|
||||
ON kv_share_handles (grantee_user_id, app_uid, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_pair
|
||||
ON kv_share_handles (owner_user_id, grantee_user_id, revoked_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_revoked
|
||||
ON kv_share_handles (revoked_at);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Lets a holder's grant check read only the permissions it names.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_permission
|
||||
ON user_to_user_permissions (holder_user_id, permission);
|
||||
@@ -0,0 +1,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/>.
|
||||
|
||||
-- Uid and owner of each deleted app. An origin-derived uid comes back when the
|
||||
-- origin is used again; on a hosted subdomain now registered to someone else,
|
||||
-- AuthService moves on to a fresh uid so the deleted app's data stays put.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `deleted_apps` (
|
||||
"uid" TEXT PRIMARY KEY NOT NULL,
|
||||
"owner_user_id" INTEGER DEFAULT NULL
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- What share levels need from `kv_share_handles`. The level itself is not a
|
||||
-- column: it is the root of the stored `permission`.
|
||||
--
|
||||
-- - `revoked_by` : `owner`, `holder` or `system` (the namespace app was
|
||||
-- deleted). Null while live, and on rows retired before
|
||||
-- this existed.
|
||||
-- - `label` : optional owner-set text the holder is shown.
|
||||
-- - the grantee index serves the holder's listing and their unshare, and
|
||||
-- the app index retiring a deleted app's handles.
|
||||
|
||||
ALTER TABLE `kv_share_handles` ADD COLUMN `revoked_by` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `kv_share_handles` ADD COLUMN `label` TEXT DEFAULT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee`
|
||||
ON `kv_share_handles` (`grantee_user_id`, `id`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_app`
|
||||
ON `kv_share_handles` (`app_uid`);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Indexes behind the share-handle quotas, the app-filtered listings and the
|
||||
-- retention purge on `kv_share_handles`.
|
||||
--
|
||||
-- - owner_live : the owner's live count, in total and per app, read off
|
||||
-- the index alone.
|
||||
-- - owner_app : the owner's listing narrowed to one app, in id order.
|
||||
-- - grantee_app : the holder's listing narrowed to one app, in id order.
|
||||
-- - pair : live handles one owner holds out to one holder, counted at
|
||||
-- mint and walked on revoke and unshare.
|
||||
-- - revoked : retired rows past retention, for the purge.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner_live`
|
||||
ON `kv_share_handles` (`owner_user_id`, `revoked_at`, `app_uid`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner_app`
|
||||
ON `kv_share_handles` (`owner_user_id`, `app_uid`, `id`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee_app`
|
||||
ON `kv_share_handles` (`grantee_user_id`, `app_uid`, `id`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_pair`
|
||||
ON `kv_share_handles` (`owner_user_id`, `grantee_user_id`, `revoked_at`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_revoked`
|
||||
ON `kv_share_handles` (`revoked_at`);
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Lets a holder's grant check read only the permissions it names.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_user_to_user_permissions_holder_permission`
|
||||
ON `user_to_user_permissions` (`holder_user_id`, `permission`);
|
||||
@@ -188,6 +188,47 @@ describe('DDBClient — item operations', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('puts only while its condition holds', async () => {
|
||||
const absent = {
|
||||
expression: 'attribute_not_exists(#pk)',
|
||||
names: { '#pk': 'pk' },
|
||||
};
|
||||
await client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 1 },
|
||||
{
|
||||
condition: absent,
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 2 },
|
||||
{
|
||||
condition: absent,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
|
||||
expect(
|
||||
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
|
||||
).toBe(1);
|
||||
|
||||
await client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 3 },
|
||||
{
|
||||
condition: {
|
||||
expression: '#v = :held',
|
||||
names: { '#v': 'v' },
|
||||
values: { ':held': 1 },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('fetches keys from several tables in one batch', async () => {
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
|
||||
@@ -501,6 +542,19 @@ describe('DDBClient — credentialled configuration', () => {
|
||||
).toThrow('requires both `access_key` and `secret_key`');
|
||||
});
|
||||
|
||||
it('accepts the credential chain in place of keys', async () => {
|
||||
const client = new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { useCredentialChain: true, region: 'us-west-2' },
|
||||
endpoint: 'http://127.0.0.1:1',
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
await expect(client.recreateClient()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips table creation on a managed deployment', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new DDBClient({
|
||||
|
||||
@@ -43,6 +43,10 @@ import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import {
|
||||
awsCredentialsFrom,
|
||||
type AwsCredentials,
|
||||
} from '../../util/awsCredentials.js';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
@@ -248,7 +252,19 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
async put<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
item: T,
|
||||
opts?: {
|
||||
/** Refused (`ConditionalCheckFailedException`) unless it holds. */
|
||||
condition?: {
|
||||
expression: string;
|
||||
names?: Record<string, string>;
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const condition = opts?.condition;
|
||||
const client = await this.#getDocumentClient();
|
||||
return sendRepairingValues(
|
||||
item,
|
||||
@@ -258,6 +274,23 @@ export class DDBClient extends PuterClient {
|
||||
TableName: table,
|
||||
Item: itemToWrite,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
...(condition
|
||||
? {
|
||||
ConditionExpression: condition.expression,
|
||||
...(condition.names
|
||||
? {
|
||||
ExpressionAttributeNames:
|
||||
condition.names,
|
||||
}
|
||||
: {}),
|
||||
...(condition.values
|
||||
? {
|
||||
ExpressionAttributeValues:
|
||||
condition.values,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
() => `a put to ${table}`,
|
||||
@@ -501,6 +534,8 @@ export class DDBClient extends PuterClient {
|
||||
scanIndexForward?: boolean;
|
||||
beginsWith?: { key: string; value: string };
|
||||
select?: 'COUNT';
|
||||
/** Attributes to return instead of the whole item. */
|
||||
projection?: string[];
|
||||
filter?: {
|
||||
expression: string;
|
||||
values?: Record<string, unknown>;
|
||||
@@ -541,6 +576,10 @@ export class DDBClient extends PuterClient {
|
||||
Object.assign(expressionAttributeValues, options.filter.values);
|
||||
Object.assign(expressionAttributeNames, options.filter.names);
|
||||
}
|
||||
const projection = (options?.projection ?? []).map((name) => {
|
||||
expressionAttributeNames[`#p_${name}`] = name;
|
||||
return `#p_${name}`;
|
||||
});
|
||||
|
||||
const command = new QueryCommand({
|
||||
TableName: table,
|
||||
@@ -555,6 +594,9 @@ export class DDBClient extends PuterClient {
|
||||
? { FilterExpression: options.filter.expression }
|
||||
: {}),
|
||||
...(options?.select ? { Select: options.select } : {}),
|
||||
...(projection.length > 0
|
||||
? { ProjectionExpression: projection.join(', ') }
|
||||
: {}),
|
||||
...(options?.scanIndexForward !== undefined
|
||||
? { ScanIndexForward: options.scanIndexForward }
|
||||
: {}),
|
||||
@@ -688,17 +730,15 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
#bindAwsClient() {
|
||||
const accessKeyId = this.#ddbConfig.aws?.access_key;
|
||||
const secretAccessKey = this.#ddbConfig.aws?.secret_key;
|
||||
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
const credentials = awsCredentialsFrom(this.#ddbConfig.aws);
|
||||
if (!credentials) {
|
||||
throw new Error(
|
||||
'DynamoDB aws config requires both `access_key` and `secret_key`',
|
||||
'DynamoDB aws config requires both `access_key` and `secret_key`, or `useCredentialChain`',
|
||||
);
|
||||
}
|
||||
|
||||
this.#bindClient({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
credentials,
|
||||
region: this.#ddbConfig.aws?.region || 'us-west-2',
|
||||
endpoint: this.#ddbConfig.endpoint,
|
||||
});
|
||||
@@ -720,7 +760,7 @@ export class DDBClient extends PuterClient {
|
||||
region,
|
||||
endpoint,
|
||||
}: {
|
||||
credentials: { accessKeyId: string; secretAccessKey: string };
|
||||
credentials: AwsCredentials;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
}) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
InMemorySpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
} from '@opentelemetry/sdk-trace-base';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
@@ -207,6 +213,122 @@ describe('EventClient', () => {
|
||||
await target.emitAndWait(`${key}.child`, {}, {});
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
describe('tracing', () => {
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new BasicTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
const spansFor = (eventKey: string) =>
|
||||
exporter
|
||||
.getFinishedSpans()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.name === 'event.emitAndWait' &&
|
||||
s.attributes['event.key'] === eventKey,
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
trace.setGlobalTracerProvider(provider);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await provider.shutdown();
|
||||
trace.disable();
|
||||
});
|
||||
|
||||
it('resolves without a span when nothing listens', async () => {
|
||||
const meta: { listener_failed?: boolean } = {};
|
||||
await expect(
|
||||
target.emitAndWait(`${key}.child`, {}, meta),
|
||||
).resolves.toBeUndefined();
|
||||
expect(meta).toEqual({});
|
||||
expect(spansFor(`${key}.child`)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('spans the wait whenever a listener matches, wildcards included', async () => {
|
||||
target.on(`${key}.*`, vi.fn());
|
||||
await expect(
|
||||
target.emitAndWait(`${key}.child`, {}, {}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(spansFor(`${key}.child`)).toHaveLength(1);
|
||||
|
||||
extensionStore.events[`${key}.ext`] = [vi.fn()];
|
||||
try {
|
||||
await target.emitAndWait(`${key}.ext`, {}, {});
|
||||
} finally {
|
||||
delete extensionStore.events[`${key}.ext`];
|
||||
}
|
||||
expect(spansFor(`${key}.ext`)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAndWaitConcurrently', () => {
|
||||
it('runs every listener at once, wildcards included, and awaits them all', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const contribute =
|
||||
(share: number) => async (_k: string, data: unknown) => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
inFlight--;
|
||||
(data as { total: number }).total += share;
|
||||
};
|
||||
target.on(`${key}.*`, contribute(1));
|
||||
target.on(`${key}.child`, contribute(10));
|
||||
target.on(`${key}.child`, contribute(100));
|
||||
|
||||
const data = { total: 0 };
|
||||
await target.emitAndWaitConcurrently(`${key}.child`, data, {});
|
||||
|
||||
expect(peak).toBe(3);
|
||||
expect(data.total).toBe(111);
|
||||
});
|
||||
|
||||
it('flags a failed exact-key listener and still awaits the rest', async () => {
|
||||
const errSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
let finished = false;
|
||||
target.on(key, () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
target.on(key, async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
finished = true;
|
||||
});
|
||||
const meta: Record<string, unknown> = {};
|
||||
await target.emitAndWaitConcurrently(key, {}, meta);
|
||||
expect(finished).toBe(true);
|
||||
expect(meta.listener_failed).toBe(true);
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
expect.any(Error),
|
||||
);
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('leaves the flag alone when only a wildcard listener fails', async () => {
|
||||
const errSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
target.on(`${key}.*`, () => {
|
||||
throw new Error('observer');
|
||||
});
|
||||
const meta: Record<string, unknown> = {};
|
||||
await target.emitAndWaitConcurrently(`${key}.child`, {}, meta);
|
||||
expect(meta.listener_failed).toBeUndefined();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('resolves at once with nobody listening', async () => {
|
||||
await expect(
|
||||
target.emitAndWaitConcurrently(`${key}.nobody`, {}, {}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('off', () => {
|
||||
|
||||
@@ -94,6 +94,7 @@ export class EventClient extends PuterClient {
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
if (!this.hasListeners(key)) return;
|
||||
// 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 () => {
|
||||
@@ -118,6 +119,43 @@ export class EventClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `emitAndWait`, but every matched listener runs at once, so none sees
|
||||
* another's mutation of `data` before all have finished. Only for events
|
||||
* whose listeners each add an independent share, written after their own
|
||||
* awaits (`const v = await x; data.n += v`).
|
||||
*/
|
||||
async emitAndWaitConcurrently<T extends keyof EventMap>(
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
const matched = this.#matchKeys(key).flatMap((matchKey) =>
|
||||
this.#listenersFor(matchKey).map((listener) => ({
|
||||
listener,
|
||||
// A wildcard listener observes the event; it does not implement it.
|
||||
isExact: matchKey === key,
|
||||
})),
|
||||
);
|
||||
if (matched.length === 0) return;
|
||||
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
|
||||
await Promise.all(
|
||||
matched.map(async ({ listener, isExact }) => {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
if (isExact) meta.listener_failed = true;
|
||||
console.error(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anything would run if `key` were emitted, wildcards included.
|
||||
*
|
||||
|
||||
@@ -137,6 +137,17 @@ export type EventMap = {
|
||||
};
|
||||
'app.new-icon': { app_uid: string; data_url: string };
|
||||
'app.opened': { app_uid: string; user_id: number; ts: number };
|
||||
/**
|
||||
* A user removed an app: its grants and worker sessions are gone. With
|
||||
* `deleteData`, the user asked for the app's data in their account to go
|
||||
* too. Awaited by the emitter.
|
||||
*/
|
||||
'app.uninstalled': {
|
||||
userId: number;
|
||||
userUuid: string;
|
||||
appUid: string;
|
||||
deleteData: boolean;
|
||||
};
|
||||
'app.rename': {
|
||||
app_uid: string;
|
||||
old_name: string;
|
||||
@@ -197,7 +208,7 @@ export type EventMap = {
|
||||
*/
|
||||
is_temp?: boolean;
|
||||
ip?: string | null;
|
||||
source?: 'oidc';
|
||||
source?: 'oidc' | 'team_provision';
|
||||
req?: unknown;
|
||||
data?: unknown;
|
||||
abuse?: unknown;
|
||||
@@ -257,7 +268,7 @@ export type EventMap = {
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
reputation?: number | null;
|
||||
source?: 'oidc';
|
||||
source?: 'oidc' | 'team_provision';
|
||||
requires_phone_verification: boolean;
|
||||
requires_card_verification: boolean;
|
||||
accepted: boolean;
|
||||
@@ -389,6 +400,9 @@ export type EventMap = {
|
||||
new_username: string;
|
||||
};
|
||||
'user.email-changed': { user_id: number; new_email: string };
|
||||
/** An account was suspended, or its suspension lifted. Local events. */
|
||||
'user.suspended': { userId: number };
|
||||
'user.unsuspended': { userId: number };
|
||||
// Fired after an account is torn down (self-serve, admin, or temp-user
|
||||
// logout cleanup). Listeners purge external state tied to the account —
|
||||
// e.g. the marketplace extension cancels the user's Stripe subscriptions.
|
||||
@@ -397,6 +411,8 @@ export type EventMap = {
|
||||
user_id: number;
|
||||
user_uuid?: string;
|
||||
stripe_customer_id?: string | null;
|
||||
/** Apps the account owned, which cascaded away with it. */
|
||||
appUids?: string[];
|
||||
};
|
||||
|
||||
// ---- Team billing ---- the trigger, never the charge ----
|
||||
@@ -459,7 +475,11 @@ export type EventMap = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
'storage.quota.bonus': { userId: number; extra: number };
|
||||
/**
|
||||
* Bytes to add to a user's storage allowance. Listeners run concurrently:
|
||||
* add to `extra` after your own awaits, never `extra += await x`.
|
||||
*/
|
||||
'storage.quota.bonus': { userId: number; userUuid?: string; extra: number };
|
||||
|
||||
// ---- Key-value ----
|
||||
/**
|
||||
@@ -486,13 +506,33 @@ export type EventMap = {
|
||||
noShareKeys?: string[];
|
||||
/** Events handler runs behind the write; see `Actor.handlerDepth`. */
|
||||
handlerDepth?: number;
|
||||
/**
|
||||
* The user who made the change. Differs from `userId` when a share
|
||||
* holder wrote into the owner's namespace.
|
||||
*/
|
||||
actingUserId?: number;
|
||||
/** Made by a worker session or an events handler; see `viaWorkerOf`. */
|
||||
viaWorker?: true;
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
* key enumeration is truncated for a large namespace, so the keys it saw
|
||||
* are not the keys it removed.
|
||||
*/
|
||||
'kv.flushed': { namespace: string; userId: number };
|
||||
'kv.flushed': {
|
||||
namespace: string;
|
||||
userId: number;
|
||||
actingUserId?: number;
|
||||
viaWorker?: true;
|
||||
};
|
||||
/**
|
||||
* Share handles stopped being addresses, whoever retired them. `userId` is
|
||||
* one user who could have subscribed through them, their owner or their
|
||||
* holder. Listeners end what was made through them even where another grant
|
||||
* still covers the region. Local, like `permission.revoked`: one region
|
||||
* settling covers every other.
|
||||
*/
|
||||
'kv.share.retired': { handles: string[]; userId: number };
|
||||
|
||||
// ---- Notifications ----
|
||||
/**
|
||||
@@ -512,6 +552,23 @@ export type EventMap = {
|
||||
/** Events handler runs behind whatever created it. */
|
||||
handlerDepth?: number;
|
||||
};
|
||||
/**
|
||||
* Every non-silent write or update, only on the node that wrote it. The
|
||||
* value is the listener's own copy of what the socket carries.
|
||||
*/
|
||||
'notif.written': {
|
||||
userId: number;
|
||||
uid: string;
|
||||
type: string;
|
||||
audience: string;
|
||||
/** The row's app; null when it names none or an update could not tell. */
|
||||
appUid: string | null;
|
||||
value: Record<string, unknown>;
|
||||
/** An earlier notification rewritten in place. */
|
||||
updated?: true;
|
||||
/** Names this one send, for a receipt asked about it. */
|
||||
sendId: string;
|
||||
};
|
||||
/**
|
||||
* One notification, addressed at whatever sockets each region holds for the
|
||||
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
|
||||
@@ -523,6 +580,28 @@ export type EventMap = {
|
||||
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
|
||||
response: unknown;
|
||||
};
|
||||
/**
|
||||
* How one live notification went, on the node that asked for its receipt:
|
||||
* from the desktop's sockets, or with `appUid` the app's own.
|
||||
*/
|
||||
'notif.delivery.settled': {
|
||||
sendId?: string;
|
||||
uid: string;
|
||||
userId: number;
|
||||
type: string;
|
||||
/** The app whose own sockets were asked; absent for the desktop's. */
|
||||
appUid?: string;
|
||||
outcome: NotifDeliveryOutcome;
|
||||
/** A socket in this region acknowledges the receipt. */
|
||||
ackCapable: boolean;
|
||||
/** No socket here is an older client that acknowledges nothing. */
|
||||
everySocketAcks: boolean;
|
||||
/** Every node here answered the ask, not just this one. */
|
||||
complete: boolean;
|
||||
/** A peer region holds a socket for the user. */
|
||||
peerRegions: boolean;
|
||||
responders: NotifDeliveryResponder[];
|
||||
};
|
||||
|
||||
// ---- Metering ----
|
||||
// Recurring charges are pure mechanism here: the metering service knows
|
||||
@@ -639,6 +718,13 @@ export type EventMap = {
|
||||
* reason as the events generation bump: a per-process cache.
|
||||
*/
|
||||
'outer.pubsub.share.ownerShared': { ownerUserId: number };
|
||||
/**
|
||||
* Share handles were retired or changed, so every process must drop its
|
||||
* cached copy. `outer.pubsub.*` because the cache is a per-process map.
|
||||
*/
|
||||
'outer.pubsub.kv.share.forget': { handles: string[] };
|
||||
/** The same, for every cached handle one owner holds out. */
|
||||
'outer.pubsub.kv.share.forgetOwner': { ownerUserId: number };
|
||||
'outer.fs.write-hash': { hash: string; uuid: string };
|
||||
/**
|
||||
* Cache keys the KV read cache must stop serving, because the entries
|
||||
@@ -664,7 +750,7 @@ export type EventMap = {
|
||||
// ---- Subdomains ----
|
||||
// `uid` is the row uuid — for `delete` it is the only surviving handle a
|
||||
// listener can use to find state keyed to the row after it is gone.
|
||||
'subdomain.delete': { subdomain: string; uid?: string };
|
||||
'subdomain.delete': { subdomain: string; uid?: string; userId: number };
|
||||
'subdomain.update': { subdomain: string; uid?: string };
|
||||
'site.htmlServed': {
|
||||
subdomain: string;
|
||||
@@ -675,6 +761,10 @@ export type EventMap = {
|
||||
requestHash?: string;
|
||||
mime: string;
|
||||
};
|
||||
// Asked for a request whose host is outside every configured domain. A
|
||||
// listener that recognises the host sets `result.subdomain` to the site
|
||||
// it serves; an unclaimed host is answered with a 404.
|
||||
'site.resolveHost': { host: string; result: { subdomain: string | null } };
|
||||
// Asked before a hosted file is streamed. A listener that owns the site
|
||||
// sets `result.allowed = false` to withhold the entry; the visitor then
|
||||
// sees the same 404 as for a missing file.
|
||||
@@ -931,6 +1021,15 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
|
||||
? Extract<EventKey, `${Prefix}.${string}`>
|
||||
: P & EventKey;
|
||||
|
||||
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
|
||||
export type NotifDeliveryOutcome =
|
||||
'received' | 'unreceived' | 'no-socket' | 'remote-only';
|
||||
|
||||
/** One socket's receipt for a live notification. */
|
||||
export interface NotifDeliveryResponder {
|
||||
visibilityState: 'visible' | 'hidden' | 'unknown';
|
||||
}
|
||||
|
||||
export type EventMetadata = {
|
||||
from_outside?: boolean;
|
||||
/** Set by `emitAndWait` when a listener threw, so a verdict hook can refuse. */
|
||||
|
||||
@@ -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 { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
@@ -24,7 +25,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { S3Client } from './S3Client';
|
||||
import { S3Client, withPresignHeadroom } from './S3Client';
|
||||
|
||||
const LOCAL_BUCKET = 'puter-local';
|
||||
|
||||
@@ -487,3 +488,54 @@ describe('S3Client — short reads during multipart', () => {
|
||||
expect(parts).toEqual([10, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withPresignHeadroom', () => {
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const creds = (msLeft?: number, id = 'a') => ({
|
||||
accessKeyId: id,
|
||||
secretAccessKey: 's',
|
||||
...(msLeft === undefined
|
||||
? {}
|
||||
: { expiration: new Date(Date.now() + msLeft) }),
|
||||
});
|
||||
|
||||
it('reports expiry an hour early so the SDK refreshes while an hour remains', async () => {
|
||||
const chain = vi.fn(async () => creds(5 * HOUR));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenCalledTimes(1);
|
||||
const left = identity.expiration!.getTime() - Date.now();
|
||||
expect(left).toBeGreaterThan(4 * HOUR - 1000);
|
||||
expect(left).toBeLessThanOrEqual(4 * HOUR);
|
||||
});
|
||||
|
||||
it('force-refreshes credentials that are inside the presign window', async () => {
|
||||
const chain = vi.fn(async (options?: { forceRefresh?: boolean }) =>
|
||||
options?.forceRefresh
|
||||
? creds(6 * HOUR, 'fresh')
|
||||
: creds(30 * 60 * 1000, 'stale'),
|
||||
);
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ forceRefresh: true }),
|
||||
);
|
||||
expect(identity.accessKeyId).toBe('fresh');
|
||||
});
|
||||
|
||||
it('leaves long-lived credentials alone', async () => {
|
||||
const chain = vi.fn(async () => creds(undefined));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenCalledTimes(1);
|
||||
expect(identity.expiration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never reports an expiry inside the SDK refresh margin', async () => {
|
||||
const chain = vi.fn(async () => creds(20 * 60 * 1000));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
const left = identity.expiration!.getTime() - Date.now();
|
||||
expect(left).toBeGreaterThan(5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,43 @@ const DEFAULT_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
const FAUXQS_SAFE_PUT_OBJECT_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||
const LEGACY_STORAGE_BUCKET = 'puter-local';
|
||||
|
||||
// A presigned URL stops working when the credentials that signed it expire.
|
||||
// Presigns last up to an hour, but the SDK only refreshes temporary
|
||||
// credentials five minutes before expiry.
|
||||
const PRESIGN_MAX_LIFETIME_MS = 60 * 60 * 1000;
|
||||
const SDK_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
||||
|
||||
type CredentialChain = ReturnType<typeof fromNodeProviderChain>;
|
||||
|
||||
/**
|
||||
* Keeps temporary credentials at least an hour from expiry when they sign. The
|
||||
* SDK is handed each identity with its expiration moved an hour early, so its
|
||||
* own refresh comes back here while an hour remains, and a credential already
|
||||
* inside that window is force-refreshed from the chain.
|
||||
*/
|
||||
export function withPresignHeadroom(chain: CredentialChain): CredentialChain {
|
||||
return async (options) => {
|
||||
let identity = await chain(options);
|
||||
const remaining = (i: typeof identity) =>
|
||||
i.expiration ? i.expiration.getTime() - Date.now() : Infinity;
|
||||
if (
|
||||
remaining(identity) <
|
||||
PRESIGN_MAX_LIFETIME_MS + SDK_REFRESH_MARGIN_MS
|
||||
) {
|
||||
identity = await chain({ ...options, forceRefresh: true });
|
||||
}
|
||||
if (!identity.expiration) return identity;
|
||||
// If even fresh credentials are short-lived, report an expiry just past
|
||||
// the SDK's margin rather than one in the past, which would make it
|
||||
// re-resolve on every request.
|
||||
const reported = Math.max(
|
||||
identity.expiration.getTime() - PRESIGN_MAX_LIFETIME_MS,
|
||||
Date.now() + SDK_REFRESH_MARGIN_MS + 60 * 1000,
|
||||
);
|
||||
return { ...identity, expiration: new Date(reported) };
|
||||
};
|
||||
}
|
||||
|
||||
type S3CommandSender = Pick<AwsS3Client, 'send'>;
|
||||
|
||||
export class S3Client extends PuterClient {
|
||||
@@ -83,7 +120,9 @@ export class S3Client extends PuterClient {
|
||||
|
||||
if (useCredentialChain) {
|
||||
this.useProviderChain = true;
|
||||
this.awsConfig = { credentials: fromNodeProviderChain() };
|
||||
this.awsConfig = {
|
||||
credentials: withPresignHeadroom(fromNodeProviderChain()),
|
||||
};
|
||||
this.partSize = 64 * 1024 * 1024;
|
||||
this.maxSingleUploadSize = 128 * 1024 * 1024;
|
||||
} else {
|
||||
|
||||
@@ -338,6 +338,42 @@ describe('AppController POST /rao', () => {
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps every open as a row, and counts one per account per hour', async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const app = await createApp(owner.actor);
|
||||
|
||||
const open = async (user: { actor: Actor; userId: number }) => {
|
||||
const { res } = makeRes();
|
||||
await withActor(user.actor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
'/rao',
|
||||
makeReq({ body: { app_uid: app.uid }, actor: user.actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) await open(owner);
|
||||
await open(other);
|
||||
await server.controllers.apps.drainPendingAppOpens();
|
||||
|
||||
// Every open is still a row: the recent-apps list orders on them.
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT `user_id` FROM `app_opens` WHERE `app_uid` = ?',
|
||||
[app.uid],
|
||||
)) as Array<{ user_id: number }>;
|
||||
expect(rows).toHaveLength(6);
|
||||
|
||||
// The count is accounts-per-hour, so repeats do not move it.
|
||||
const stats = await server.stores.app.getAppsStats([app.uid]);
|
||||
expect(stats.get(app.uid)).toMatchObject({
|
||||
open_count: 2,
|
||||
user_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
// `app_opens` is analytics: the response carries nothing derived from it
|
||||
// and the client never awaits the post. Holding the response open for a
|
||||
// primary write put that write's latency inside the app launch it was
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
describe('auth elevation cookies over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it.each(['bearer', 'cookie'])(
|
||||
'API host returns a usable token without setting cookies (%s auth)',
|
||||
async (credential) => {
|
||||
const { token, password, username } = env.users.user;
|
||||
const res = await fetch(new URL('/auth/elevate', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(credential === 'cookie'
|
||||
? { cookie: `puter_auth_token=${token}` }
|
||||
: { authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
const body = (await res.json()) as {
|
||||
elevated: boolean;
|
||||
token: string;
|
||||
};
|
||||
expect(body.elevated).toBe(true);
|
||||
expect(typeof body.token).toBe('string');
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const { verifyStepUpSession } =
|
||||
await import('../../core/http/middleware/stepUpSession.js');
|
||||
expect(
|
||||
verifyStepUpSession(
|
||||
{
|
||||
headers: { 'x-puter-elevation': body.token },
|
||||
actor: { user: { uuid: user!.uuid } },
|
||||
} as never,
|
||||
{ tokenService: env.server.services.token },
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('GUI host sets the elevation cookie and omits the raw token for cookie auth', async () => {
|
||||
const { token, password } = env.users.user;
|
||||
const res = await fetch(new URL('/auth/elevate', env.origin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
cookie: `puter_auth_token=${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('set-cookie')).toContain('puter_elevated=');
|
||||
expect(await res.json()).toEqual({ elevated: true });
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { TOTP } from 'otpauth';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
|
||||
@@ -424,7 +423,7 @@ describe('2FA enrollment over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -561,56 +560,6 @@ describe('2FA enrollment over HTTP', () => {
|
||||
expect((await otpState(account.userId)).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a seat its team holds for 2FA enroll through the same gate', async () => {
|
||||
const owner = await makeAccount();
|
||||
// The rule can only be turned on by an owner who has 2FA.
|
||||
await env.server.stores.user.update(owner.userId, { otp_enabled: 1 });
|
||||
await env.server.stores.user.invalidateById(owner.userId);
|
||||
const team = await env.server.services.team.createTeam(owner.userId, {
|
||||
name: 'Acme',
|
||||
});
|
||||
const seatName = `seat_${uniq()}`;
|
||||
const { userId, temporaryPassword } =
|
||||
await env.server.services.team.provisionAccount(
|
||||
team.uid,
|
||||
owner.userId,
|
||||
{ username: seatName },
|
||||
);
|
||||
await env.server.services.team.updateTeam(team.uid, owner.userId, {
|
||||
require2fa: true,
|
||||
});
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getById(userId))!,
|
||||
);
|
||||
|
||||
// First sign-in replaces the temporary password.
|
||||
const changed = await protectedPost(
|
||||
'/user-protected/change-password',
|
||||
token,
|
||||
{ password: temporaryPassword, new_pass: 'seat-own-password' },
|
||||
);
|
||||
expect(changed.status).toBe(200);
|
||||
|
||||
const held = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(held.status).toBe(403);
|
||||
expect(await held.text()).toContain('two_factor_required');
|
||||
|
||||
const res = await setup(token, { password: 'seat-own-password' });
|
||||
expect(res.status).toBe(200);
|
||||
const { secret } = (await res.json()) as { secret: string };
|
||||
const enabled = await configure(token, 'enable', {
|
||||
code: liveTotp(seatName, secret),
|
||||
});
|
||||
expect(enabled.status).toBe(200);
|
||||
expect((await otpState(userId)).enabled).toBe(true);
|
||||
|
||||
const released = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(released.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -680,3 +629,89 @@ describe('temp-account reauth over HTTP', () => {
|
||||
expect(who.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session token helpers over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
}, 120_000);
|
||||
|
||||
const signupTemp = async () => {
|
||||
const res = await fetch(new URL('/signup', env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ is_temp: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as { token: string; user: { uuid: string } };
|
||||
};
|
||||
|
||||
const get = (path: string, token: string) =>
|
||||
fetch(new URL(path, env.origin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const claims = (token: string) =>
|
||||
env.server.services.token.verify('auth', token) as {
|
||||
type: string;
|
||||
user_uid: string;
|
||||
session_uid: string;
|
||||
};
|
||||
|
||||
it('mint tokens for the session the request authenticated with', async () => {
|
||||
const { token, user } = await signupTemp();
|
||||
const session = claims(token);
|
||||
const row = await env.server.stores.user.getByUuid(user.uuid);
|
||||
const getById = vi.spyOn(env.server.stores.user, 'getById');
|
||||
try {
|
||||
const gui = await get('/get-gui-token', token);
|
||||
expect(gui.status).toBe(200);
|
||||
const { token: guiToken } = (await gui.json()) as {
|
||||
token: string;
|
||||
};
|
||||
expect(claims(guiToken)).toMatchObject({
|
||||
type: 'gui',
|
||||
user_uid: user.uuid,
|
||||
session_uid: session.session_uid,
|
||||
});
|
||||
|
||||
const sync = await get('/session/sync-cookie', token);
|
||||
expect(sync.status).toBe(204);
|
||||
const cookie = sync.headers.get('set-cookie') ?? '';
|
||||
const cookieToken = /puter_auth_token=([^;]+)/.exec(cookie)?.[1];
|
||||
expect(claims(cookieToken!)).toMatchObject({
|
||||
type: 'session',
|
||||
user_uid: user.uuid,
|
||||
session_uid: session.session_uid,
|
||||
});
|
||||
|
||||
// The user is the row token auth already read.
|
||||
expect(getById.mock.calls.filter(([id]) => id === row!.id)).toEqual(
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
getById.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuse a deleted account's session before either handler runs", async () => {
|
||||
const { token, user } = await signupTemp();
|
||||
const row = await env.server.stores.user.getByUuid(user.uuid);
|
||||
await env.server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
|
||||
row!.id,
|
||||
]);
|
||||
await env.server.stores.user.markDeleted(row!);
|
||||
|
||||
expect((await get('/get-gui-token', token)).status).toBe(401);
|
||||
const sync = await get('/session/sync-cookie', token);
|
||||
expect(sync.status).toBe(401);
|
||||
expect(sync.headers.get('set-cookie') ?? '').not.toContain(
|
||||
'puter_auth_token=',
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -33,7 +33,7 @@ import {
|
||||
hasVerifiedPhone,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import { isAppActor, isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
consumeRouteRateLimit,
|
||||
@@ -88,13 +88,16 @@ import {
|
||||
import { promoteToVerifiedGroup } from '../../util/userProvisioning.js';
|
||||
import {
|
||||
assertBoundedManageGrant,
|
||||
assertNewShareSegments,
|
||||
assertPrefixFitsAccess,
|
||||
isKvSharePermission,
|
||||
} from '../../services/events/kvShares.js';
|
||||
} from '../../services/kvShare/kvShares.js';
|
||||
import {
|
||||
APP_DATA_PERMISSION_PREFIX,
|
||||
appDataSharingAllowed,
|
||||
parseAppDataPermission,
|
||||
} from '../../services/permission/appDataScopes.js';
|
||||
import { parseAppGrantablePermission } from '../../services/permission/appGrantable.js';
|
||||
import {
|
||||
assertCreatablePath,
|
||||
isOwnHomePath,
|
||||
@@ -111,6 +114,7 @@ import { normalizeAbsolutePath } from '../../services/fs/resolveNode.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { startWebSession } from './webSession.js';
|
||||
import { completeOidcLoginReturn } from './oidcLoginReturn.js';
|
||||
|
||||
const FINGERPRINT_MAX_LENGTH = 128;
|
||||
// One consent prompt covers a handful of scopes at most. The cap keeps a
|
||||
@@ -595,6 +599,7 @@ export class AuthController extends PuterController {
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
oidcContinuation?: unknown;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
@@ -628,7 +633,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user);
|
||||
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
|
||||
}
|
||||
|
||||
// -- Login: recovery code ----------------------------------------
|
||||
@@ -664,6 +669,7 @@ export class AuthController extends PuterController {
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
oidcContinuation?: unknown;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
@@ -688,6 +694,11 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
await this.#assertSecondFactorAttemptsRemain(user.uuid);
|
||||
if (typeof code !== 'string') {
|
||||
throw new HttpError(400, 'code must be a string.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const hashed = hashRecoveryCode(code);
|
||||
const codes = ((user.otp_recovery_codes as string) || '')
|
||||
.split(',')
|
||||
@@ -700,15 +711,15 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
await this.#consumeOtpLoginToken(token);
|
||||
|
||||
// Consume the recovery code
|
||||
codes.splice(idx, 1);
|
||||
await this.stores.user.update(user.id, {
|
||||
otp_recovery_codes: codes.join(','),
|
||||
});
|
||||
if (!(await this.stores.user.consumeOtpRecoveryCode(user.id, hashed))) {
|
||||
await this.#recordSecondFactorFailure(user.uuid);
|
||||
res.json({ proceed: false });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user);
|
||||
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
|
||||
}
|
||||
|
||||
// -- Signup ------------------------------------------------------
|
||||
@@ -1932,7 +1943,7 @@ export class AuthController extends PuterController {
|
||||
// A seat's address is admin-supplied and never verified, so whoever
|
||||
// holds that inbox could take the seat over. Its recovery channel is
|
||||
// the team admin's password reset, not this one.
|
||||
if (await this.stores.team.getOrgSeat(user.id)) {
|
||||
if (await this.stores.team?.getOrgSeat(user.id)) {
|
||||
res.json({ message: genericMessage });
|
||||
return;
|
||||
}
|
||||
@@ -2133,10 +2144,16 @@ export class AuthController extends PuterController {
|
||||
const password_hash = await bcrypt.hash(password, 8);
|
||||
let result;
|
||||
try {
|
||||
result = await this.clients.db.write(
|
||||
'UPDATE `user` SET `password` = ?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL, ' +
|
||||
'`requires_password_change` = 0, `temp_password_expires_at` = NULL WHERE `id` = ? AND `pass_recovery_token` = ?',
|
||||
[password_hash, user.id, decoded.token],
|
||||
result = await this.stores.user.update(
|
||||
user.id,
|
||||
{
|
||||
password: password_hash,
|
||||
pass_recovery_token: null,
|
||||
change_email_confirm_token: null,
|
||||
requires_password_change: false,
|
||||
temp_password_expires_at: null,
|
||||
},
|
||||
{ expectedRecoveryToken: decoded.token },
|
||||
);
|
||||
} catch (e) {
|
||||
if (!isOwnedEmailConflict(e)) throw e;
|
||||
@@ -2152,22 +2169,16 @@ export class AuthController extends PuterController {
|
||||
{ legacyCode: 'email_already_in_use' as never },
|
||||
);
|
||||
}
|
||||
const affected =
|
||||
(result as { affectedRows?: number; changes?: number })
|
||||
?.affectedRows ??
|
||||
(result as { affectedRows?: number; changes?: number })?.changes ??
|
||||
0;
|
||||
if (affected === 0) {
|
||||
if (!result) {
|
||||
throw new HttpError(400, 'Token has already been used.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
await this.stores.user.invalidateById(user.id);
|
||||
// Best effort: the password is already committed, and an audit write
|
||||
// must not skip the eviction below.
|
||||
await this.services.team
|
||||
.recordPasswordSelfChange(user.id as number)
|
||||
.catch((err: unknown) => {
|
||||
?.recordPasswordSelfChange(user.id as number)
|
||||
?.catch((err: unknown) => {
|
||||
console.warn('[team] password self-change audit failed:', err);
|
||||
});
|
||||
|
||||
@@ -2222,8 +2233,8 @@ export class AuthController extends PuterController {
|
||||
temp_password_expires_at: null,
|
||||
});
|
||||
await this.services.team
|
||||
.recordPasswordSelfChange(user.id)
|
||||
.catch((err: unknown) => {
|
||||
?.recordPasswordSelfChange(user.id)
|
||||
?.catch((err: unknown) => {
|
||||
console.warn('[team] password self-change audit failed:', err);
|
||||
});
|
||||
|
||||
@@ -2257,7 +2268,7 @@ export class AuthController extends PuterController {
|
||||
// A provisioned account's name belongs to the team that made it: the
|
||||
// console lists its members by username and the audit log records them
|
||||
// by username, so a self-service rename would desync both.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this username. Ask a team admin to change it.',
|
||||
@@ -2351,6 +2362,34 @@ export class AuthController extends PuterController {
|
||||
res.json({ username: new_username });
|
||||
}
|
||||
|
||||
#requireAccountTwoFactor(user: UserRow, code: unknown): void {
|
||||
if (user.otp_enabled) {
|
||||
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Enter the 6-digit code from your authenticator app.',
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
fields: { factor: 'otp' },
|
||||
},
|
||||
);
|
||||
}
|
||||
let verified = false;
|
||||
try {
|
||||
verified =
|
||||
typeof user.otp_secret === 'string' &&
|
||||
verifyOtp(user.username, user.otp_secret, code);
|
||||
} catch {
|
||||
// Invalid or unavailable secrets cannot authorize a change.
|
||||
}
|
||||
if (!verified)
|
||||
throw new HttpError(401, 'Incorrect code.', {
|
||||
legacyCode: 'code_mismatch',
|
||||
fields: { factor: 'otp' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/user-protected/change-email', {
|
||||
userProtected: true,
|
||||
rateLimit: {
|
||||
@@ -2363,7 +2402,7 @@ export class AuthController extends PuterController {
|
||||
async handleChangeEmail(req: Request, res: Response): Promise<void> {
|
||||
// The address is where admin-issued credentials and team notices go;
|
||||
// same reasoning as the username and deletion guards above.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this address. Ask a team admin to change it.',
|
||||
@@ -2371,6 +2410,17 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const user =
|
||||
req.userProtected?.user ??
|
||||
(await this.stores.user.getById(req.actor!.user.id!, {
|
||||
force: true,
|
||||
}));
|
||||
if (!user)
|
||||
throw new HttpError(404, 'User not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
this.#requireAccountTwoFactor(user, req.body?.code);
|
||||
|
||||
const { new_email } = req.body ?? {};
|
||||
if (!new_email || typeof new_email !== 'string') {
|
||||
throw new HttpError(400, '`new_email` is required', {
|
||||
@@ -2412,6 +2462,7 @@ export class AuthController extends PuterController {
|
||||
token: confirm_token,
|
||||
user_id: req.actor!.user.id,
|
||||
purpose: 'change-email',
|
||||
twoFactorVerified: Boolean(user.otp_enabled),
|
||||
},
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
@@ -2429,10 +2480,7 @@ export class AuthController extends PuterController {
|
||||
console.warn('[change-email] new-address email failed:', e);
|
||||
}
|
||||
// Notify the old address too
|
||||
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
||||
force: true,
|
||||
});
|
||||
if (user?.email) {
|
||||
if (user.email) {
|
||||
try {
|
||||
await (
|
||||
this.clients.email as unknown as {
|
||||
@@ -2489,19 +2537,21 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT * FROM `user` WHERE `change_email_confirm_token` = ? ORDER BY `id` ASC LIMIT 1',
|
||||
[decoded.token],
|
||||
)) as Array<Record<string, unknown>>;
|
||||
const user = rows[0] as
|
||||
| {
|
||||
id: number;
|
||||
email_confirmed?: number | boolean;
|
||||
password?: string | null;
|
||||
unconfirmed_change_email?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!user || !user.unconfirmed_change_email) {
|
||||
const user =
|
||||
typeof decoded.user_id === 'number' &&
|
||||
Number.isInteger(decoded.user_id) &&
|
||||
decoded.user_id > 0
|
||||
? await this.stores.user.getById(decoded.user_id, {
|
||||
force: true,
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
!user ||
|
||||
user.change_email_confirm_token !== decoded.token ||
|
||||
typeof user.unconfirmed_change_email !== 'string' ||
|
||||
!user.unconfirmed_change_email ||
|
||||
(user.otp_enabled && decoded.twoFactorVerified !== true)
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid or expired token.', {
|
||||
legacyCode: 'token_expired' as never,
|
||||
});
|
||||
@@ -2950,6 +3000,45 @@ export class AuthController extends PuterController {
|
||||
return typeof permission === 'string' && permission ? [permission] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses any entry outside the families an app can be granted. Anything
|
||||
* else is a row the backend never checks for an app, or one wider than the
|
||||
* consent prompt can describe.
|
||||
*/
|
||||
async #assertAppGrantable(
|
||||
actor: Actor,
|
||||
appIdentifier: string,
|
||||
list: string[],
|
||||
): Promise<void> {
|
||||
const refuse = (entry: string) =>
|
||||
new HttpError(
|
||||
400,
|
||||
`Permission cannot be granted to an app: ${entry}`,
|
||||
{ legacyCode: 'permission_not_grantable' },
|
||||
);
|
||||
let appUid: string | undefined;
|
||||
for (const entry of list) {
|
||||
// A delegation over a whole key-value namespace has no bounded
|
||||
// description; refused with its own code.
|
||||
assertBoundedManageGrant(entry);
|
||||
const parsed = parseAppGrantablePermission(entry, actor.user!.uuid);
|
||||
if (!parsed) throw refuse(entry);
|
||||
if (parsed.family !== 'kvShare') continue;
|
||||
// Held to what a new share's prefix may be, so a consent never
|
||||
// covers shares that could not be made.
|
||||
assertNewShareSegments(parsed.segments);
|
||||
assertPrefixFitsAccess(
|
||||
parsed.ownerUuid,
|
||||
parsed.appUid,
|
||||
parsed.segments.join(':'),
|
||||
parsed.level,
|
||||
);
|
||||
// An app shares regions of its own namespace only.
|
||||
appUid ??= (await this.stores.app.resolveApp(appIdentifier))?.uid;
|
||||
if (parsed.appUid !== appUid) throw refuse(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a cross-app data grant: the target must exist, must not have opted
|
||||
* out of sharing, and must be named. Also creates the target's AppData
|
||||
@@ -3128,6 +3217,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
// 400 on a bad value, before anything else runs. Absent means create.
|
||||
const create = parseCreateFlag(req.body?.create);
|
||||
await this.#assertAppGrantable(req.actor!, app_uid, list);
|
||||
|
||||
// Validate every entry before writing or creating anything, so a bad
|
||||
// one in the list cannot leave a partially-granted set behind: the
|
||||
@@ -3140,10 +3230,6 @@ export class AuthController extends PuterController {
|
||||
kind: FsCreateKind;
|
||||
}> = [];
|
||||
for (const entry of list) {
|
||||
// A delegation over a whole key-value namespace has no bounded
|
||||
// description, so it is refused where it is asked for rather than
|
||||
// prompted for and then refused at use.
|
||||
assertBoundedManageGrant(entry);
|
||||
await this.#prepareAppDataGrant(req.actor!, entry);
|
||||
const plan = await this.#planFsPathCreate(
|
||||
req.actor!,
|
||||
@@ -3241,7 +3327,8 @@ export class AuthController extends PuterController {
|
||||
})
|
||||
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body ?? {};
|
||||
const { origin, permission, permissions, meta } = req.body ?? {};
|
||||
const { origin, permission, permissions, meta, deleteData } =
|
||||
req.body ?? {};
|
||||
this.#validateAppPermissionParams({
|
||||
app_uid,
|
||||
origin,
|
||||
@@ -3257,14 +3344,25 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
if (deleteData !== undefined && typeof deleteData !== 'boolean') {
|
||||
throw new HttpError(400, '`deleteData` must be a boolean', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
if (deleteData && permission !== '*') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`deleteData` only applies when `permission` is "*"',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// Deliberately not gated by the target's sharing flag: a user must
|
||||
// always be able to withdraw a grant, whatever the target now says.
|
||||
if (permission === '*') {
|
||||
await this.services.permission.revokeUserAppAll(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
meta ?? undefined,
|
||||
);
|
||||
await this.services.auth.uninstallApp(req.actor!, app_uid, {
|
||||
deleteData: deleteData === true,
|
||||
meta: meta ?? undefined,
|
||||
});
|
||||
} else {
|
||||
for (const entry of list) {
|
||||
await this.services.permission.revokeUserAppPermission(
|
||||
@@ -3344,11 +3442,28 @@ export class AuthController extends PuterController {
|
||||
const unique = [...new Set(permissions)] as string[];
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
// `fs:` reaches down a tree, and only ACL walks that.
|
||||
// A share grant names its owner and prefix, which its holder is never
|
||||
// told, so asking about one would confirm a guess. `manage:` arms are
|
||||
// the app's own consent and stay answerable.
|
||||
const answerable: string[] = [];
|
||||
for (const permission of unique) {
|
||||
if (
|
||||
typeof permission === 'string' &&
|
||||
isKvSharePermission(permission)
|
||||
)
|
||||
result[permission] = false;
|
||||
else answerable.push(permission);
|
||||
}
|
||||
|
||||
// `fs:` reaches down a tree, and only ACL walks that. An app asking
|
||||
// about itself gets the same answer, so a grant on a parent folder
|
||||
// doesn't read as missing and prompt again.
|
||||
const viaAcl = new Set(
|
||||
app_uid ? unique.filter((p) => aclAnswerableFsPermission(p)) : [],
|
||||
app_uid || isAppActor(actor)
|
||||
? answerable.filter((p) => aclAnswerableFsPermission(p))
|
||||
: [],
|
||||
);
|
||||
const viaScan = unique.filter((p) => !viaAcl.has(p));
|
||||
const viaScan = answerable.filter((p) => !viaAcl.has(p));
|
||||
|
||||
let granted: Map<string, boolean>;
|
||||
try {
|
||||
@@ -3744,6 +3859,7 @@ export class AuthController extends PuterController {
|
||||
APP_AUTHENTICATED_FLAG,
|
||||
{},
|
||||
{},
|
||||
app,
|
||||
);
|
||||
|
||||
const tokenPromise: Promise<{ token: string; expiresAt?: number }> =
|
||||
@@ -3753,6 +3869,7 @@ export class AuthController extends PuterController {
|
||||
token: await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
app,
|
||||
),
|
||||
}))();
|
||||
|
||||
@@ -3855,6 +3972,7 @@ export class AuthController extends PuterController {
|
||||
result.token = await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app!.uid,
|
||||
app!,
|
||||
);
|
||||
}
|
||||
res.json(result);
|
||||
@@ -4074,7 +4192,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
if (action === 'enable') {
|
||||
// A seat has no email by design, so this would bar it forever.
|
||||
const seat = await this.stores.team.getOrgSeat(user.id);
|
||||
const seat = await this.stores.team?.getOrgSeat(user.id);
|
||||
if (!user.email_confirmed && !seat) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
@@ -4148,7 +4266,7 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
|
||||
const seat = await this.stores.team.getOrgSeat(user.id);
|
||||
const seat = await this.stores.team?.getOrgSeat(user.id);
|
||||
if (seat && Number(seat.require_2fa) === 1) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
@@ -4157,6 +4275,28 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const code = req.body?.code;
|
||||
if (
|
||||
user.otp_enabled &&
|
||||
typeof code === 'string' &&
|
||||
/^[A-Z0-9]{8}$/.test(code)
|
||||
) {
|
||||
await this.#assertSecondFactorAttemptsRemain(user.uuid);
|
||||
const consumed = await this.stores.user.consumeOtpRecoveryCode(
|
||||
user.id,
|
||||
hashRecoveryCode(code),
|
||||
);
|
||||
if (!consumed) {
|
||||
await this.#recordSecondFactorFailure(user.uuid);
|
||||
throw new HttpError(401, 'Incorrect code.', {
|
||||
legacyCode: 'code_mismatch',
|
||||
fields: { factor: 'otp' },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.#requireAccountTwoFactor(user, code);
|
||||
}
|
||||
|
||||
await this.stores.user.update(user.id, {
|
||||
otp_enabled: false,
|
||||
otp_recovery_codes: null,
|
||||
@@ -4234,13 +4374,10 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(400, 'No session bound to this actor', {
|
||||
legacyCode: 'session_required' as never,
|
||||
});
|
||||
const user = await this.stores.user.getById(req.actor.user.id!);
|
||||
if (!user)
|
||||
throw new HttpError(404, 'User not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
// Token auth built this actor from the user's row in this request, and
|
||||
// a deleted user fails it.
|
||||
const guiToken = this.services.auth.createGuiToken(
|
||||
user,
|
||||
req.actor.user as UserRow,
|
||||
req.actor.session.uid,
|
||||
);
|
||||
res.json({ token: guiToken });
|
||||
@@ -4270,13 +4407,9 @@ export class AuthController extends PuterController {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
const user = await this.stores.user.getById(req.actor.user.id!);
|
||||
if (!user) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
// As in `/get-gui-token`: the actor is the user's row as auth read it.
|
||||
const sessionToken = this.services.auth.createSessionTokenForSession(
|
||||
user,
|
||||
req.actor.user as UserRow,
|
||||
req.actor.session.uid,
|
||||
);
|
||||
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
||||
@@ -4372,11 +4505,14 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
const token = signStepUpToken(this.services.token, user as never);
|
||||
res.cookie(
|
||||
STEP_UP_COOKIE_NAME,
|
||||
token,
|
||||
stepUpCookieOptions(this.config),
|
||||
);
|
||||
const isApiHost = req.subdomains?.at(-1) === 'api';
|
||||
if (!isApiHost) {
|
||||
res.cookie(
|
||||
STEP_UP_COOKIE_NAME,
|
||||
token,
|
||||
stepUpCookieOptions(this.config),
|
||||
);
|
||||
}
|
||||
|
||||
// A browser reads its elevation back from the httpOnly cookie and never
|
||||
// needs the raw value; handing it to page JS would put the second factor
|
||||
@@ -4385,7 +4521,9 @@ export class AuthController extends PuterController {
|
||||
// password/TOTP — this only avoids needless exposure, it isn't a gate.
|
||||
const cookieName = this.config.cookie_name ?? 'puter_token';
|
||||
const usedSessionCookie =
|
||||
!!req.token && req.token === req.cookies?.[cookieName];
|
||||
!isApiHost &&
|
||||
!!req.token &&
|
||||
req.token === req.cookies?.[cookieName];
|
||||
res.json(
|
||||
usedSessionCookie ? { elevated: true } : { elevated: true, token },
|
||||
);
|
||||
@@ -4406,7 +4544,7 @@ export class AuthController extends PuterController {
|
||||
const userId = req.actor!.user.id!;
|
||||
// The team owns the account and is billed for it; only they may close
|
||||
// it, through the console that keeps the audit trail.
|
||||
if (await this.stores.team.getOrgSeat(userId)) {
|
||||
if (await this.stores.team?.getOrgSeat(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team owns this account. Ask a team admin to remove it.',
|
||||
@@ -4416,7 +4554,7 @@ export class AuthController extends PuterController {
|
||||
// The other direction: deleting an owner sets `owner_user_id` NULL and
|
||||
// cascades their membership, leaving a team no one can administer —
|
||||
// not even an admin, since every team route resolves by membership.
|
||||
if (await this.stores.team.countOwned(userId)) {
|
||||
if (await this.stores.team?.countOwned(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Delete the teams you own before closing this account.',
|
||||
@@ -4425,7 +4563,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
// A deleted team's seats are suspended, not gone, and only its owner
|
||||
// can retire them.
|
||||
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
|
||||
if (await this.stores.team?.countOwnedTeamsWithAccounts(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Close the accounts your teams created before closing this one.',
|
||||
@@ -4578,7 +4716,15 @@ export class AuthController extends PuterController {
|
||||
req: Request,
|
||||
res: Response,
|
||||
user: UserRow,
|
||||
oidcContinuation?: unknown,
|
||||
): Promise<void> {
|
||||
const redirectUrl =
|
||||
oidcContinuation === undefined
|
||||
? undefined
|
||||
: completeOidcLoginReturn(res, user, oidcContinuation, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
});
|
||||
const gui_token = await startWebSession(req, res, user, {
|
||||
config: this.config,
|
||||
auth: this.services.auth,
|
||||
@@ -4604,6 +4750,7 @@ export class AuthController extends PuterController {
|
||||
next_step: 'complete',
|
||||
token: gui_token,
|
||||
user: details,
|
||||
...(redirectUrl ? { redirectUrl } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { TOTP } from 'otpauth';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
|
||||
describe('account change second-factor verification', () => {
|
||||
let env: PuterTestEnv;
|
||||
const password = 'email-change-password';
|
||||
const links = new Map<string, string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
vi.spyOn(env.server.clients.email, 'send').mockImplementation(
|
||||
async (to, template, values) => {
|
||||
if (template === 'email_verification_link')
|
||||
links.set(to, String(values?.link));
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const account = async (enabled = true, oidcOnly = false) => {
|
||||
const username = `email2fa_${uuidv4().slice(0, 8)}`;
|
||||
const credentials = await createTestUser(env.server, {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
const otp = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
label: username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
});
|
||||
await env.server.stores.user.update(user.id, {
|
||||
email: `${username}@example.com`,
|
||||
email_confirmed: true,
|
||||
otp_enabled: enabled,
|
||||
otp_secret: otp.secret.base32,
|
||||
...(oidcOnly ? { password: null } : {}),
|
||||
});
|
||||
return { ...credentials, id: user.id, uuid: user.uuid, otp };
|
||||
};
|
||||
|
||||
const change = (
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
revalidation?: string,
|
||||
path = '/user-protected/change-email',
|
||||
) =>
|
||||
fetch(new URL(path, env.origin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
cookie: `puter_auth_token=${token}${revalidation ? `; puter_revalidation=${revalidation}` : ''}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it.each([undefined, 'invalid-code', 123456, {}])(
|
||||
'rejects an enabled account with code %j before staging a change',
|
||||
async (code) => {
|
||||
const user = await account();
|
||||
const response = await change(user.token, {
|
||||
password,
|
||||
new_email: '[email protected]',
|
||||
code,
|
||||
});
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
expect(await response.json()).toMatchObject({ factor: 'otp' });
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.change_email_confirm_token).toBeFalsy();
|
||||
expect(after!.unconfirmed_change_email).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an incorrect six-digit code without staging the address', async () => {
|
||||
const user = await account();
|
||||
const code = ['000000', '000001', '000002', '000003'].find(
|
||||
(token) => user.otp.validate({ token, window: 1 }) === null,
|
||||
);
|
||||
const response = await change(user.token, {
|
||||
password,
|
||||
new_email: '[email protected]',
|
||||
code,
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: 'code_mismatch',
|
||||
factor: 'otp',
|
||||
});
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, { force: true }))!
|
||||
.change_email_confirm_token,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a change when the enabled account secret is unavailable', async () => {
|
||||
const user = await account();
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_secret: 'invalid!',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await change(user.token, {
|
||||
password,
|
||||
new_email: '[email protected]',
|
||||
code: '000000',
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it('requires a code even after OIDC revalidation', async () => {
|
||||
const user = await account(true, true);
|
||||
const proof = env.server.services.token.sign(
|
||||
'oidc-state',
|
||||
{
|
||||
user_uuid: user.uuid,
|
||||
purpose: 'revalidate',
|
||||
},
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await change(
|
||||
user.token,
|
||||
{ new_email: '[email protected]' },
|
||||
proof,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await change(
|
||||
user.token,
|
||||
{
|
||||
new_email: '[email protected]',
|
||||
code: user.otp.generate(),
|
||||
},
|
||||
proof,
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'completes the change with the required factors (2FA enabled: %s)',
|
||||
async (enabled) => {
|
||||
const user = await account(enabled);
|
||||
const newEmail = `${uuidv4()}@example.com`;
|
||||
expect(
|
||||
(
|
||||
await change(user.token, {
|
||||
password,
|
||||
new_email: newEmail,
|
||||
...(enabled ? { code: user.otp.generate() } : {}),
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const link = links.get(newEmail)!;
|
||||
expect(link).toBeTruthy();
|
||||
expect((await fetch(link, { redirect: 'manual' })).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.email,
|
||||
).toBe(newEmail);
|
||||
},
|
||||
);
|
||||
|
||||
it('requires a new request if 2FA is enabled after the link was issued', async () => {
|
||||
const user = await account(false);
|
||||
const newEmail = `${uuidv4()}@example.com`;
|
||||
expect(
|
||||
(await change(user.token, { password, new_email: newEmail }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
await env.server.stores.user.update(user.id, { otp_enabled: true });
|
||||
expect(
|
||||
(await fetch(links.get(newEmail)!, { redirect: 'manual' })).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, { force: true }))!
|
||||
.email,
|
||||
).not.toBe(newEmail);
|
||||
});
|
||||
|
||||
it('refuses an existing confirmation link that never verified 2FA', async () => {
|
||||
const user = await account();
|
||||
const token = uuidv4();
|
||||
await env.server.stores.user.update(user.id, {
|
||||
change_email_confirm_token: token,
|
||||
unconfirmed_change_email: '[email protected]',
|
||||
});
|
||||
const link = env.server.services.token.sign(
|
||||
'otp',
|
||||
{
|
||||
token,
|
||||
user_id: user.id,
|
||||
purpose: 'change-email',
|
||||
},
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
const response = await fetch(
|
||||
new URL(`/change_email/confirm?token=${link}`, env.origin),
|
||||
{ redirect: 'manual' },
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, { force: true }))!
|
||||
.email,
|
||||
).toBe(`${user.username}@example.com`);
|
||||
});
|
||||
describe('disabling 2FA', () => {
|
||||
it.each([undefined, 'invalid-code', 123456, {}])(
|
||||
'refuses code %j and retains the second-factor configuration',
|
||||
async (code) => {
|
||||
const user = await account();
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_recovery_codes: 'one,two,three',
|
||||
});
|
||||
const response = await change(
|
||||
user.token,
|
||||
{ password, code },
|
||||
undefined,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ factor: 'otp' });
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_enabled).toBeTruthy();
|
||||
expect(after!.otp_secret).toBe(user.otp.secret.base32);
|
||||
expect(after!.otp_recovery_codes).toBe('one,two,three');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an incorrect six-digit code', async () => {
|
||||
const user = await account();
|
||||
const code = ['000000', '000001', '000002', '000003'].find(
|
||||
(token) => user.otp.validate({ token, window: 1 }) === null,
|
||||
);
|
||||
const response = await change(
|
||||
user.token,
|
||||
{ password, code },
|
||||
undefined,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: 'code_mismatch',
|
||||
factor: 'otp',
|
||||
});
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.otp_enabled,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'accepts a current code after proving identity (OIDC-only: %s)',
|
||||
async (oidcOnly) => {
|
||||
const user = await account(true, oidcOnly);
|
||||
const proof = oidcOnly
|
||||
? env.server.services.token.sign(
|
||||
'oidc-state',
|
||||
{
|
||||
user_uuid: user.uuid,
|
||||
purpose: 'revalidate',
|
||||
},
|
||||
{ expiresIn: '5m' },
|
||||
)
|
||||
: undefined;
|
||||
const missing = await change(
|
||||
user.token,
|
||||
oidcOnly ? {} : { password },
|
||||
proof,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(missing.status).toBe(400);
|
||||
const response = await change(
|
||||
user.token,
|
||||
{
|
||||
...(oidcOnly ? {} : { password }),
|
||||
code: user.otp.generate(),
|
||||
},
|
||||
proof,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_enabled).toBeFalsy();
|
||||
expect(after!.otp_secret).toBeNull();
|
||||
expect(after!.otp_recovery_codes).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not accept a code without password or OIDC revalidation', async () => {
|
||||
const user = await account();
|
||||
const response = await change(
|
||||
user.token,
|
||||
{ code: user.otp.generate() },
|
||||
undefined,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: 'password_required',
|
||||
});
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.otp_enabled,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not let a valid code override a team requirement for 2FA', async () => {
|
||||
const member = await account(true, true);
|
||||
installSeatStub(env.server as never, [member.id], {
|
||||
require2fa: true,
|
||||
});
|
||||
const proof = env.server.services.token.sign('oidc-state', {
|
||||
user_uuid: member.uuid, purpose: 'revalidate',
|
||||
}, { expiresIn: '5m' });
|
||||
const response = await change(
|
||||
member.token,
|
||||
{ code: member.otp.generate() },
|
||||
proof,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(409);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(member.id, {
|
||||
force: true,
|
||||
}))!.otp_enabled,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('remains idempotent for an account with 2FA already disabled', async () => {
|
||||
const user = await account(false);
|
||||
expect(
|
||||
(
|
||||
await change(
|
||||
user.token,
|
||||
{ password },
|
||||
undefined,
|
||||
'/user-protected/disable-2fa',
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { OIDCService } from '../../services/auth/OIDCService.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
|
||||
export const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
|
||||
export const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
|
||||
|
||||
export interface OidcLoginContinuation {
|
||||
userUuid: string;
|
||||
redirectUri: string;
|
||||
embeddedInPopup: boolean;
|
||||
openerOrigin: string | null;
|
||||
messageId: string | null;
|
||||
switchedToLogin: boolean;
|
||||
}
|
||||
|
||||
interface OidcReturnDeps {
|
||||
config: IConfig;
|
||||
oidc: OIDCService;
|
||||
}
|
||||
|
||||
/** Bind an attested popup return to the browser that completed sign-in. */
|
||||
export function mintOidcPopupReturn(
|
||||
res: Response,
|
||||
payload: Record<string, unknown>,
|
||||
deps: OidcReturnDeps,
|
||||
): string {
|
||||
const nonce = crypto.randomBytes(32).toString('base64url');
|
||||
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
|
||||
...sessionCookieFlags(deps.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
return deps.oidc.signPopupReturn({ action: 'sign-in', ...payload, nonce });
|
||||
}
|
||||
|
||||
/** Call only after both the provider and the account's second factor succeed. */
|
||||
export function completeOidcLoginReturn(
|
||||
res: Response,
|
||||
user: Pick<UserRow, 'uuid'>,
|
||||
continuation: unknown,
|
||||
deps: OidcReturnDeps,
|
||||
): string {
|
||||
if (!continuation || typeof continuation !== 'object') {
|
||||
throw new HttpError(400, 'Invalid login continuation.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const data = continuation as OidcLoginContinuation;
|
||||
if (
|
||||
data.userUuid !== user.uuid ||
|
||||
typeof data.redirectUri !== 'string' ||
|
||||
typeof data.embeddedInPopup !== 'boolean' ||
|
||||
typeof data.switchedToLogin !== 'boolean' ||
|
||||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
|
||||
(data.messageId !== null && typeof data.messageId !== 'string')
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid login continuation.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const origin = (deps.config.origin ?? '').replace(/\/$/, '');
|
||||
let target = data.redirectUri || origin || '/';
|
||||
try {
|
||||
if (origin && new URL(target).origin !== new URL(origin).origin)
|
||||
target = origin;
|
||||
} catch {
|
||||
target = origin || '/';
|
||||
}
|
||||
const append = (key: string, value: string) => {
|
||||
target += `${target.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
};
|
||||
if (data.embeddedInPopup) {
|
||||
append('oidc_login', 'true');
|
||||
append(
|
||||
'opener_state',
|
||||
mintOidcPopupReturn(
|
||||
res,
|
||||
{
|
||||
opener_origin: data.openerOrigin,
|
||||
msg_id: data.messageId,
|
||||
oidc_login: true,
|
||||
user_uuid: user.uuid,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.switchedToLogin) append('oidc_switched', 'login');
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
describe('recovery code consumption', () => {
|
||||
let env: PuterTestEnv;
|
||||
const password = 'recovery-code-password';
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Hold the same cached snapshot until every concurrent request has read it.
|
||||
const overlapReads = (userUuid: string, count: number) => {
|
||||
const redis = env.server.clients.redis;
|
||||
const get = redis.get.bind(redis);
|
||||
const ready = Promise.withResolvers<void>();
|
||||
let reads = 0;
|
||||
vi.spyOn(redis, 'get').mockImplementation(async (key: string) => {
|
||||
const value = await get(key);
|
||||
if (key.includes(userUuid) && reads < count) {
|
||||
if (++reads === count) ready.resolve();
|
||||
await ready.promise;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
const post = async (path: string, body: Record<string, unknown>) => {
|
||||
const response = await fetch(new URL(path, env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { response, body: await response.json() };
|
||||
};
|
||||
|
||||
const account = async (codes: string[]) => {
|
||||
const username = `recovery_${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, { username, password });
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_enabled: true,
|
||||
otp_secret: 'TESTSECRETBASE32',
|
||||
otp_recovery_codes: codes.map(hashRecoveryCode).join(','),
|
||||
});
|
||||
const challenge = async () => {
|
||||
const { response, body } = await post('/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
expect(response.status).toBe(202);
|
||||
expect(body.next_step).toBe('otp');
|
||||
return body.otp_jwt_token as string;
|
||||
};
|
||||
return { user, challenge };
|
||||
};
|
||||
|
||||
it('allows only one concurrent redemption of the same code', async () => {
|
||||
const { user, challenge } = await account(['RECOVER1', 'RECOVER2']);
|
||||
const tokens = await Promise.all([challenge(), challenge()]);
|
||||
overlapReads(user.uuid, tokens.length);
|
||||
const results = await Promise.all(
|
||||
tokens.map((token) =>
|
||||
post('/login/recovery-code', { token, code: 'RECOVER1' }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
results.filter((result) => result.body.next_step === 'complete'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
results.filter((result) => result.body.proceed === false),
|
||||
).toHaveLength(1);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_recovery_codes).toBe(hashRecoveryCode('RECOVER2'));
|
||||
});
|
||||
|
||||
it('consumes different concurrent codes without restoring either one', async () => {
|
||||
const codes = ['RECOVER1', 'RECOVER2', 'RECOVER3'];
|
||||
const { user, challenge } = await account(codes);
|
||||
const tokens = await Promise.all(codes.map(() => challenge()));
|
||||
overlapReads(user.uuid, tokens.length);
|
||||
const results = await Promise.all(
|
||||
codes.map((code, index) =>
|
||||
post('/login/recovery-code', { token: tokens[index], code }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
results.every((result) => result.body.next_step === 'complete'),
|
||||
).toBe(true);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_recovery_codes).toBe('');
|
||||
const replay = await post('/login/recovery-code', {
|
||||
token: tokens[0],
|
||||
code: codes[0],
|
||||
});
|
||||
expect(replay.body).toMatchObject({ proceed: false });
|
||||
});
|
||||
|
||||
it.each([{ code: 123456 }, { code: {} }, { code: [] }])(
|
||||
'rejects a non-string recovery code $code without consuming a code',
|
||||
async ({ code }) => {
|
||||
const { user, challenge } = await account(['RECOVER1']);
|
||||
const result = await post('/login/recovery-code', {
|
||||
token: await challenge(),
|
||||
code,
|
||||
});
|
||||
expect(result.response.status).toBe(400);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.otp_recovery_codes,
|
||||
).toBe(hashRecoveryCode('RECOVER1'));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
describe('2FA after signing in with a recovery code', () => {
|
||||
let env: PuterTestEnv;
|
||||
const password = 'lost-authenticator-password';
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = (
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
cookie?: string,
|
||||
) =>
|
||||
fetch(new URL(path, env.origin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it('allows an unused recovery code to disable 2FA after password revalidation', async () => {
|
||||
const username = `lost2fa_${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, { username, password });
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(user.id, {
|
||||
email: `${username}@example.com`,
|
||||
email_confirmed: true,
|
||||
otp_enabled: true,
|
||||
otp_secret: 'TESTSECRETBASE32',
|
||||
otp_recovery_codes: ['RECOVER1', 'RECOVER2', 'RECOVER3']
|
||||
.map(hashRecoveryCode)
|
||||
.join(','),
|
||||
});
|
||||
|
||||
// Sign in with a recovery code: works, and yields a web session.
|
||||
const login = await post('/login', { username, password });
|
||||
expect(login.status).toBe(202);
|
||||
const { otp_jwt_token } = await login.json();
|
||||
const recovered = await post('/login/recovery-code', {
|
||||
token: otp_jwt_token,
|
||||
code: 'RECOVER1',
|
||||
});
|
||||
expect((await recovered.json()).next_step).toBe('complete');
|
||||
const cookie = recovered.headers
|
||||
.getSetCookie()
|
||||
.find((value) => value.startsWith('puter_auth_token='))!
|
||||
.split(';')[0]!;
|
||||
|
||||
const reused = await post(
|
||||
'/user-protected/disable-2fa',
|
||||
{ password, code: 'RECOVER1' },
|
||||
cookie,
|
||||
);
|
||||
expect(reused.status).toBe(401);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, { force: true }))!
|
||||
.otp_enabled,
|
||||
).toBeTruthy();
|
||||
|
||||
const disable = await post(
|
||||
'/user-protected/disable-2fa',
|
||||
{ password, code: 'RECOVER2' },
|
||||
cookie,
|
||||
);
|
||||
expect(disable.status).toBe(200);
|
||||
expect(await disable.json()).toMatchObject({ success: true });
|
||||
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_enabled).toBeFalsy();
|
||||
expect(after!.otp_secret).toBeNull();
|
||||
expect(after!.otp_recovery_codes).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { Context, runWithContext } from '../../core/context.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { DriverController } from './DriverController.js';
|
||||
@@ -278,6 +278,86 @@ describe('DriverController — concurrent acquire/release', () => {
|
||||
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
|
||||
});
|
||||
|
||||
// A driver that keeps working after the caller hangs up (async video
|
||||
// polling) must keep counting against the cap until it settles.
|
||||
it('holds the slot while the driver still works after a disconnect', async () => {
|
||||
let finish = () => {};
|
||||
const driver = {
|
||||
...makeSyntheticDriver(),
|
||||
ping: () =>
|
||||
new Promise<string>((resolve) => {
|
||||
finish = () => resolve('pong');
|
||||
}),
|
||||
};
|
||||
const handler = captureCallHandler(buildController(driver));
|
||||
const req = () =>
|
||||
makeReq({ interface: 'test-iface', method: 'ping' }, 'working');
|
||||
|
||||
const res1 = new StubRes();
|
||||
const first = callInScope(handler, req(), res1 as unknown as Response);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
res1.emit('close');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
await expect(
|
||||
callInScope(handler, req(), new StubRes() as unknown as Response),
|
||||
).rejects.toSatisfy(
|
||||
(e) =>
|
||||
isHttpError(e) &&
|
||||
(e as { statusCode: number }).statusCode === 429,
|
||||
);
|
||||
|
||||
finish();
|
||||
await first;
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const res2 = new StubRes();
|
||||
const second = callInScope(handler, req(), res2 as unknown as Response);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
finish();
|
||||
await second;
|
||||
res2.emit('finish');
|
||||
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
|
||||
});
|
||||
|
||||
it('holds the slot for work a driver keeps past its response', async () => {
|
||||
let settleWork = () => {};
|
||||
const driver = {
|
||||
...makeSyntheticDriver(),
|
||||
ping: async () => {
|
||||
Context.get('keepDriverSlot')?.(
|
||||
new Promise<void>((resolve) => {
|
||||
settleWork = resolve;
|
||||
}),
|
||||
);
|
||||
return 'pong';
|
||||
},
|
||||
};
|
||||
const handler = captureCallHandler(buildController(driver));
|
||||
const req = () =>
|
||||
makeReq({ interface: 'test-iface', method: 'ping' }, 'kept');
|
||||
|
||||
const res1 = new StubRes();
|
||||
await callInScope(handler, req(), res1 as unknown as Response);
|
||||
res1.emit('finish');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
await expect(
|
||||
callInScope(handler, req(), new StubRes() as unknown as Response),
|
||||
).rejects.toSatisfy(
|
||||
(e) =>
|
||||
isHttpError(e) &&
|
||||
(e as { statusCode: number }).statusCode === 429,
|
||||
);
|
||||
|
||||
settleWork();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const res2 = new StubRes();
|
||||
await callInScope(handler, req(), res2 as unknown as Response);
|
||||
res2.emit('finish');
|
||||
settleWork();
|
||||
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
|
||||
});
|
||||
|
||||
it('does not attach release listeners when the driver declares no concurrent config', async () => {
|
||||
// With no spec there is nothing to release, so the gate must not
|
||||
// wire `finish`/`close` release listeners. The client-disconnect
|
||||
|
||||
@@ -823,9 +823,10 @@ describe('DriverController.#handleCall tracing', () => {
|
||||
'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');
|
||||
// The KV store's dynamo writes produce spans of their own. The
|
||||
// permission gate doesn't: every user holds `service` by default, which
|
||||
// `check` answers without a scan.
|
||||
expect(names).not.toContain('permission.scan');
|
||||
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import { PermissionUtil } from '../../services/permission/permissionUtil.js';
|
||||
import type { WithLifecycle } from '../../types';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { CallSlot } from './callSlot.js';
|
||||
|
||||
type DriverInstance = WithLifecycle & Record<string, unknown>;
|
||||
|
||||
@@ -259,6 +260,17 @@ export class DriverController extends PuterController {
|
||||
// -- Handlers ----------------------------------------------------
|
||||
|
||||
#handleCall = async (req: Request, res: Response): Promise<void> => {
|
||||
const slot = new CallSlot();
|
||||
const call = this.#call(req, res, slot);
|
||||
slot.keep(call);
|
||||
await call;
|
||||
};
|
||||
|
||||
#call = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
slot: CallSlot,
|
||||
): Promise<void> => {
|
||||
const {
|
||||
interface: ifaceName,
|
||||
method,
|
||||
@@ -395,10 +407,9 @@ export class DriverController extends PuterController {
|
||||
|
||||
// Per-method rate-limit and concurrent specs both live on the
|
||||
// driver's resolved meta. Rate-limit is single-shot; concurrent
|
||||
// acquires a slot that must be released when the response is done
|
||||
// — we hook `res.finish` / `res.close` for that so streamed
|
||||
// responses hold their slot until the stream drains, and aborted
|
||||
// requests still give the slot back.
|
||||
// acquires a slot held until the response is done and the call's
|
||||
// work has settled, so streamed responses hold it until the stream
|
||||
// drains and work that continues after a disconnect still counts.
|
||||
const rateLimitSpec = resolvePerMethod(driverMeta?.rateLimit, method);
|
||||
if (
|
||||
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
|
||||
@@ -430,18 +441,8 @@ export class DriverController extends PuterController {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
void handle.release();
|
||||
};
|
||||
// If the handler throws before responding, the express error
|
||||
// handler will eventually send a response — `finish` fires then,
|
||||
// so we still release. `close` covers client aborts.
|
||||
res.once('finish', release);
|
||||
res.once('close', release);
|
||||
if (res.destroyed || res.writableFinished) release();
|
||||
slot.attach(handle, res);
|
||||
Context.set('keepDriverSlot', (work) => slot.keep(work));
|
||||
}
|
||||
|
||||
// Stash the requested driver name in Context so multi-provider
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Response } from 'express';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CallSlot } from './callSlot.js';
|
||||
|
||||
const handle = () => ({
|
||||
release: vi.fn(() => Promise.resolve()),
|
||||
renew: vi.fn(() => Promise.resolve()),
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
let resolve = () => {};
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('CallSlot', () => {
|
||||
it('renews a slot held by a long call and stops once it is released', async () => {
|
||||
vi.useFakeTimers();
|
||||
const slot = new CallSlot();
|
||||
const slotHandle = handle();
|
||||
const res = new EventEmitter();
|
||||
const call = deferred();
|
||||
slot.keep(call.promise);
|
||||
slot.attach(slotHandle, res as unknown as Response);
|
||||
|
||||
res.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(25 * 60 * 1000);
|
||||
expect(slotHandle.renew).toHaveBeenCalledTimes(2);
|
||||
expect(slotHandle.release).not.toHaveBeenCalled();
|
||||
|
||||
call.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(slotHandle.release).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(25 * 60 * 1000);
|
||||
expect(slotHandle.renew).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('releases once the response is done if no work is outstanding', async () => {
|
||||
const slot = new CallSlot();
|
||||
const slotHandle = handle();
|
||||
const res = new EventEmitter();
|
||||
slot.attach(slotHandle, res as unknown as Response);
|
||||
res.emit('finish');
|
||||
expect(slotHandle.release).toHaveBeenCalledOnce();
|
||||
expect(slotHandle.renew).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
|
||||
// Under the concurrency store's one-hour orphan TTL.
|
||||
const SLOT_RENEW_MS = 10 * 60 * 1000;
|
||||
|
||||
interface SlotHandle {
|
||||
release(): Promise<void>;
|
||||
renew?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A driver call's concurrency slot, held until the response is done and the
|
||||
* call's work has settled, including work a driver carries on after the caller
|
||||
* hung up or was answered.
|
||||
*/
|
||||
export class CallSlot {
|
||||
#handle?: SlotHandle;
|
||||
#work = new Set<Promise<unknown>>();
|
||||
#responded = false;
|
||||
#released = false;
|
||||
#renew?: NodeJS.Timeout;
|
||||
|
||||
attach(handle: SlotHandle, res: Response): void {
|
||||
this.#handle = handle;
|
||||
const responded = () => {
|
||||
this.#responded = true;
|
||||
this.#maybeRelease();
|
||||
};
|
||||
res.once('finish', responded);
|
||||
res.once('close', responded);
|
||||
if (res.destroyed || res.writableFinished) responded();
|
||||
if (!this.#released && this.#work.size > 0) this.#startRenewing();
|
||||
}
|
||||
|
||||
keep(work: Promise<unknown>): void {
|
||||
if (this.#released) return;
|
||||
this.#work.add(work);
|
||||
if (this.#handle) this.#startRenewing();
|
||||
const settled = () => {
|
||||
this.#work.delete(work);
|
||||
this.#maybeRelease();
|
||||
};
|
||||
work.then(settled, settled);
|
||||
}
|
||||
|
||||
#startRenewing(): void {
|
||||
if (this.#renew) return;
|
||||
this.#renew = setInterval(() => {
|
||||
void this.#handle?.renew?.();
|
||||
}, SLOT_RENEW_MS);
|
||||
this.#renew.unref?.();
|
||||
}
|
||||
|
||||
#maybeRelease(): void {
|
||||
if (this.#released || !this.#handle) return;
|
||||
if (!this.#responded || this.#work.size > 0) return;
|
||||
this.#released = true;
|
||||
clearInterval(this.#renew);
|
||||
void this.#handle.release();
|
||||
}
|
||||
}
|
||||
@@ -154,8 +154,9 @@ export class EventsController extends PuterController {
|
||||
// -- Cross-user key-value handles --------------------------------
|
||||
|
||||
/**
|
||||
* POST /events/kv-handles — hand another user a watchable region of this
|
||||
* account's key-value data.
|
||||
* POST /events/kv-handles — hand another user a region of this account's
|
||||
* key-value data, `watch` unless the body asks for more. The older name for
|
||||
* `POST /kv/shares`, keeping its own body and response shapes.
|
||||
*
|
||||
* An account session, or an app session holding a `manage:` delegation on
|
||||
* the region — the service tells those apart and gates each rather than the
|
||||
@@ -167,12 +168,32 @@ export class EventsController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
})
|
||||
async mintKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.mintKvHandle(actor, bodyRecord(req)),
|
||||
const body = bodyRecord(req);
|
||||
const share = await this.services.kvShare.mintKvHandle(
|
||||
actor,
|
||||
{
|
||||
prefix: body.prefix,
|
||||
recipient: {
|
||||
username:
|
||||
typeof body.granteeUsername === 'string'
|
||||
? body.granteeUsername
|
||||
: undefined,
|
||||
uid:
|
||||
typeof body.granteeUid === 'string'
|
||||
? body.granteeUid
|
||||
: undefined,
|
||||
},
|
||||
access: body.access,
|
||||
appUid: body.appUid,
|
||||
},
|
||||
// Callers of this route mint again to get the same handle back.
|
||||
{ defaultAccess: 'watch', keepLevelUnlessNamed: true },
|
||||
);
|
||||
res.json({ handle: share.handle, prefix: share.prefix });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +210,7 @@ export class EventsController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.services.events.listKvHandles(actor, {
|
||||
const page = await this.services.kvShare.listKvHandles(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: KV_HANDLE_LIST_LIMIT_CAP,
|
||||
}),
|
||||
@@ -198,7 +219,10 @@ export class EventsController extends PuterController {
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items,
|
||||
items: page.items.map(({ holderUsername, ...row }) => ({
|
||||
...row,
|
||||
granteeUsername: holderUsername,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
@@ -217,7 +241,10 @@ export class EventsController extends PuterController {
|
||||
async revokeKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.revokeKvHandle(actor, req.params.handle),
|
||||
await this.services.kvShare.revokeKvHandle(
|
||||
actor,
|
||||
req.params.handle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,14 +139,14 @@ describe('the delivery budgets', () => {
|
||||
});
|
||||
|
||||
it('pins the share-handle quotas by plan', () => {
|
||||
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
|
||||
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
|
||||
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(25_000);
|
||||
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(10_000);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(200);
|
||||
).toBe(5_000);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(128);
|
||||
).toBe(2_000);
|
||||
});
|
||||
|
||||
it('keeps `single` well under broadcast — each one costs far more', () => {
|
||||
|
||||
@@ -155,6 +155,13 @@ export const EVENTS_LIST_LIMIT = userWindow('events:list', 120);
|
||||
*/
|
||||
export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
|
||||
|
||||
/**
|
||||
* How long a connected client holds a queued delivery before the events worker
|
||||
* may take it. A client that has no handler for it, or whose handler fails,
|
||||
* declines it at once; this only bounds one that never answers.
|
||||
*/
|
||||
export const EVENTS_SOCKET_LEASE_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Share-handle mint + revoke calls per minute, per user.
|
||||
*
|
||||
@@ -164,22 +171,49 @@ export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
|
||||
export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
|
||||
|
||||
/**
|
||||
* Live share handles one account may hold out at a time, across every app.
|
||||
*
|
||||
* Each is a standing grant on part of the account's data, and revoking marks
|
||||
* rather than deletes, so without a ceiling the rate limit alone lets the rows
|
||||
* grow forever. A temporary account holds none: the grant outlives the session
|
||||
* that made it, and no account is left to take it back.
|
||||
* `kv.share.received` notices per hour, per owner and holder. A share past it
|
||||
* is still made, without a notice.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
|
||||
export const KV_SHARE_NOTICE_LIMIT = userWindow(
|
||||
'kvShare:notices',
|
||||
5,
|
||||
60 * 60_000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Live share handles one app may hold out for one account.
|
||||
* Live share handles one account may hold out at a time, across every app.
|
||||
*
|
||||
* Separately bounded from the account cap so one namespace cannot consume the
|
||||
* whole budget on free accounts.
|
||||
* A backstop above the per-app cap: every count reads a covering index, so the
|
||||
* bound is on standing grants, not query cost. A temporary account holds none:
|
||||
* the grant outlives the session that made it, and no account is left to take
|
||||
* it back.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(25_000, 5_000, 0);
|
||||
|
||||
/**
|
||||
* Live share handles one app may hold out for one account, so one namespace
|
||||
* cannot consume the whole account budget.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(10_000, 2_000, 0);
|
||||
|
||||
/**
|
||||
* Live share handles one owner may hold out to one holder, on every plan.
|
||||
* Bounds the rows a revoke or unshare walks to find the narrower handles inside
|
||||
* one.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_HOLDER = 256;
|
||||
|
||||
/**
|
||||
* Recipients one account may share with, per minute and per hour. A batch
|
||||
* spends one per recipient, on top of the per-call mint budget.
|
||||
*/
|
||||
export const KV_SHARE_GRANT_LIMITS = [
|
||||
userWindow('kvShare:grants:minute', 300),
|
||||
userWindow('kvShare:grants:hour', 2_000, 60 * 60_000),
|
||||
] as const;
|
||||
|
||||
/** Recipients one `POST /kv/shares` call may name. */
|
||||
export const KV_SHARE_BATCH_MAX = 50;
|
||||
|
||||
// -- Handler surface -------------------------------------------------
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
@@ -549,3 +551,111 @@ describe('POST /fs/startWrite storage quota over HTTP', () => {
|
||||
expect(responseBody.code).toBe('bad_request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file reads over HTTP when storage fails mid-download', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const writeFile = async (name: string, content: string) => {
|
||||
const { username } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const body = Buffer.from(content);
|
||||
const { fsEntry } = await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/${name}`,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
return fsEntry;
|
||||
};
|
||||
|
||||
// Storage hands over one chunk and then fails, the way a dropped
|
||||
// connection or a checksum mismatch surfaces on a read in flight.
|
||||
const breakReadsOf = (objectKey: string) => {
|
||||
const s3 = env.server.stores.s3Object;
|
||||
const client = env.server.clients.s3.get(s3.resolveRegion(null));
|
||||
const send = client.send.bind(client);
|
||||
return vi.spyOn(client, 'send').mockImplementation(((
|
||||
command: unknown,
|
||||
) => {
|
||||
if (
|
||||
!(command instanceof GetObjectCommand) ||
|
||||
command.input.Key !== objectKey
|
||||
) {
|
||||
return send(command as never);
|
||||
}
|
||||
let reads = 0;
|
||||
const body = new Readable({
|
||||
read() {
|
||||
reads += 1;
|
||||
if (reads === 1) this.push('partial');
|
||||
// Late enough for the headers and first chunk to go out.
|
||||
else if (reads === 2)
|
||||
setTimeout(
|
||||
() => this.destroy(new Error('Checksum mismatch')),
|
||||
25,
|
||||
);
|
||||
},
|
||||
});
|
||||
return Promise.resolve({
|
||||
Body: body,
|
||||
ContentLength: 64,
|
||||
ContentType: 'text/plain',
|
||||
});
|
||||
}) as never);
|
||||
};
|
||||
|
||||
it.each([
|
||||
['/fs/read', 'path'],
|
||||
['/read', 'file'],
|
||||
])(
|
||||
'GET %s fails that response and keeps serving others',
|
||||
async (route, param) => {
|
||||
const { username, token } = env.users.user;
|
||||
const urlFor = (name: string) => {
|
||||
const url = new URL(route, env.apiOrigin);
|
||||
url.searchParams.set(param, `/${username}/Documents/${name}`);
|
||||
return url;
|
||||
};
|
||||
const suffix = route.replace(/\W/g, '');
|
||||
const broken = await writeFile(
|
||||
`broken-${suffix}.txt`,
|
||||
'x'.repeat(64),
|
||||
);
|
||||
await writeFile(`healthy-${suffix}.txt`, 'still fine');
|
||||
|
||||
const send = breakReadsOf(broken.uuid);
|
||||
let status = 0;
|
||||
let completed = false;
|
||||
try {
|
||||
const response = await fetch(urlFor(`broken-${suffix}.txt`), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
status = response.status;
|
||||
await response.arrayBuffer();
|
||||
completed = true;
|
||||
} catch {
|
||||
// Expected: the transfer is cut short.
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
expect(status).toBe(200);
|
||||
expect(completed).toBe(false);
|
||||
|
||||
const healthy = await fetch(urlFor(`healthy-${suffix}.txt`), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(healthy.status).toBe(200);
|
||||
expect(await healthy.text()).toBe('still fine');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
describe('recursive readdir with offset', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = async (path: string, token: string, body: unknown) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it('skips entries before applying the recursive page limit', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const base = `/${username}/Documents/rec-offset`;
|
||||
for (const path of [base, `${base}/a`, `${base}/b`, `${base}/c`]) {
|
||||
expect((await post('/fs/mkdir', token, { path })).status).toBe(200);
|
||||
}
|
||||
|
||||
const list = async (body: Record<string, unknown>) => {
|
||||
const response = await post('/fs/readdir', token, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
...body,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const page = (await response.json()) as {
|
||||
items: Array<{ name: string }>;
|
||||
};
|
||||
return page.items.map((item) => item.name);
|
||||
};
|
||||
|
||||
expect(await list({ limit: 3 })).toEqual(['a', 'b', 'c']);
|
||||
// Non-recursive honours offset.
|
||||
const flat = await post('/fs/readdir', token, {
|
||||
path: base,
|
||||
limit: 1,
|
||||
offset: 2,
|
||||
});
|
||||
expect(
|
||||
((await flat.json()) as Array<{ name: string }>).map((e) => e.name),
|
||||
).toEqual(['c']);
|
||||
expect(await list({ limit: 1, offset: 2 })).toEqual(['c']);
|
||||
expect(await list({ offset: 2 })).toEqual(['c']);
|
||||
expect(await list({ offset: 20 })).toEqual([]);
|
||||
expect(await list({ limit: 1, offset: 1, sortOrder: 'desc' })).toEqual([
|
||||
'b',
|
||||
]);
|
||||
const first = await post('/fs/readdir', token, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
limit: 1,
|
||||
offset: 1,
|
||||
});
|
||||
const page = await first.json();
|
||||
expect(page.items.map((item: { name: string }) => item.name)).toEqual([
|
||||
'b',
|
||||
]);
|
||||
expect(page.cursor).toBeTypeOf('string');
|
||||
expect(await list({ limit: 1, cursor: page.cursor })).toEqual(['c']);
|
||||
// A cursor wins over offset, and an invalid offset is ignored, as
|
||||
// recursive listings did before they honoured offset.
|
||||
expect(
|
||||
await list({ limit: 1, cursor: page.cursor, offset: 1 }),
|
||||
).toEqual(['c']);
|
||||
expect(await list({ limit: 1, offset: -1 })).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -1948,6 +1948,27 @@ describe('FSController.readEntry (file streaming)', () => {
|
||||
expect(captured.statusCode).toBe(206);
|
||||
});
|
||||
|
||||
it('returns 206 with Range honored when offset and byte_count query params are supplied', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('abcdefghij');
|
||||
const target = `/${username}/Documents/ranged-query.bin`;
|
||||
await writeFile(userId, target, body, 'application/octet-stream');
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readEntry(
|
||||
makeReq({
|
||||
query: { path: target, offset: '2', byte_count: '4' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.statusCode).toBe(206);
|
||||
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('cdef');
|
||||
});
|
||||
|
||||
it('throws 404 when the path does not exist', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
|
||||
@@ -20,12 +20,15 @@
|
||||
import Busboy from 'busboy';
|
||||
import type { Request, Response } from 'express';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { FS_ROUTE_REFUSAL } from '../../services/acl/ACLService.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
import {
|
||||
FS_ROUTE_REFUSAL,
|
||||
type ResourceDescriptor,
|
||||
} from '../../services/acl/ACLService.js';
|
||||
import { clientParentUid } from '../../services/fs/rootListing.js';
|
||||
import {
|
||||
expandTildePath,
|
||||
@@ -42,7 +45,11 @@ import type {
|
||||
UploadedBatchWriteItem,
|
||||
UploadProgressTrackerLike,
|
||||
} from '../../services/fs/types.js';
|
||||
import type { FSEntry, FSEntryWriteInput } from '../../stores/fs/FSEntry.js';
|
||||
import type {
|
||||
FSEntry,
|
||||
FSEntryWriteInput,
|
||||
PendingUploadSession,
|
||||
} from '../../stores/fs/FSEntry.js';
|
||||
import {
|
||||
runWithConcurrencyLimit,
|
||||
runWithConcurrencyLimitSettled,
|
||||
@@ -75,6 +82,8 @@ import {
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveOrNotFound,
|
||||
resolveReadRange,
|
||||
signEntryThumbnail,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
@@ -150,8 +159,14 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// Write routes admit scoped access tokens for the same reason the reads
|
||||
// do (see the note above `/stat`): every handler authorizes the target
|
||||
// through the ACL, which intersects a token's grant with its issuer's, so
|
||||
// a token writes exactly where it was granted write — the parity WebDAV
|
||||
// already has. Upload sessions are re-checked on complete, sign and abort.
|
||||
@Post('/startWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -218,6 +233,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/startBatchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -324,6 +340,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/completeWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -366,6 +383,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/completeBatchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -396,26 +414,26 @@ export class FSController extends PuterController {
|
||||
userId,
|
||||
requests,
|
||||
);
|
||||
const updatedResponse = await runWithConcurrencyLimit(
|
||||
response,
|
||||
const withSideEffects = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
response.map((writeResponse, index) => {
|
||||
const write: WriteResponse = {
|
||||
fsEntry: writeResponse.fsEntry,
|
||||
wasOverwrite: writeResponse.wasOverwrite,
|
||||
requestedThumbnail: writeResponse.requestedThumbnail,
|
||||
contentHashSha256: null,
|
||||
};
|
||||
return {
|
||||
response: write,
|
||||
guiMetadata: requests[index]?.guiMetadata,
|
||||
};
|
||||
}),
|
||||
DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY,
|
||||
async (writeResponse, index) => {
|
||||
const requestBody = requests[index];
|
||||
const withSideEffects =
|
||||
await this.#applyWriteResponseSideEffects(
|
||||
userId,
|
||||
{
|
||||
fsEntry: writeResponse.fsEntry,
|
||||
wasOverwrite: writeResponse.wasOverwrite,
|
||||
requestedThumbnail:
|
||||
writeResponse.requestedThumbnail,
|
||||
contentHashSha256: null,
|
||||
},
|
||||
requestBody?.guiMetadata,
|
||||
);
|
||||
return { ...writeResponse, fsEntry: withSideEffects.fsEntry };
|
||||
},
|
||||
);
|
||||
const updatedResponse = response.map((writeResponse, index) => ({
|
||||
...writeResponse,
|
||||
fsEntry: withSideEffects[index]?.fsEntry ?? writeResponse.fsEntry,
|
||||
}));
|
||||
res.json(
|
||||
updatedResponse.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
);
|
||||
@@ -423,6 +441,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/abortWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
})
|
||||
@@ -437,12 +456,31 @@ export class FSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// An unknown id stays a no-op, as it always was.
|
||||
try {
|
||||
await this.services.fs.getUploadSessions(userId, [
|
||||
req.body.uploadId,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError && error.statusCode === 404)) {
|
||||
throw error;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
return;
|
||||
}
|
||||
// Same re-check as complete/sign: a scoped token may only abort an
|
||||
// upload whose target it could write.
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body.uploadId,
|
||||
]);
|
||||
|
||||
await this.services.fs.abortUrlWrite(userId, req.body.uploadId);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
@Post('/signMultipartParts', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -463,18 +501,22 @@ export class FSController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body?.uploadId,
|
||||
]);
|
||||
const sessions = await this.#assertUploadSessionWriteAccess(
|
||||
req,
|
||||
userId,
|
||||
[req.body?.uploadId],
|
||||
);
|
||||
const response = await this.services.fs.signMultipartParts(
|
||||
userId,
|
||||
req.body,
|
||||
sessions?.[0],
|
||||
);
|
||||
res.json(this.#withoutStorageInternals(response));
|
||||
}
|
||||
|
||||
@Post('/write', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_WRITE_LIMIT,
|
||||
@@ -530,6 +572,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/batchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_WRITE_LIMIT,
|
||||
@@ -865,17 +908,13 @@ export class FSController extends PuterController {
|
||||
preparedBatch,
|
||||
uploadedItems,
|
||||
);
|
||||
const updatedResponses = await runWithConcurrencyLimit(
|
||||
writeResponses,
|
||||
const updatedResponses = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
writeResponses.map((writeResponse, index) => ({
|
||||
response: writeResponse,
|
||||
guiMetadata: preparedBatch?.items[index]?.guiMetadata,
|
||||
})),
|
||||
32,
|
||||
async (writeResponse, index) => {
|
||||
const preparedItem = preparedBatch?.items[index];
|
||||
return this.#applyWriteResponseSideEffects(
|
||||
userId,
|
||||
writeResponse,
|
||||
preparedItem?.guiMetadata,
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
@@ -988,17 +1027,13 @@ export class FSController extends PuterController {
|
||||
preparedBatch,
|
||||
uploadedItems,
|
||||
);
|
||||
const updatedResponses = await runWithConcurrencyLimit(
|
||||
writeResponses,
|
||||
const updatedResponses = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
writeResponses.map((writeResponse, index) => ({
|
||||
response: writeResponse,
|
||||
guiMetadata: filteredRequests[index]?.guiMetadata,
|
||||
})),
|
||||
32,
|
||||
async (writeResponse, index) => {
|
||||
const requestBody = filteredRequests[index];
|
||||
return this.#applyWriteResponseSideEffects(
|
||||
userId,
|
||||
writeResponse,
|
||||
requestBody?.guiMetadata,
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
@@ -1021,7 +1056,9 @@ export class FSController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#toObjectRecord(req.body);
|
||||
const entry = await this.#resolveEntryForRequest(body);
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
// One descriptor for both checks, so the chain is read at most once.
|
||||
const descriptor = this.services.acl.fsDescriptor(entry.path);
|
||||
await this.#assertAccess(actor, descriptor, 'see');
|
||||
|
||||
const wantsSize = this.#toBoolean(body.return_size);
|
||||
const wantsShares = this.#toBoolean(body.return_shares);
|
||||
@@ -1039,7 +1076,7 @@ export class FSController extends PuterController {
|
||||
const mayReadSize =
|
||||
entry.isDir &&
|
||||
wantsSize &&
|
||||
(await this.#canAccess(actor, entry.path, 'list'));
|
||||
(await this.#canAccess(actor, descriptor, 'list'));
|
||||
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares, parentUid] =
|
||||
await Promise.all([
|
||||
@@ -1298,6 +1335,7 @@ export class FSController extends PuterController {
|
||||
parent.path,
|
||||
{
|
||||
limit,
|
||||
offset,
|
||||
cursor:
|
||||
typeof body.cursor === 'string'
|
||||
? body.cursor
|
||||
@@ -1459,10 +1497,7 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -1488,19 +1523,14 @@ export class FSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
try {
|
||||
await pipeline(download.body, res);
|
||||
} catch {
|
||||
// Client disconnect or upstream stream error — pipeline already
|
||||
// tore down both ends. Response is partially sent; nothing to do.
|
||||
return;
|
||||
}
|
||||
await pipeToResponse(download.body, res);
|
||||
}
|
||||
|
||||
// -- Mutation routes ------------------------------------------------
|
||||
|
||||
@Post('/mkdir', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1546,6 +1576,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/touch', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1586,6 +1617,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/rename', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1609,6 +1641,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/delete', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1635,6 +1668,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/move', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1688,6 +1722,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/copy', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
@@ -1739,6 +1774,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/mkshortcut', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1790,7 +1826,6 @@ export class FSController extends PuterController {
|
||||
}
|
||||
|
||||
async #resolveEntryForRequest(source: Record<string, unknown>) {
|
||||
const mod = await import('../../services/fs/resolveNode.js');
|
||||
const username = Context.get('actor')?.user?.username;
|
||||
const rawPath =
|
||||
typeof source.path === 'string' ? source.path : undefined;
|
||||
@@ -1810,15 +1845,8 @@ export class FSController extends PuterController {
|
||||
? source.id
|
||||
: undefined,
|
||||
};
|
||||
const entry = await mod.resolveNode(this.stores.fsEntry, ref, {
|
||||
required: true,
|
||||
});
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Entry not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
// The resolved path is the owner's real one, so a miss must not echo it.
|
||||
return resolveOrNotFound(this.stores.fsEntry, ref);
|
||||
}
|
||||
|
||||
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
|
||||
@@ -1835,16 +1863,20 @@ export class FSController extends PuterController {
|
||||
/** As `#assertAccess`, but for what to include rather than what to serve. */
|
||||
async #canAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
path: string | ResourceDescriptor,
|
||||
mode: 'see' | 'list' | 'read' | 'write',
|
||||
): Promise<boolean> {
|
||||
const acl = this.services.acl;
|
||||
return acl.check(actor, acl.fsDescriptor(path), mode);
|
||||
return acl.check(
|
||||
actor,
|
||||
typeof path === 'string' ? acl.fsDescriptor(path) : path,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
|
||||
async #assertAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
path: string | ResourceDescriptor,
|
||||
mode: 'see' | 'list' | 'read' | 'write',
|
||||
) {
|
||||
await this.services.acl.assertFsAccess(
|
||||
@@ -2240,7 +2272,8 @@ 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),
|
||||
@@ -2478,19 +2511,24 @@ export class FSController extends PuterController {
|
||||
* `startWrite` checked has to be re-checked against the session's recorded
|
||||
* target every time the caller signs more parts or completes the upload —
|
||||
* otherwise a revoked sharee still lands bytes in the owner's tree.
|
||||
*
|
||||
* Returns the sessions it checked, in request order, for signing to reuse;
|
||||
* undefined when any id was left to the service to read and reject.
|
||||
* Completion reads its own, so a session settled during this check still
|
||||
* gets a 409.
|
||||
*/
|
||||
async #assertUploadSessionWriteAccess(
|
||||
req: Request,
|
||||
userId: number,
|
||||
uploadIds: Array<string | undefined>,
|
||||
): Promise<void> {
|
||||
): Promise<PendingUploadSession[] | undefined> {
|
||||
// A malformed id is left to the service, which owns that error shape.
|
||||
const knownUploadIds = uploadIds.filter(
|
||||
(uploadId): uploadId is string =>
|
||||
typeof uploadId === 'string' && uploadId.length > 0,
|
||||
);
|
||||
if (knownUploadIds.length === 0) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
const sessions = await this.services.fs.getUploadSessions(
|
||||
userId,
|
||||
@@ -2505,6 +2543,9 @@ export class FSController extends PuterController {
|
||||
})),
|
||||
{ pathAlreadyNormalized: true },
|
||||
);
|
||||
return knownUploadIds.length === uploadIds.length
|
||||
? sessions
|
||||
: undefined;
|
||||
}
|
||||
|
||||
#toEventGuiMetadata(
|
||||
@@ -2619,16 +2660,16 @@ export class FSController extends PuterController {
|
||||
return this.#estimateDataUrlSize(thumbnail) > MAX_THUMBNAIL_BYTES;
|
||||
}
|
||||
|
||||
async #applyThumbnailAfterWrite(
|
||||
userId: number,
|
||||
/** The thumbnail a write should store on `fsEntry`, or null for none. */
|
||||
async #thumbnailToStore(
|
||||
fsEntry: FSEntry,
|
||||
requestedThumbnail: string | null | undefined,
|
||||
): Promise<FSEntry> {
|
||||
): Promise<string | null> {
|
||||
if (!requestedThumbnail || this.#isAppDataPath(fsEntry.path)) {
|
||||
return fsEntry;
|
||||
return null;
|
||||
}
|
||||
if (this.#isOversizedThumbnailDataUrl(requestedThumbnail)) {
|
||||
return fsEntry;
|
||||
return null;
|
||||
}
|
||||
|
||||
const thumbnailPayload = {
|
||||
@@ -2649,15 +2690,10 @@ export class FSController extends PuterController {
|
||||
? thumbnailPayload.url
|
||||
: null;
|
||||
|
||||
if (finalThumbnail === fsEntry.thumbnail || finalThumbnail === null) {
|
||||
return fsEntry;
|
||||
if (finalThumbnail === fsEntry.thumbnail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.services.fs.updateEntryThumbnail(
|
||||
userId,
|
||||
fsEntry.uuid,
|
||||
finalThumbnail,
|
||||
);
|
||||
return finalThumbnail;
|
||||
}
|
||||
|
||||
#toThumbnailPrepareItem(
|
||||
@@ -2887,36 +2923,130 @@ export class FSController extends PuterController {
|
||||
response: WriteResponse,
|
||||
guiMetadata: WriteGuiMetadata | undefined,
|
||||
): Promise<WriteResponse> {
|
||||
let fsEntry = response.fsEntry;
|
||||
const [result] = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
[{ response, guiMetadata }],
|
||||
1,
|
||||
);
|
||||
return result ?? response;
|
||||
}
|
||||
|
||||
const hashEventPromise = this.#runNonCritical(async () => {
|
||||
await this.#emitWriteHashEvent(
|
||||
response.contentHashSha256,
|
||||
fsEntry.uuid,
|
||||
);
|
||||
}, 'emitWriteHashEvent');
|
||||
/**
|
||||
* Each write's hash event, thumbnail and GUI event. Thumbnails are vetted
|
||||
* `concurrency` writes at a time, then the caller's own go to the store in
|
||||
* one update; a write into someone else's folder is updated alone.
|
||||
*/
|
||||
async #applyWriteResponsesSideEffects(
|
||||
userId: number,
|
||||
writes: Array<{
|
||||
response: WriteResponse;
|
||||
guiMetadata: WriteGuiMetadata | undefined;
|
||||
}>,
|
||||
concurrency: number,
|
||||
): Promise<WriteResponse[]> {
|
||||
type Write = (typeof writes)[number];
|
||||
const results: WriteResponse[] = new Array(writes.length);
|
||||
const finish = async (
|
||||
index: number,
|
||||
{ response, guiMetadata }: Write,
|
||||
fsEntry: FSEntry,
|
||||
hashEventPromise: Promise<void>,
|
||||
) => {
|
||||
await this.#runNonCritical(async () => {
|
||||
await this.#emitGuiWriteEvent(
|
||||
response.wasOverwrite
|
||||
? 'outer.gui.item.updated'
|
||||
: 'outer.gui.item.added',
|
||||
fsEntry,
|
||||
guiMetadata,
|
||||
);
|
||||
}, 'emitGuiWriteEvent');
|
||||
await hashEventPromise;
|
||||
results[index] = { ...response, fsEntry };
|
||||
};
|
||||
|
||||
await this.#runNonCritical(async () => {
|
||||
fsEntry = await this.#applyThumbnailAfterWrite(
|
||||
userId,
|
||||
response.fsEntry,
|
||||
response.requestedThumbnail,
|
||||
);
|
||||
}, 'applyThumbnailAfterWrite');
|
||||
const toStore: Array<{
|
||||
index: number;
|
||||
write: Write;
|
||||
thumbnail: string;
|
||||
hashEventPromise: Promise<void>;
|
||||
}> = [];
|
||||
await runWithConcurrencyLimit(
|
||||
writes,
|
||||
concurrency,
|
||||
async (write, index) => {
|
||||
const { fsEntry, contentHashSha256, requestedThumbnail } =
|
||||
write.response;
|
||||
const hashEventPromise = this.#runNonCritical(async () => {
|
||||
await this.#emitWriteHashEvent(
|
||||
contentHashSha256,
|
||||
fsEntry.uuid,
|
||||
);
|
||||
}, 'emitWriteHashEvent');
|
||||
let thumbnail = null as string | null;
|
||||
await this.#runNonCritical(async () => {
|
||||
thumbnail = await this.#thumbnailToStore(
|
||||
fsEntry,
|
||||
requestedThumbnail,
|
||||
);
|
||||
}, 'applyThumbnailAfterWrite');
|
||||
if (thumbnail === null) {
|
||||
await finish(index, write, fsEntry, hashEventPromise);
|
||||
return;
|
||||
}
|
||||
toStore.push({ index, write, thumbnail, hashEventPromise });
|
||||
},
|
||||
);
|
||||
if (toStore.length === 0) return results;
|
||||
|
||||
await this.#runNonCritical(async () => {
|
||||
await this.#emitGuiWriteEvent(
|
||||
response.wasOverwrite
|
||||
? 'outer.gui.item.updated'
|
||||
: 'outer.gui.item.added',
|
||||
fsEntry,
|
||||
guiMetadata,
|
||||
);
|
||||
}, 'emitGuiWriteEvent');
|
||||
// The update is scoped to the caller's rows, so batching anyone else's
|
||||
// would only fail the batch.
|
||||
const own = toStore.filter(
|
||||
({ write }) => write.response.fsEntry.userId === userId,
|
||||
);
|
||||
const groups = [
|
||||
own,
|
||||
...toStore
|
||||
.filter(({ write }) => write.response.fsEntry.userId !== userId)
|
||||
.map((item) => [item]),
|
||||
].filter((group) => group.length > 0);
|
||||
const stored = new Map<number, PromiseSettledResult<FSEntry>>();
|
||||
await runWithConcurrencyLimit(groups, concurrency, async (group) => {
|
||||
let outcomes: PromiseSettledResult<FSEntry>[];
|
||||
try {
|
||||
outcomes = await this.services.fs.updateEntryThumbnails(
|
||||
userId,
|
||||
group.map(({ write, thumbnail }) => ({
|
||||
uuid: write.response.fsEntry.uuid,
|
||||
thumbnail,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
outcomes = group.map(() => ({
|
||||
status: 'rejected',
|
||||
reason: error,
|
||||
}));
|
||||
}
|
||||
group.forEach(({ index }, i) => {
|
||||
const outcome = outcomes[i];
|
||||
if (outcome) stored.set(index, outcome);
|
||||
});
|
||||
});
|
||||
|
||||
await hashEventPromise;
|
||||
|
||||
return { ...response, fsEntry };
|
||||
await runWithConcurrencyLimit(
|
||||
toStore,
|
||||
concurrency,
|
||||
async ({ index, write, hashEventPromise }) => {
|
||||
let fsEntry = write.response.fsEntry;
|
||||
await this.#runNonCritical(async () => {
|
||||
const outcome = stored.get(index);
|
||||
if (outcome?.status === 'rejected') throw outcome.reason;
|
||||
if (outcome) fsEntry = outcome.value;
|
||||
}, 'applyThumbnailAfterWrite');
|
||||
await finish(index, write, fsEntry, hashEventPromise);
|
||||
},
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
#shouldIgnoreUploadPath(targetPath: string): boolean {
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { toPendingUploadSessionKey } from '../../stores/fs/pendingUploadSessionHelpers.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import { FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_MAX_PARTS } from './limits.js';
|
||||
@@ -1256,8 +1257,271 @@ describe('FSController.completeWrite', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// One read for the access check and one for the status decision; the
|
||||
// completion is recorded from the second without reading it back.
|
||||
it('reads the session twice and records the completion from the second copy', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const target = `/${username}/Documents/complete-once.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
const before = (await server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
started.sessionId,
|
||||
))!;
|
||||
|
||||
const reads = countSessionReads();
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
expect(reads.of(started.sessionId)).toBe(2);
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
|
||||
const after = (await server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
started.sessionId,
|
||||
))!;
|
||||
expect(after).toEqual({
|
||||
...before,
|
||||
status: 'completed',
|
||||
failureReason: null,
|
||||
updatedAt: after.completedAt,
|
||||
consumedAt: after.completedAt,
|
||||
completedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('reads each session of a batch completion twice', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const started = [];
|
||||
for (const name of ['batch-once-a.txt', 'batch-once-b.txt']) {
|
||||
const session = await startSignedWrite(
|
||||
actor,
|
||||
`/${username}/Documents/${name}`,
|
||||
2,
|
||||
);
|
||||
await fetch(session.url!, { method: 'PUT', body: 'ab' });
|
||||
started.push(session);
|
||||
}
|
||||
|
||||
const reads = countSessionReads();
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: started.map((session) => ({
|
||||
uploadId: session.sessionId,
|
||||
})),
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(captured.body as Array<{ fsEntry: { path: string } }>).map(
|
||||
(item) => item.fsEntry.path,
|
||||
),
|
||||
).toEqual([
|
||||
`/${username}/Documents/batch-once-a.txt`,
|
||||
`/${username}/Documents/batch-once-b.txt`,
|
||||
]);
|
||||
for (const session of started) {
|
||||
expect(reads.of(session.sessionId)).toBe(2);
|
||||
}
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
for (const session of started) {
|
||||
await expect(
|
||||
server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
session.sessionId,
|
||||
),
|
||||
).resolves.toMatchObject({ status: 'completed' });
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves a malformed id in a batch to the service', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const started = await startSignedWrite(
|
||||
actor,
|
||||
`/${username}/Documents/batch-malformed.txt`,
|
||||
2,
|
||||
);
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: [
|
||||
{ uploadId: started.sessionId },
|
||||
{ uploadId: '' },
|
||||
],
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// The access check reads the session before the status decision; another
|
||||
// request that settles it in between must still turn this one away.
|
||||
const settleDuringAccessCheck = (settle: () => Promise<void>) => {
|
||||
const fs = server.services.fs;
|
||||
const read = fs.getUploadSessions.bind(fs);
|
||||
return vi
|
||||
.spyOn(fs, 'getUploadSessions')
|
||||
.mockImplementationOnce(async (userId, uploadIds) => {
|
||||
const sessions = await read(userId, uploadIds);
|
||||
await settle();
|
||||
return sessions;
|
||||
});
|
||||
};
|
||||
|
||||
it('turns away a completion whose session another one finished during the access check', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const target = `/${username}/Documents/raced-complete.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
|
||||
const spy = settleDuringAccessCheck(async () => {
|
||||
await server.services.fs.completeUrlWrite(userId, {
|
||||
uploadId: started.sessionId,
|
||||
});
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
message: expect.stringContaining('not pending'),
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(
|
||||
server.stores.fsEntry.getPendingEntryBySessionId(started.sessionId),
|
||||
).resolves.toMatchObject({ status: 'completed' });
|
||||
// A completed session's object backs the live file; a late abort must
|
||||
// leave it alone.
|
||||
await server.services.fs.abortUrlWrite(userId, started.sessionId);
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(target))!;
|
||||
const { body } = await server.services.fs.readContent(entry);
|
||||
expect(await readAll(body)).toBe('wxyz');
|
||||
});
|
||||
|
||||
it('turns away a completion whose session was aborted during the access check', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const target = `/${username}/Documents/raced-abort.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
|
||||
const spy = settleDuringAccessCheck(() =>
|
||||
server.services.fs.abortUrlWrite(userId, started.sessionId),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
message: expect.stringContaining('not pending'),
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(
|
||||
server.stores.fsEntry.getPendingEntryBySessionId(started.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
status: 'aborted',
|
||||
failureReason: 'Upload aborted by caller',
|
||||
});
|
||||
await expect(
|
||||
server.stores.fsEntry.getEntryByPath(target),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('turns away a batch completion whose session another one finished during the access check', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const target = `/${username}/Documents/raced-batch.txt`;
|
||||
const started = await startSignedWrite(actor, target, 2);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'ab' });
|
||||
|
||||
const spy = settleDuringAccessCheck(async () => {
|
||||
await server.services.fs.batchCompleteUrlWrite(userId, [
|
||||
{ uploadId: started.sessionId },
|
||||
]);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: [{ uploadId: started.sessionId }],
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
message: expect.stringContaining('not pending'),
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(
|
||||
server.stores.fsEntry.getPendingEntryBySessionId(started.sessionId),
|
||||
).resolves.toMatchObject({ status: 'completed' });
|
||||
});
|
||||
});
|
||||
|
||||
const readAll = async (body: Readable): Promise<string> => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
};
|
||||
|
||||
/** Counts system KV reads of each upload session's key. */
|
||||
const countSessionReads = () => {
|
||||
const get = vi.spyOn(server.stores.kv, 'get');
|
||||
return {
|
||||
of: (sessionId: string) =>
|
||||
get.mock.calls
|
||||
.flatMap(([params]) =>
|
||||
[(params as { key: unknown }).key].flat(),
|
||||
)
|
||||
.filter((key) => key === toPendingUploadSessionKey(sessionId))
|
||||
.length,
|
||||
restore: () => get.mockRestore(),
|
||||
};
|
||||
};
|
||||
|
||||
describe('FSController.abortWrite', () => {
|
||||
it('rejects a request with no uploadId', async () => {
|
||||
const { actor } = await makeUser();
|
||||
@@ -1381,6 +1645,48 @@ describe('FSController.signMultipartParts', () => {
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('reads the session once to sign more parts', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const { res: startRes, captured: startCaptured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startWrite(
|
||||
makeReq<SignedWriteRequest>({
|
||||
body: {
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/sign-once.bin`,
|
||||
size: 8,
|
||||
},
|
||||
uploadMode: 'multipart',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
startRes,
|
||||
),
|
||||
);
|
||||
const started = startCaptured.body as ClientSignedWriteResponse;
|
||||
|
||||
const reads = countSessionReads();
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: { uploadId: started.sessionId, partNumbers: [1] },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.body).toMatchObject({
|
||||
uploadId: started.sessionId,
|
||||
multipartPartUrls: [expect.anything()],
|
||||
});
|
||||
expect(reads.of(started.sessionId)).toBe(1);
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// The session is unknown, so reaching the lookup would answer 404.
|
||||
it.each([
|
||||
[FS_MULTIPART_MAX_PARTS + 1, 400],
|
||||
@@ -1447,6 +1753,66 @@ describe('FSController.batchWrites (json)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('stores a batch of thumbnails in one update and announces each with its thumbnail', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const names = ['th-a.png', 'th-b.png', 'th-c.png', 'th-none.png'];
|
||||
const thumbnailFor = (name: string) =>
|
||||
name === 'th-none.png'
|
||||
? undefined
|
||||
: `data:image/png;base64,${Buffer.from(name).toString('base64')}`;
|
||||
const writeSpy = vi.spyOn(server.clients.db, 'write');
|
||||
const announced: Array<{ response: Record<string, unknown> }> = [];
|
||||
const listener = (_key: string, data: unknown) => {
|
||||
announced.push(data as { response: Record<string, unknown> });
|
||||
};
|
||||
server.clients.event.on('outer.gui.item.added', listener as never);
|
||||
const { res, captured } = makeRes();
|
||||
let statements: string[];
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.batchWrites(
|
||||
makeReq<WriteRequest[]>({
|
||||
body: names.map((name) => ({
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/${name}`,
|
||||
size: 1,
|
||||
},
|
||||
fileContent: 'x',
|
||||
thumbnailData: thumbnailFor(name),
|
||||
})) as WriteRequest[],
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
statements = writeSpy.mock.calls.map(([sql]) => String(sql));
|
||||
writeSpy.mockRestore();
|
||||
server.clients.event.off('outer.gui.item.added', listener as never);
|
||||
}
|
||||
|
||||
expect(
|
||||
statements.filter((sql) => sql.includes('SET thumbnail')),
|
||||
).toHaveLength(1);
|
||||
const body = captured.body as Array<{
|
||||
fsEntry: { path: string; thumbnail?: string | null };
|
||||
}>;
|
||||
expect(body.map((item) => item.fsEntry.path)).toEqual(
|
||||
names.map((name) => `/${username}/Documents/${name}`),
|
||||
);
|
||||
for (const name of names) {
|
||||
const path = `/${username}/Documents/${name}`;
|
||||
const expected = thumbnailFor(name) ?? null;
|
||||
await expect(
|
||||
server.stores.fsEntry.getEntryByUuidFromPrimary(
|
||||
(await server.stores.fsEntry.getEntryByPath(path))!.uuid,
|
||||
),
|
||||
).resolves.toMatchObject({ thumbnail: expected });
|
||||
const event = announced.find((data) => data.response.path === path);
|
||||
expect(event?.response.thumbnail ?? null).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns [] for a non-array body', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
describe('/auth/check-app-acl', () => {
|
||||
let env: PuterTestEnv;
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = (path: string, body: unknown, token: string) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const makeDirAndApp = async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const server = env.server;
|
||||
const user = (await server.stores.user.getByUsername(username))!;
|
||||
const path = `/${username}/Documents/cacl-${uuidv4()}`;
|
||||
expect((await post('/fs/mkdir', { path }, token)).status).toBe(200);
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(path))!;
|
||||
|
||||
const app = await (
|
||||
server.stores.app.create as unknown as (
|
||||
f: Record<string, unknown>,
|
||||
o: { ownerUserId: number },
|
||||
) => Promise<{ uid: string; id: number }>
|
||||
)(
|
||||
{
|
||||
name: `cacl-${uuidv4()}`,
|
||||
title: 'Check app acl',
|
||||
index_url: `https://cacl-${uuidv4()}.test/`,
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
return { user, path, entry, app };
|
||||
};
|
||||
|
||||
it('allows a granted file without poisoning the app permission cache', async () => {
|
||||
const { token } = env.users.user;
|
||||
const server = env.server;
|
||||
const { user, path, entry, app } = await makeDirAndApp();
|
||||
|
||||
const grant = await post(
|
||||
'/auth/grant-user-app',
|
||||
{ app_uid: app.uid, permission: `fs:${entry.uuid}:read` },
|
||||
token,
|
||||
);
|
||||
expect(grant.status).toBe(200);
|
||||
|
||||
const res = await post(
|
||||
'/auth/check-app-acl',
|
||||
{ subject: { path }, app: app.uid, mode: 'read' },
|
||||
token,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ allowed: true });
|
||||
|
||||
const asApp = makeActor({ user, app: { uid: app.uid, id: app.id } });
|
||||
const descriptor = () => server.services.acl.fsDescriptor(path);
|
||||
expect(
|
||||
await server.services.acl.check(asApp, descriptor(), 'read'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies an app with no grant', async () => {
|
||||
const { path, app } = await makeDirAndApp();
|
||||
const res = await post(
|
||||
'/auth/check-app-acl',
|
||||
{ subject: { path }, app: app.uid, mode: 'read' },
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it('refuses app and access tokens', async () => {
|
||||
const { token, apiToken } = env.users.user;
|
||||
const { path, entry, app } = await makeDirAndApp();
|
||||
const grant = await post(
|
||||
'/auth/grant-user-app',
|
||||
{ app_uid: app.uid, permission: `fs:${entry.uuid}:read` },
|
||||
token,
|
||||
);
|
||||
expect(grant.status).toBe(200);
|
||||
|
||||
const minted = await post(
|
||||
'/auth/get-user-app-token',
|
||||
{ app_uid: app.uid },
|
||||
token,
|
||||
);
|
||||
expect(minted.status).toBe(200);
|
||||
const appToken = ((await minted.json()) as { token: string }).token;
|
||||
|
||||
for (const credential of [appToken, apiToken]) {
|
||||
const res = await post(
|
||||
'/auth/check-app-acl',
|
||||
{ subject: { path }, app: app.uid, mode: 'read' },
|
||||
credential,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const write = (path: string, content: string) =>
|
||||
post('/fs/write', {
|
||||
fileMetadata: { path, size: content.length, contentType: 'text/plain' },
|
||||
fileContent: content,
|
||||
});
|
||||
|
||||
const read = async (path: string) => {
|
||||
const url = new URL('/read', env.apiOrigin);
|
||||
url.searchParams.set('file', path);
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${env.users.user.token}` },
|
||||
});
|
||||
return response.text();
|
||||
};
|
||||
|
||||
const makeDir = async (name: string) => {
|
||||
const path = `/${env.users.user.username}/Documents/${name}-${randomUUID().slice(0, 8)}`;
|
||||
expect((await post('/fs/mkdir', { path })).status).toBe(200);
|
||||
return path;
|
||||
};
|
||||
|
||||
describe('legacy /copy onto an existing file', () => {
|
||||
it('replaces the file with overwrite', async () => {
|
||||
const dir = await makeDir('copy-onto');
|
||||
expect((await write(`${dir}/a.txt`, 'aaa')).status).toBe(200);
|
||||
expect((await write(`${dir}/b.txt`, 'bbbbbb')).status).toBe(200);
|
||||
|
||||
const copy = await post('/copy', {
|
||||
source: `${dir}/a.txt`,
|
||||
destination: `${dir}/b.txt`,
|
||||
overwrite: true,
|
||||
});
|
||||
expect(copy.status).toBe(200);
|
||||
const [result] = (await copy.json()) as Array<{
|
||||
copied: { name: string; path: string };
|
||||
overwritten?: { name: string };
|
||||
}>;
|
||||
expect(result!.copied.name).toBe('b.txt');
|
||||
expect(result!.overwritten?.name).toBe('b.txt');
|
||||
expect(await read(`${dir}/b.txt`)).toBe('aaa');
|
||||
expect(await read(`${dir}/a.txt`)).toBe('aaa');
|
||||
});
|
||||
|
||||
it('refuses without overwrite and leaves the file alone', async () => {
|
||||
const dir = await makeDir('copy-onto-refused');
|
||||
await write(`${dir}/a.txt`, 'aaa');
|
||||
await write(`${dir}/b.txt`, 'bbbbbb');
|
||||
|
||||
const copy = await post('/copy', {
|
||||
source: `${dir}/a.txt`,
|
||||
destination: `${dir}/b.txt`,
|
||||
});
|
||||
expect(copy.status).toBe(409);
|
||||
expect(((await copy.json()) as { code: string }).code).toBe(
|
||||
'item_with_same_name_exists',
|
||||
);
|
||||
expect(await read(`${dir}/b.txt`)).toBe('bbbbbb');
|
||||
});
|
||||
|
||||
it('still copies into a directory destination under the same name', async () => {
|
||||
const dir = await makeDir('copy-into');
|
||||
await write(`${dir}/a.txt`, 'aaa');
|
||||
expect((await post('/fs/mkdir', { path: `${dir}/sub` })).status).toBe(
|
||||
200,
|
||||
);
|
||||
|
||||
const copy = await post('/copy', {
|
||||
source: `${dir}/a.txt`,
|
||||
destination: `${dir}/sub`,
|
||||
});
|
||||
expect(copy.status).toBe(200);
|
||||
expect(await read(`${dir}/sub/a.txt`)).toBe('aaa');
|
||||
});
|
||||
|
||||
it('refuses to overwrite a file with itself', async () => {
|
||||
const dir = await makeDir('copy-self');
|
||||
await write(`${dir}/a.txt`, 'aaa');
|
||||
|
||||
for (const destination of [`${dir}/a.txt`, dir]) {
|
||||
const copy = await post('/copy', {
|
||||
source: `${dir}/a.txt`,
|
||||
destination,
|
||||
overwrite: true,
|
||||
});
|
||||
expect(copy.status).toBe(400);
|
||||
}
|
||||
expect(await read(`${dir}/a.txt`)).toBe('aaa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signed folder listing', () => {
|
||||
const signedListing = async (path: string) => {
|
||||
const signed = await post('/sign', {
|
||||
items: [{ path, action: 'read' }],
|
||||
});
|
||||
expect(signed.status).toBe(200);
|
||||
const readUrl = new URL(
|
||||
(await signed.json()).signatures[0].read_url as string,
|
||||
);
|
||||
return fetch(
|
||||
new URL(`${readUrl.pathname}${readUrl.search}`, env.apiOrigin),
|
||||
);
|
||||
};
|
||||
|
||||
it('lists every child of a folder larger than one query', async () => {
|
||||
const dir = await makeDir('big');
|
||||
const parent = (await env.server.stores.fsEntry.getEntryByPath(dir))!;
|
||||
const total = 10_050;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (let start = 0; start < total; start += 500) {
|
||||
const rows: unknown[][] = [];
|
||||
for (let i = start; i < Math.min(total, start + 500); i++) {
|
||||
const name = `f${String(i).padStart(5, '0')}.txt`;
|
||||
rows.push([
|
||||
randomUUID(),
|
||||
parent.userId,
|
||||
parent.id,
|
||||
parent.uuid,
|
||||
name,
|
||||
`${dir}/${name}`,
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
0,
|
||||
]);
|
||||
}
|
||||
await env.server.clients.db.write(
|
||||
`INSERT INTO fsentries (uuid, user_id, parent_id, parent_uid, name, path, is_dir, modified, created, accessed, size) VALUES ${rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`,
|
||||
rows.flat(),
|
||||
);
|
||||
}
|
||||
|
||||
const response = await signedListing(dir);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toMatch(
|
||||
/^application\/json/,
|
||||
);
|
||||
const children = (await response.json()) as Array<{
|
||||
fsentry_name: string;
|
||||
write_url?: string;
|
||||
}>;
|
||||
expect(children).toHaveLength(total);
|
||||
expect(children[0]!.fsentry_name).toBe('f00000.txt');
|
||||
expect(children.at(-1)!.fsentry_name).toBe('f10049.txt');
|
||||
expect(new Set(children.map((c) => c.fsentry_name)).size).toBe(total);
|
||||
expect(children.some((c) => c.write_url)).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
it('answers a small folder in one body', async () => {
|
||||
const dir = await makeDir('small');
|
||||
await write(`${dir}/one.txt`, '1');
|
||||
const response = await signedListing(dir);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-length')).not.toBeNull();
|
||||
const children = (await response.json()) as Array<{
|
||||
fsentry_name: string;
|
||||
}>;
|
||||
expect(children.map((c) => c.fsentry_name)).toEqual(['one.txt']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
* 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 { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
* The v1 download routes over real HTTP: what reaches the client, and what
|
||||
* happens to the storage stream when the client goes away mid-download.
|
||||
*/
|
||||
describe('legacy file downloads over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const writeFile = async (name: string, content: Buffer) => {
|
||||
const { username } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const path = `/${username}/Documents/${name}`;
|
||||
const { fsEntry } = await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: content.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
return { path, uuid: fsEntry.uuid };
|
||||
};
|
||||
|
||||
const signedReadUrl = async (path: string): Promise<URL> => {
|
||||
const signed = await fetch(new URL('/sign', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.users.user.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ items: [{ path, action: 'read' }] }),
|
||||
});
|
||||
expect(signed.status).toBe(200);
|
||||
const readUrl = new URL(
|
||||
(await signed.json()).signatures[0].read_url as string,
|
||||
);
|
||||
return new URL(`${readUrl.pathname}${readUrl.search}`, env.apiOrigin);
|
||||
};
|
||||
|
||||
const accessTokenFor = async (uuid: string): Promise<string> => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(
|
||||
{ user } as never,
|
||||
[[`fs:${uuid}:read`]],
|
||||
{ label: 'legacy-download-http' },
|
||||
);
|
||||
};
|
||||
|
||||
type Route = '/read' | '/token-read' | '/file' | '/down';
|
||||
const routes: Route[] = ['/read', '/token-read', '/file', '/down'];
|
||||
|
||||
const requestFor = async (
|
||||
route: Route,
|
||||
file: { path: string; uuid: string },
|
||||
init: RequestInit & { headers?: Record<string, string> } = {},
|
||||
): Promise<Response> => {
|
||||
const { token, apiToken } = env.users.user;
|
||||
let url: URL;
|
||||
let base: RequestInit & { headers?: Record<string, string> } = {};
|
||||
if (route === '/read') {
|
||||
url = new URL('/read', env.apiOrigin);
|
||||
url.searchParams.set('file', file.path);
|
||||
base = { headers: { Authorization: `Bearer ${token}` } };
|
||||
} else if (route === '/token-read') {
|
||||
url = new URL('/token-read', env.apiOrigin);
|
||||
url.searchParams.set('uid', file.uuid);
|
||||
url.searchParams.set('token', await accessTokenFor(file.uuid));
|
||||
} else if (route === '/file') {
|
||||
url = await signedReadUrl(file.path);
|
||||
} else {
|
||||
url = new URL('/down', env.origin);
|
||||
url.searchParams.set('path', file.path);
|
||||
base = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
};
|
||||
}
|
||||
return fetch(url, {
|
||||
...base,
|
||||
...init,
|
||||
headers: { ...base.headers, ...init.headers },
|
||||
});
|
||||
};
|
||||
|
||||
it.each(routes)(
|
||||
'GET %s returns the stored bytes and headers unchanged',
|
||||
async (route) => {
|
||||
const content = randomBytes(256 * 1024 + 7);
|
||||
const name = `bytes-${route.replace(/\W/g, '')}.bin`;
|
||||
const file = await writeFile(name, content);
|
||||
|
||||
const raw = await requestFor(route, file, {
|
||||
headers: { 'Accept-Encoding': 'identity' },
|
||||
});
|
||||
expect(raw.status).toBe(200);
|
||||
expect(raw.headers.get('content-length')).toBe(
|
||||
String(content.byteLength),
|
||||
);
|
||||
expect(raw.headers.get('content-type')).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
expect(raw.headers.get('content-disposition')).toBe(
|
||||
`${route === '/down' ? 'attachment' : 'inline'}; filename="${name}"`,
|
||||
);
|
||||
expect(raw.headers.get('etag')).toEqual(expect.any(String));
|
||||
expect(raw.headers.get('last-modified')).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
expect(Buffer.from(await raw.arrayBuffer()).equals(content)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Compressed on the wire by default; the bytes still match.
|
||||
const compressed = await requestFor(route, file);
|
||||
expect(compressed.headers.get('content-encoding')).toBe('gzip');
|
||||
expect(
|
||||
Buffer.from(await compressed.arrayBuffer()).equals(content),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(routes)(
|
||||
'GET %s answers a ranged read of an empty file with an empty 200',
|
||||
async (route) => {
|
||||
const file = await writeFile(
|
||||
`empty-${route.replace(/\W/g, '')}.bin`,
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
const ranged = await requestFor(route, file, {
|
||||
headers: { Range: 'bytes=0-511' },
|
||||
});
|
||||
expect(ranged.status).toBe(200);
|
||||
expect(await ranged.text()).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
it('GET /read answers byte_count on an empty file with an empty 200', async () => {
|
||||
const file = await writeFile('empty-byte-count.bin', Buffer.alloc(0));
|
||||
for (const query of ['byte_count=512', 'offset=0']) {
|
||||
const url = new URL(`/read?${query}`, env.apiOrigin);
|
||||
url.searchParams.set('file', file.path);
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${env.users.user.token}` },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
// Storage hands over one chunk and then goes quiet, as a large object
|
||||
// does while the client is still reading.
|
||||
const stallReadsOf = (objectKey: string) => {
|
||||
const s3 = env.server.stores.s3Object;
|
||||
const client = env.server.clients.s3.get(s3.resolveRegion(null));
|
||||
const send = client.send.bind(client);
|
||||
const bodies: Readable[] = [];
|
||||
const spy = vi.spyOn(client, 'send').mockImplementation(((
|
||||
command: unknown,
|
||||
) => {
|
||||
if (
|
||||
!(command instanceof GetObjectCommand) ||
|
||||
command.input.Key !== objectKey
|
||||
) {
|
||||
return send(command as never);
|
||||
}
|
||||
let pushed = false;
|
||||
const body = new Readable({
|
||||
read() {
|
||||
if (!pushed) {
|
||||
pushed = true;
|
||||
this.push(Buffer.alloc(16 * 1024, 1));
|
||||
}
|
||||
},
|
||||
});
|
||||
bodies.push(body);
|
||||
return Promise.resolve({
|
||||
Body: body,
|
||||
ContentLength: 1024 * 1024,
|
||||
ContentType: 'application/octet-stream',
|
||||
});
|
||||
}) as never);
|
||||
return { spy, bodies };
|
||||
};
|
||||
|
||||
it.each(routes)(
|
||||
'GET %s releases the storage stream when the client aborts',
|
||||
async (route) => {
|
||||
const file = await writeFile(
|
||||
`abort-${route.replace(/\W/g, '')}.bin`,
|
||||
Buffer.alloc(1024 * 1024, 1),
|
||||
);
|
||||
const { spy, bodies } = stallReadsOf(file.uuid);
|
||||
try {
|
||||
const abort = new AbortController();
|
||||
// Uncompressed, so the first chunk reaches the client as-is.
|
||||
const response = await requestFor(route, file, {
|
||||
signal: abort.signal,
|
||||
headers: { 'Accept-Encoding': 'identity' },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
abort.abort();
|
||||
|
||||
expect(bodies).toHaveLength(1);
|
||||
await vi.waitFor(() => expect(bodies[0]!.destroyed).toBe(true));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
// The server keeps serving after the aborted transfer.
|
||||
const healthy = await writeFile(
|
||||
`after-abort-${route.replace(/\W/g, '')}.txt`,
|
||||
Buffer.from('still fine'),
|
||||
);
|
||||
const next = await requestFor(route, healthy);
|
||||
expect(next.status).toBe(200);
|
||||
expect(await next.text()).toBe('still fine');
|
||||
},
|
||||
);
|
||||
|
||||
// Storage fails, either before its first byte or after one chunk.
|
||||
const failReadsOf = (objectKey: string, sendFirst: boolean) => {
|
||||
const s3 = env.server.stores.s3Object;
|
||||
const client = env.server.clients.s3.get(s3.resolveRegion(null));
|
||||
const send = client.send.bind(client);
|
||||
return vi.spyOn(client, 'send').mockImplementation(((
|
||||
command: unknown,
|
||||
) => {
|
||||
if (
|
||||
!(command instanceof GetObjectCommand) ||
|
||||
command.input.Key !== objectKey
|
||||
) {
|
||||
return send(command as never);
|
||||
}
|
||||
let reads = 0;
|
||||
const body = new Readable({
|
||||
read() {
|
||||
reads++;
|
||||
if (sendFirst && reads === 1) {
|
||||
this.push(Buffer.alloc(16 * 1024, 1));
|
||||
return;
|
||||
}
|
||||
setImmediate(() =>
|
||||
this.destroy(new Error('storage socket reset')),
|
||||
);
|
||||
},
|
||||
});
|
||||
return Promise.resolve({
|
||||
Body: body,
|
||||
ContentLength: 1024 * 1024,
|
||||
ContentType: 'application/octet-stream',
|
||||
});
|
||||
}) as never);
|
||||
};
|
||||
|
||||
it.each(
|
||||
routes.flatMap((route) => [
|
||||
[route, true] as const,
|
||||
[route, false] as const,
|
||||
]),
|
||||
)(
|
||||
'GET %s drops the connection when storage fails (bytes sent first: %s)',
|
||||
async (route, sendFirst) => {
|
||||
const file = await writeFile(
|
||||
`fail-${sendFirst ? 'mid' : 'start'}-${route.replace(/\W/g, '')}.bin`,
|
||||
Buffer.alloc(1024 * 1024, 1),
|
||||
);
|
||||
const spy = failReadsOf(file.uuid, sendFirst);
|
||||
try {
|
||||
// No error body after a partial one: the client sees the
|
||||
// transfer cut off, either at the headers or mid-body.
|
||||
const outcome = await (async () => {
|
||||
try {
|
||||
const response = await requestFor(route, file, {
|
||||
headers: { 'Accept-Encoding': 'identity' },
|
||||
});
|
||||
await response.arrayBuffer();
|
||||
return `completed ${response.status}`;
|
||||
} catch {
|
||||
return 'cut off';
|
||||
}
|
||||
})();
|
||||
expect(outcome).toBe('cut off');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const healthy = await writeFile(
|
||||
`after-fail-${sendFirst ? 'mid' : 'start'}-${route.replace(/\W/g, '')}.txt`,
|
||||
Buffer.from('still fine'),
|
||||
);
|
||||
const next = await requestFor(route, healthy);
|
||||
expect(next.status).toBe(200);
|
||||
expect(await next.text()).toBe('still fine');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { LegacyFSController } from './LegacyFSController.js';
|
||||
|
||||
const PNG_2X2 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII=';
|
||||
|
||||
let server: PuterServer;
|
||||
let handler: RequestHandler;
|
||||
type Driver = {
|
||||
create: (a: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
api_base_url: 'http://api.test.local',
|
||||
static_hosting_domain: 'site.test',
|
||||
} as unknown as Parameters<typeof setupTestServer>[0]);
|
||||
await server.services.appIcon.ensureIconsDirectory();
|
||||
const controller = server.controllers
|
||||
.legacyFs as unknown as LegacyFSController;
|
||||
const router = new PuterRouter();
|
||||
controller.registerRoutes(router);
|
||||
handler = router.routes.find(
|
||||
(r) =>
|
||||
r.path === '/get-launch-apps' &&
|
||||
String(r.method).toLowerCase() === 'get',
|
||||
)!.handler as RequestHandler;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeActor = async (): Promise<{ actor: Actor; id: number }> => {
|
||||
const username = `v13-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const u = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
const r = (await server.stores.user.getById(u.id))!;
|
||||
return {
|
||||
id: r.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: r.id,
|
||||
uuid: r.uuid,
|
||||
username: r.username,
|
||||
email: r.email ?? null,
|
||||
email_confirmed: true,
|
||||
},
|
||||
} as unknown as Actor,
|
||||
};
|
||||
};
|
||||
|
||||
const createViaDriver = async (actor: Actor, icon: string) =>
|
||||
runWithContext({ actor }, () =>
|
||||
(server.drivers.apps as unknown as Driver).create({
|
||||
object: {
|
||||
name: `v13-${Math.random().toString(36).slice(2, 10)}`,
|
||||
title: 'V',
|
||||
index_url: `https://v13-${Math.random().toString(36).slice(2, 10)}.test/`,
|
||||
icon,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const launchApps = async (actor: Actor, appUid: string, userId: number) => {
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
|
||||
[appUid, userId, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
let body: { recent: Array<Record<string, unknown>> } | undefined;
|
||||
const res = {
|
||||
json: (v: unknown) => {
|
||||
body = v as typeof body;
|
||||
return res;
|
||||
},
|
||||
status: () => res,
|
||||
setHeader: () => res,
|
||||
} as unknown as Response;
|
||||
await handler(
|
||||
{
|
||||
query: { icon_size: '64' },
|
||||
headers: {},
|
||||
body: {},
|
||||
actor,
|
||||
} as unknown as Request,
|
||||
res,
|
||||
() => {},
|
||||
);
|
||||
return body!.recent.find((e) => e.uuid === appUid)!;
|
||||
};
|
||||
|
||||
describe('/get-launch-apps recent icon, verified', () => {
|
||||
it('a valid data-URL icon written through the driver is migrated to a short URL', async () => {
|
||||
const { actor, id } = await makeActor();
|
||||
const app = await createViaDriver(
|
||||
actor,
|
||||
`data:image/png;base64,${PNG_2X2}`,
|
||||
);
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const row = await server.stores.app.getByUid(app.uid as string);
|
||||
expect(String(row?.icon).slice(0, 80)).toMatch(
|
||||
/^https?:\/\/.*\/app-icon\/app-/,
|
||||
);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const entry = await launchApps(actor, app.uid as string, id);
|
||||
expect(String(entry.icon).length).toBeLessThan(200);
|
||||
expect(entry.iconCdnUrl).toMatch(/-64\.png$/);
|
||||
}, 30_000);
|
||||
|
||||
it('returns an icon endpoint when migration cannot decode the stored icon', async () => {
|
||||
const { actor, id } = await makeActor();
|
||||
// PNG signature passes the write-path sniff; sharp rejects the rest.
|
||||
const corrupt = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.alloc(1024 * 1024, 0x41),
|
||||
]).toString('base64');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
let app: Record<string, unknown>;
|
||||
try {
|
||||
app = await createViaDriver(
|
||||
actor,
|
||||
`data:image/png;base64,${corrupt}`,
|
||||
);
|
||||
// Give the background pipeline time to try and fail.
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(
|
||||
warn.mock.calls.some(
|
||||
([m]) => m === '[app-icon] icon processing failed',
|
||||
),
|
||||
).toBe(true),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
const row = await server.stores.app.getByUid(app!.uid as string);
|
||||
expect(String(row?.icon).startsWith('data:image/png')).toBe(true);
|
||||
const entry = await launchApps(actor, app!.uid as string, id);
|
||||
expect(entry.icon).toBe(
|
||||
`http://api.test.local/app-icon/${app!.uid}/64`,
|
||||
);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -467,6 +467,88 @@ describe('LegacyFSController.stat', () => {
|
||||
expect(body.size).toBe(0);
|
||||
});
|
||||
|
||||
it('reports is_empty from the same children a listing returns', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const emptyPath = `/${username}/Documents/probe-empty`;
|
||||
const fullPath = `/${username}/Documents/probe-full`;
|
||||
for (const path of [emptyPath, fullPath]) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({ body: { path }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: `${fullPath}/inner.txt`,
|
||||
size: 5,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: 'hello',
|
||||
} as never);
|
||||
|
||||
for (const path of [emptyPath, fullPath]) {
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(path))!;
|
||||
const listed = await server.stores.fsEntry.listChildren(
|
||||
entry.uuid,
|
||||
{ limit: 1 },
|
||||
);
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_size: true }, actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.is_empty).toBe(listed.length === 0);
|
||||
expect(body.size).toBe(path === fullPath ? 5 : 0);
|
||||
expect(body.owner).toEqual({ username });
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces an earlier failure ahead of a failed subtree size, with nothing left unhandled', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/size-order`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
const flagsFailure = new Error('share flags failed');
|
||||
const sizeFailure = new Error('subtree size failed');
|
||||
const flags = vi
|
||||
.spyOn(server.services.share, 'shareFlags')
|
||||
.mockRejectedValueOnce(flagsFailure);
|
||||
const size = vi
|
||||
.spyOn(server.services.fs, 'getSubtreeSize')
|
||||
.mockRejectedValue(sizeFailure);
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_size: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toBe(flagsFailure);
|
||||
|
||||
// On its own, the size failure is what the caller sees.
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_size: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toBe(sizeFailure);
|
||||
} finally {
|
||||
flags.mockRestore();
|
||||
size.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports the share flag, and the recipients when asked', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
@@ -3650,6 +3732,63 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
);
|
||||
expect(captured.headers['Content-Type']).toMatch(/text\/html/);
|
||||
});
|
||||
|
||||
it('honors offset and byte_count query params on /read', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('0123456789abcdef');
|
||||
const target = `/${username}/Documents/range-test.txt`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: target,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.read(
|
||||
makeReq({
|
||||
query: { file: target, offset: '4', byte_count: '6' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(captured.statusCode).toBe(206);
|
||||
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('456789');
|
||||
});
|
||||
|
||||
it('rejects invalid offset query param with 400', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('test');
|
||||
const target = `/${username}/Documents/invalid-offset.txt`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: target,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
|
||||
const { res } = makeStreamingRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.read(
|
||||
makeReq({
|
||||
query: { file: target, offset: '-1' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /file directory listing ─────────────────────────────────────────
|
||||
@@ -5154,4 +5293,39 @@ describe('LegacyFSController GET /get-launch-apps', () => {
|
||||
|
||||
expect(returned).toEqual(recentUids);
|
||||
});
|
||||
|
||||
it('skips the recent opens for recent=0 and keeps recommended', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const app = await makeHostedApp(userId, 'unregistered-skip');
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
const call = async (query: Record<string, unknown>) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, async () => {
|
||||
await getLaunchAppsHandler()(
|
||||
makeReq({ actor, query }),
|
||||
res,
|
||||
() => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
},
|
||||
);
|
||||
});
|
||||
return captured.body as {
|
||||
recommended: unknown[];
|
||||
recent: unknown[];
|
||||
};
|
||||
};
|
||||
const full = await call({});
|
||||
expect(full.recent.length).toBeGreaterThan(0);
|
||||
|
||||
const opens = vi.spyOn(server.stores.app, 'getRecentAppOpens');
|
||||
try {
|
||||
const skipped = await call({ recent: '0' });
|
||||
expect(skipped.recent).toEqual([]);
|
||||
expect(skipped.recommended).toEqual(full.recommended);
|
||||
expect(opens).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
opens.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Team2faLookup } from '../../core/http/middleware/gates.js';
|
||||
import Busboy from 'busboy';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
@@ -25,11 +26,13 @@ import {
|
||||
assertResolvedActor,
|
||||
isAccessTokenActor,
|
||||
isAppActor,
|
||||
isPlainUserActor,
|
||||
makeActor,
|
||||
} from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertTeam2fa,
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
} from '../../util/fileSigning.js';
|
||||
import { APP_ICON_SIZES } from '../../util/appIcon.js';
|
||||
import { toAppSummary } from '../../util/appView.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import {
|
||||
joinChildPath,
|
||||
normalizeAbsolutePath,
|
||||
@@ -92,6 +96,7 @@ import {
|
||||
assertAccess,
|
||||
assertCanCreate,
|
||||
assertCanMoveInto,
|
||||
contextBoundStream,
|
||||
emitGuiItemEvent,
|
||||
expandClientPath,
|
||||
findOverwriteTarget,
|
||||
@@ -99,7 +104,9 @@ import {
|
||||
getString,
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
rangeForEntry,
|
||||
replacedByMove,
|
||||
resolveReadRange,
|
||||
resolveV1Selector,
|
||||
signEntry,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -209,12 +216,19 @@ export class LegacyFSController extends PuterController {
|
||||
// Limits come from `./limits` and carry an explicit `scope`, so these
|
||||
// draw from the same per-user budget as their v2 counterparts rather
|
||||
// than handing a caller a second allowance for the same operation.
|
||||
const mutate = { ...apiOptions, rateLimit: FS_MUTATE_LIMIT };
|
||||
// Read-side routes admit scoped access tokens: the ACL check each
|
||||
// handler runs already intersects a token's grant with its issuer's,
|
||||
// so the token reaches exactly what it was minted for. That is what a
|
||||
// background events handler acts through — its `user` is a token
|
||||
// scoped to the subscription's grant.
|
||||
//
|
||||
// The entry routes — reads and writes alike — admit scoped access
|
||||
// tokens: the ACL check each handler runs already intersects a token's
|
||||
// grant with its issuer's, so the token reaches exactly what it was
|
||||
// minted for. That is what a background events handler acts through —
|
||||
// its `user` is a token scoped to the subscription's grant — and it
|
||||
// matches what WebDAV admits. Routes reaching past a single entry
|
||||
// (search, df, sign, the app helpers) stay closed to them.
|
||||
const mutate = {
|
||||
...apiOptions,
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
} as RouteOptions;
|
||||
const reads = { ...apiOptions, allowAccessToken: true } as RouteOptions;
|
||||
router.post('/stat', { ...reads, rateLimit: FS_STAT_LIMIT }, this.stat);
|
||||
router.post(
|
||||
@@ -264,6 +278,8 @@ export class LegacyFSController extends PuterController {
|
||||
'/batch',
|
||||
{
|
||||
...spends,
|
||||
// Every op authorizes its own target.
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_BATCH_LIMIT,
|
||||
concurrent: FS_BATCH_CONCURRENT,
|
||||
},
|
||||
@@ -368,7 +384,9 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
let recent: unknown[] = [];
|
||||
const userId = req.actor?.user?.id;
|
||||
if (userId) {
|
||||
// `recent=0`: a caller that only shows `recommended`.
|
||||
const wantsRecent = req.query.recent !== '0';
|
||||
if (userId && wantsRecent) {
|
||||
const recentUids = await this.stores.app.getRecentAppOpens(
|
||||
userId,
|
||||
{ limit: 10 },
|
||||
@@ -405,9 +423,9 @@ export class LegacyFSController extends PuterController {
|
||||
return {
|
||||
...toAppSummary(app, {
|
||||
config: this.config,
|
||||
apiBaseUrl: this.config.api_base_url,
|
||||
iconSize,
|
||||
}),
|
||||
icon: app.icon ?? null,
|
||||
index_url: backingGone ? null : app.index_url,
|
||||
...(backingGone
|
||||
? { privateAccess: buildHostedBackingDenial() }
|
||||
@@ -494,6 +512,16 @@ export class LegacyFSController extends PuterController {
|
||||
'see',
|
||||
);
|
||||
|
||||
// Started early but awaited where it used to run, so its error still
|
||||
// surfaces after the ones below. awaitLater covers the early exits
|
||||
// that never reach that await.
|
||||
const subtreeSize =
|
||||
entry.isDir && getBoolean(body, 'return_size')
|
||||
? awaitLater(
|
||||
this.services.fs.getSubtreeSize(userId, entry.path),
|
||||
)
|
||||
: null;
|
||||
|
||||
const [suggestedApps, appsById, shareFlags, parentUid] =
|
||||
await Promise.all([
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
@@ -521,12 +549,7 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
|
||||
// Optional hydrations:
|
||||
if (entry.isDir && getBoolean(body, 'return_size')) {
|
||||
shaped.size = await this.services.fs.getSubtreeSize(
|
||||
userId,
|
||||
entry.path,
|
||||
);
|
||||
}
|
||||
if (subtreeSize) shaped.size = await subtreeSize;
|
||||
// `return_versions` has no parity yet; the empty array keeps `forEach`
|
||||
// callers working. `return_owner` is a no-op — `owner` is always set.
|
||||
if (getBoolean(body, 'return_versions')) shaped.versions = [];
|
||||
@@ -783,10 +806,23 @@ export class LegacyFSController extends PuterController {
|
||||
this.stores.fsEntry,
|
||||
body.source,
|
||||
);
|
||||
const destinationParent = await resolveV1Selector(
|
||||
const destination = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
body.destination,
|
||||
);
|
||||
// A file destination names the copy itself, replaced only with
|
||||
// `overwrite`.
|
||||
let newName = getString(body, 'new_name');
|
||||
let destinationParent = destination;
|
||||
if (!destination.isDir && !newName && destination.parentUid) {
|
||||
const parent = await this.stores.fsEntry.getEntryByUuid(
|
||||
destination.parentUid,
|
||||
);
|
||||
if (parent) {
|
||||
destinationParent = parent;
|
||||
newName = destination.name;
|
||||
}
|
||||
}
|
||||
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
@@ -811,14 +847,14 @@ export class LegacyFSController extends PuterController {
|
||||
? await findOverwriteTarget(
|
||||
this.stores.fsEntry,
|
||||
destinationParent,
|
||||
getString(body, 'new_name') ?? source.name,
|
||||
newName ?? source.name,
|
||||
)
|
||||
: null;
|
||||
|
||||
const copy = await this.services.fs.copy(userId, {
|
||||
source,
|
||||
destinationParent,
|
||||
newName: getString(body, 'new_name'),
|
||||
newName,
|
||||
overwrite: overwriteRequested,
|
||||
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
|
||||
});
|
||||
@@ -914,7 +950,9 @@ 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 overwrittenEntry = replacedByMove(overwriteTarget, moved, name);
|
||||
const oldPath = source.path;
|
||||
@@ -1269,10 +1307,10 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = rangeForEntry(
|
||||
entry,
|
||||
resolveReadRange(req.headers.range, query),
|
||||
);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -1305,10 +1343,7 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
void pipeToResponse(download.body, res);
|
||||
};
|
||||
|
||||
tokenRead = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -1333,7 +1368,7 @@ export class LegacyFSController extends PuterController {
|
||||
// that guard every other authenticated FS route have to run here.
|
||||
assertNotSuspended(actor!.user);
|
||||
assertVerifiedAccount(actor!.user);
|
||||
await assertTeam2fa(actor!.user, this.stores.team);
|
||||
await assertTeam2fa(actor!.user, this.stores.team as Team2faLookup);
|
||||
|
||||
req.actor = assertResolvedActor(actor!);
|
||||
Context.set('actor', actor);
|
||||
@@ -1391,7 +1426,8 @@ 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: [],
|
||||
};
|
||||
@@ -1439,11 +1475,13 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// ACL: always require read; downgrade write→read silently.
|
||||
// One descriptor for both modes reads the chain at most once.
|
||||
const descriptor = this.services.acl.fsDescriptor(entry.path);
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
actor,
|
||||
entry.path,
|
||||
descriptor,
|
||||
'read',
|
||||
);
|
||||
let finalAction: 'read' | 'write' = 'read';
|
||||
@@ -1455,7 +1493,7 @@ export class LegacyFSController extends PuterController {
|
||||
// publishes the descriptor, so there is nothing to hide.
|
||||
const writeOk = await this.services.acl.check(
|
||||
actor,
|
||||
this.services.acl.fsDescriptor(entry.path),
|
||||
descriptor,
|
||||
'write',
|
||||
);
|
||||
finalAction = writeOk ? 'write' : 'read';
|
||||
@@ -1773,22 +1811,50 @@ export class LegacyFSController extends PuterController {
|
||||
// owner, so the layout it reveals is theirs to reveal, and the child
|
||||
// signatures expire; the paths are the only part that outlives them.
|
||||
if (entry.isDir) {
|
||||
const children = await this.services.fs.listDirectory(entry.uuid);
|
||||
const signedChildren = children.map((child) => {
|
||||
const signChild = (child: FSEntry) => {
|
||||
const { write_url: _, ...rest } = signEntry(child, signingCfg, {
|
||||
ttlSeconds: NON_OWNER_SIGNATURE_TTL_SECONDS,
|
||||
});
|
||||
return { ...rest, path: maskEntryPath(child) };
|
||||
});
|
||||
res.json(signedChildren);
|
||||
};
|
||||
const pages = this.services.fs.listDirectoryPages(entry.uuid);
|
||||
const first = await pages.next();
|
||||
const firstPage = first.done ? [] : first.value;
|
||||
const second = await pages.next();
|
||||
if (second.done) {
|
||||
res.json(firstPage.map(signChild));
|
||||
return;
|
||||
}
|
||||
|
||||
// Past one page the listing streams, so a large folder isn't held
|
||||
// in memory whole. A failure partway cuts the response off.
|
||||
const secondPage = second.value;
|
||||
const serialize = (child: FSEntry) =>
|
||||
JSON.stringify(signChild(child));
|
||||
async function* allPages() {
|
||||
yield firstPage;
|
||||
yield secondPage;
|
||||
yield* pages;
|
||||
}
|
||||
async function* body() {
|
||||
let separator = '[';
|
||||
for await (const page of allPages()) {
|
||||
if (page.length === 0) continue;
|
||||
yield separator + page.map(serialize).join(',');
|
||||
separator = ',';
|
||||
}
|
||||
yield separator === '[' ? '[]' : ']';
|
||||
}
|
||||
res.type('application/json');
|
||||
await pipeToResponse(contextBoundStream(body()), res);
|
||||
return;
|
||||
}
|
||||
|
||||
// File: stream bytes with Range support.
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = rangeForEntry(
|
||||
entry,
|
||||
resolveReadRange(req.headers.range, query),
|
||||
);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -1819,10 +1885,7 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
void pipeToResponse(download.body, res);
|
||||
};
|
||||
|
||||
/** GET|POST /df — user storage allowance. */
|
||||
@@ -1981,12 +2044,20 @@ export class LegacyFSController extends PuterController {
|
||||
*/
|
||||
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
// Sessions only: for an app, this would be a window onto its neighbours' grants.
|
||||
if (!isPlainUserActor(actor))
|
||||
throw new HttpError(403, 'actor must be a user', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const body = asRecord(req.body);
|
||||
|
||||
const subjectRef = body.subject;
|
||||
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',
|
||||
@@ -2008,7 +2079,7 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
|
||||
let app: { uid: string } | null = null;
|
||||
let app: { uid: string; id: number } | null = null;
|
||||
if (typeof appRef === 'string') {
|
||||
app =
|
||||
(await this.stores.app.getByUid(appRef)) ??
|
||||
@@ -2022,7 +2093,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Build an actor-under-user shape for the check.
|
||||
const actorForApp = makeActor({
|
||||
user: actor.user,
|
||||
app: { uid: (app as { uid: string }).uid },
|
||||
app: { uid: app.uid, id: app.id },
|
||||
});
|
||||
const allowed = await acl.check(actorForApp, descriptor, mode);
|
||||
res.json({ allowed });
|
||||
@@ -2070,10 +2141,12 @@ export class LegacyFSController extends PuterController {
|
||||
// by default, so opt in by naming the downloader as who pays.
|
||||
req.egressActor = actor;
|
||||
|
||||
const range =
|
||||
const range = rangeForEntry(
|
||||
entry,
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
: undefined,
|
||||
);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -2092,10 +2165,7 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
void pipeToResponse(download.body, res);
|
||||
};
|
||||
|
||||
async #emitGuiEvent(
|
||||
|
||||
@@ -17,13 +17,20 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Writable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { Context, runWithContext } from '../../core/context.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { createTestUser, setupTestServer } from '../../testUtil.js';
|
||||
import {
|
||||
asRecord,
|
||||
contextBoundStream,
|
||||
fsEntryMimeType,
|
||||
getBoolean,
|
||||
getString,
|
||||
loadLegacyAssociatedApps,
|
||||
rangeForEntry,
|
||||
resolveReadRange,
|
||||
signEntry,
|
||||
signEntryThumbnail,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -360,7 +367,7 @@ describe('toLegacyEntry', () => {
|
||||
|
||||
it('probes for children to set is_empty on a directory', async () => {
|
||||
const fsEntryStore = {
|
||||
listChildren: async () => [{ uuid: 'child' }],
|
||||
hasChildrenByParentUid: async () => true,
|
||||
} as never;
|
||||
const shaped = await toLegacyEntry(
|
||||
undefined,
|
||||
@@ -373,7 +380,7 @@ describe('toLegacyEntry', () => {
|
||||
|
||||
it('treats a failed child probe as non-empty rather than throwing', async () => {
|
||||
const fsEntryStore = {
|
||||
listChildren: async () => {
|
||||
hasChildrenByParentUid: async () => {
|
||||
throw new Error('db down');
|
||||
},
|
||||
} as never;
|
||||
@@ -385,6 +392,61 @@ describe('toLegacyEntry', () => {
|
||||
expect(shaped.is_empty).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves is_empty unset for a directory with no store to ask', async () => {
|
||||
const shaped = await toLegacyEntry(
|
||||
undefined,
|
||||
baseEntry({ isDir: true }),
|
||||
);
|
||||
expect(shaped).not.toHaveProperty('is_empty');
|
||||
});
|
||||
|
||||
it('runs the child probe, owner and thumbnail lookups together, keeping key order', async () => {
|
||||
const started: string[] = [];
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fsEntryStore = {
|
||||
hasChildrenByParentUid: async () => {
|
||||
started.push('children');
|
||||
await gate;
|
||||
return false;
|
||||
},
|
||||
} as never;
|
||||
const userStore = {
|
||||
getById: async () => {
|
||||
started.push('owner');
|
||||
await gate;
|
||||
return { username: 'alice' };
|
||||
},
|
||||
};
|
||||
const eventClient = {
|
||||
emitAndWait: async (_key: string, data: { thumbnail: string }) => {
|
||||
started.push('thumbnail');
|
||||
await gate;
|
||||
data.thumbnail = 'https://signed.example/t.png';
|
||||
},
|
||||
} as never;
|
||||
|
||||
const pending = toLegacyEntry(
|
||||
eventClient,
|
||||
baseEntry({ isDir: true, thumbnail: 's3://bucket/t.png' }),
|
||||
{ fsEntryStore, userStore },
|
||||
);
|
||||
await vi.waitFor(() => expect(started).toHaveLength(3));
|
||||
release();
|
||||
const shaped = await pending;
|
||||
|
||||
expect(shaped).toMatchObject({
|
||||
is_empty: true,
|
||||
owner: { username: 'alice' },
|
||||
thumbnail: 'https://signed.example/t.png',
|
||||
});
|
||||
const keys = Object.keys(shaped);
|
||||
expect(keys.slice(-2)).toEqual(['is_empty', 'owner']);
|
||||
expect(keys.indexOf('thumbnail')).toBe(keys.indexOf('is_public') + 1);
|
||||
});
|
||||
|
||||
it('hydrates the owner as a username-only object', async () => {
|
||||
const userStore = {
|
||||
getById: async () => ({ username: 'alice', email: '[email protected]' }),
|
||||
@@ -522,3 +584,175 @@ describe('signEntry', () => {
|
||||
expect(inSeconds(signed.expires)).toBeLessThanOrEqual(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLegacyEntry is_empty against stored children', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('reports an empty directory as empty, and one with a child as not', async () => {
|
||||
const username = `lfh${Math.random().toString(36).slice(2, 10)}`;
|
||||
await createTestUser(server, { username, password: 'lfh-password' });
|
||||
const fsEntryStore = server.stores.fsEntry;
|
||||
const dir = await fsEntryStore.createNonFileEntry({
|
||||
parent: (await fsEntryStore.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!,
|
||||
name: 'maybe-empty',
|
||||
kind: 'directory',
|
||||
});
|
||||
const shape = async () =>
|
||||
toLegacyEntry(
|
||||
undefined,
|
||||
(await fsEntryStore.getEntryByUuid(dir.uuid))!,
|
||||
{ fsEntryStore },
|
||||
);
|
||||
|
||||
expect((await shape()).is_empty).toBe(true);
|
||||
await fsEntryStore.createNonFileEntry({
|
||||
parent: dir,
|
||||
name: 'child',
|
||||
kind: 'directory',
|
||||
});
|
||||
expect((await shape()).is_empty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveReadRange', () => {
|
||||
it('returns the explicit headersRange when provided', () => {
|
||||
expect(
|
||||
resolveReadRange('bytes=10-20', { offset: 0, byte_count: 5 }),
|
||||
).toBe('bytes=10-20');
|
||||
});
|
||||
|
||||
it('returns undefined when neither headersRange nor range query params are present', () => {
|
||||
expect(resolveReadRange(undefined, {})).toBeUndefined();
|
||||
expect(resolveReadRange('', {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives a byte range from offset and byte_count', () => {
|
||||
expect(resolveReadRange(undefined, { offset: 0, byte_count: 5 })).toBe(
|
||||
'bytes=0-4',
|
||||
);
|
||||
expect(
|
||||
resolveReadRange(undefined, { offset: 10, byte_count: 20 }),
|
||||
).toBe('bytes=10-29');
|
||||
expect(
|
||||
resolveReadRange(undefined, { offset: '4', byte_count: '6' }),
|
||||
).toBe('bytes=4-9');
|
||||
});
|
||||
|
||||
it('derives an open range when only offset is provided', () => {
|
||||
expect(resolveReadRange(undefined, { offset: 10 })).toBe('bytes=10-');
|
||||
expect(resolveReadRange(undefined, { offset: '0' })).toBe('bytes=0-');
|
||||
});
|
||||
|
||||
it('derives a prefix range when only byte_count is provided', () => {
|
||||
expect(resolveReadRange(undefined, { byte_count: 15 })).toBe(
|
||||
'bytes=0-14',
|
||||
);
|
||||
expect(resolveReadRange(undefined, { byte_count: '1' })).toBe(
|
||||
'bytes=0-0',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws 400 on invalid or negative offset', () => {
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: -1, byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 1.5, byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 'abc', byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
});
|
||||
|
||||
it('throws 400 on non-decimal or unsafe numeric strings', () => {
|
||||
for (const offset of [
|
||||
'1e3',
|
||||
'0x10',
|
||||
' 4',
|
||||
'1e21',
|
||||
'9007199254740993',
|
||||
]) {
|
||||
expect(() => resolveReadRange(undefined, { offset })).toThrow(
|
||||
'Invalid offset',
|
||||
);
|
||||
}
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, {
|
||||
offset: String(Number.MAX_SAFE_INTEGER),
|
||||
byte_count: '3',
|
||||
}),
|
||||
).toThrow('Invalid byte_count');
|
||||
});
|
||||
|
||||
it('throws 400 on non-positive or invalid byte_count', () => {
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 0 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: -5 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 2.5 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 'xyz' }),
|
||||
).toThrow('Invalid byte_count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeForEntry', () => {
|
||||
it('drops the range for an empty file only', () => {
|
||||
const entry = (size: number) => ({ size }) as FSEntry;
|
||||
expect(rangeForEntry(entry(0), 'bytes=0-511')).toBeUndefined();
|
||||
expect(rangeForEntry(entry(10), 'bytes=0-4')).toBe('bytes=0-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextBoundStream', () => {
|
||||
it("reads every chunk in the caller's context", async () => {
|
||||
// A sink slower than the source that completes writes from outside
|
||||
// any request, so later chunks are asked for from its drain.
|
||||
const pending: Array<() => void> = [];
|
||||
const flush = runWithContext({}, () =>
|
||||
setInterval(() => pending.splice(0).forEach((done) => done()), 5),
|
||||
);
|
||||
const sink = new Writable({
|
||||
highWaterMark: 1,
|
||||
write(_chunk, _encoding, done) {
|
||||
pending.push(done);
|
||||
},
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
try {
|
||||
await runWithContext({ actor: 'caller' as never }, async () => {
|
||||
async function* chunks() {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
seen.push(Context.get('actor'));
|
||||
yield 'x';
|
||||
}
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
contextBoundStream(chunks())
|
||||
.pipe(sink)
|
||||
.on('finish', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
clearInterval(flush);
|
||||
}
|
||||
expect(seen).toHaveLength(40);
|
||||
expect(seen.every((actor) => actor === 'caller')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { AsyncResource } from 'node:async_hooks';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
type ACLService,
|
||||
type AclMode,
|
||||
type AclRefusalOptions,
|
||||
type ResourceDescriptor,
|
||||
} from '../../services/acl/ACLService.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -98,6 +101,80 @@ export function getBoolean(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Non-negative safe integer from a number or a plain digit string; anything
|
||||
// else (exponents, hex, whitespace) is rejected.
|
||||
function parseByteParam(raw: unknown, name: string, min: number): number {
|
||||
const value =
|
||||
typeof raw === 'number'
|
||||
? raw
|
||||
: typeof raw === 'string' && /^\d+$/.test(raw)
|
||||
? Number(raw)
|
||||
: NaN;
|
||||
if (!Number.isSafeInteger(value) || value < min) {
|
||||
throw new HttpError(400, `Invalid ${name}`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an HTTP Range header string from explicit `headersRange` or fallback
|
||||
* `offset` / `byte_count` query parameters.
|
||||
*/
|
||||
export function resolveReadRange(
|
||||
headersRange: unknown,
|
||||
query: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (typeof headersRange === 'string' && headersRange.length > 0) {
|
||||
return headersRange;
|
||||
}
|
||||
const isSet = (v: unknown) => v !== undefined && v !== null && v !== '';
|
||||
const offset = isSet(query.offset)
|
||||
? parseByteParam(query.offset, 'offset', 0)
|
||||
: undefined;
|
||||
const byteCount = isSet(query.byte_count)
|
||||
? parseByteParam(query.byte_count, 'byte_count', 1)
|
||||
: undefined;
|
||||
|
||||
if (byteCount !== undefined) {
|
||||
const last = (offset ?? 0) + byteCount - 1;
|
||||
if (!Number.isSafeInteger(last)) {
|
||||
throw new HttpError(400, 'Invalid byte_count', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return `bytes=${offset ?? 0}-${last}`;
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
return `bytes=${offset}-`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A response body read from `chunks` for `pipeToResponse`. Each chunk is pulled
|
||||
* in the caller's async context, which share masking reads, not in whichever
|
||||
* socket event asked for more.
|
||||
*/
|
||||
export function contextBoundStream(
|
||||
chunks: AsyncGenerator<string, void, undefined>,
|
||||
): Readable {
|
||||
const next = AsyncResource.bind(() => chunks.next());
|
||||
const stop = AsyncResource.bind(() => chunks.return(undefined));
|
||||
return Readable.from({
|
||||
[Symbol.asyncIterator]: () => ({ next, return: stop }),
|
||||
});
|
||||
}
|
||||
|
||||
/** No range is satisfiable on an empty file, so it reads whole: empty, 200. */
|
||||
export function rangeForEntry(
|
||||
entry: FSEntry,
|
||||
range: string | undefined,
|
||||
): string | undefined {
|
||||
return entry.size === 0 ? undefined : range;
|
||||
}
|
||||
|
||||
// Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }`
|
||||
// from a legacy body field. Returns a resolved entry (throwing 404 if not
|
||||
// found or 400 if no usable ref is present).
|
||||
@@ -129,7 +206,7 @@ const MAX_SELECTOR_DEPTH = 4;
|
||||
* Resolve `ref`, reporting any miss as the same 404 an unreadable entry gets,
|
||||
* so a lookup never echoes a resolved path or tells missing from hidden.
|
||||
*/
|
||||
async function resolveOrNotFound(
|
||||
export async function resolveOrNotFound(
|
||||
fsEntryStore: FSEntryStore,
|
||||
ref: NodeRef,
|
||||
): Promise<FSEntry> {
|
||||
@@ -229,7 +306,7 @@ export async function assertAccess(
|
||||
aclService: ACLService,
|
||||
_fsService: FSService,
|
||||
actor: Actor,
|
||||
path: string,
|
||||
path: string | ResourceDescriptor,
|
||||
mode: AclMode,
|
||||
): Promise<void> {
|
||||
await aclService.assertFsAccess(actor, path, mode, LEGACY_REFUSAL);
|
||||
@@ -474,39 +551,46 @@ export async function toLegacyEntry(
|
||||
...(opts.isShared === undefined ? {} : { is_shared: opts.isShared }),
|
||||
};
|
||||
|
||||
// `is_empty` — only meaningful for directories. Single-row probe so we
|
||||
// don't pay for listing every child.
|
||||
if (entry.isDir && opts.fsEntryStore) {
|
||||
try {
|
||||
const children = await opts.fsEntryStore.listChildren(entry.uuid, {
|
||||
limit: 1,
|
||||
});
|
||||
response.is_empty = children.length === 0;
|
||||
} catch {
|
||||
response.is_empty = false;
|
||||
}
|
||||
} else if (!entry.isDir) {
|
||||
response.is_empty = false;
|
||||
}
|
||||
const { fsEntryStore, userStore } = opts;
|
||||
const [isEmpty, ownerUsername, thumbnail] = await Promise.all([
|
||||
// `is_empty` — only meaningful for directories.
|
||||
entry.isDir && fsEntryStore
|
||||
? (async () => {
|
||||
try {
|
||||
return !(await fsEntryStore.hasChildrenByParentUid(
|
||||
entry.uuid,
|
||||
));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
: undefined,
|
||||
// `owner` — username-only. Matches the legacy safe-entry contract.
|
||||
userStore
|
||||
? (async () => {
|
||||
try {
|
||||
const owner = await userStore.getById(entry.userId);
|
||||
return owner && typeof owner.username === 'string'
|
||||
? owner.username
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined; // best-effort
|
||||
}
|
||||
})()
|
||||
: undefined,
|
||||
// Let the thumbnail extension swap an s3:// key for a signed URL.
|
||||
signEntryThumbnail(
|
||||
eventClient,
|
||||
entry.uuid,
|
||||
response.thumbnail as string | null,
|
||||
),
|
||||
]);
|
||||
|
||||
// `owner` — username-only. Matches the legacy safe-entry contract.
|
||||
if (opts.userStore) {
|
||||
try {
|
||||
const owner = await opts.userStore.getById(entry.userId);
|
||||
if (owner && typeof owner.username === 'string') {
|
||||
response.owner = { username: owner.username };
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Let the thumbnail extension swap an s3:// key for a signed URL.
|
||||
response.thumbnail = await signEntryThumbnail(
|
||||
eventClient,
|
||||
entry.uuid,
|
||||
response.thumbnail as string | null,
|
||||
);
|
||||
if (isEmpty !== undefined) response.is_empty = isEmpty;
|
||||
else if (!entry.isDir) response.is_empty = false;
|
||||
if (ownerUsername !== undefined)
|
||||
response.owner = { username: ownerUsername };
|
||||
response.thumbnail = thumbnail;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* 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 {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
// A scoped access token writes through the `/fs` routes wherever its grant
|
||||
// reaches — the reach it already has over WebDAV — and nowhere else. The ACL
|
||||
// each handler runs intersects the token's grant with its issuer's, so these
|
||||
// check both edges of that, plus the routes that stay closed to tokens.
|
||||
describe('scoped access tokens on the /fs write routes', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = async (route: string, token: string, body: unknown) => {
|
||||
const res = await fetch(new URL(route, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: Record<string, unknown> | null = null;
|
||||
try {
|
||||
json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
// Some refusals are plain text.
|
||||
}
|
||||
return { status: res.status, json, text };
|
||||
};
|
||||
|
||||
/** A user, a token granted `mode` on their Documents, and some fixtures. */
|
||||
const setup = async (mode: 'read' | 'write') => {
|
||||
const user = await createTestUser(env.server, {
|
||||
username: `stw${Math.random().toString(36).slice(2, 9)}`,
|
||||
password: 'puter-test-user-password',
|
||||
});
|
||||
const home = `/${user.username}`;
|
||||
const documents = await post('/stat', user.token, {
|
||||
path: `${home}/Documents`,
|
||||
});
|
||||
await post('/touch', user.token, { path: `${home}/Desktop/kept.txt` });
|
||||
await post('/touch', user.token, { path: `${home}/Documents/in.txt` });
|
||||
const minted = await post('/auth/create-access-token', user.token, {
|
||||
permissions: [`fs:${documents.json!.uid}:${mode}`],
|
||||
});
|
||||
expect(minted.status).toBe(200);
|
||||
return {
|
||||
user,
|
||||
home,
|
||||
token: minted.json!.token as string,
|
||||
exists: async (path: string) =>
|
||||
(await post('/stat', user.token, { path })).status === 200,
|
||||
};
|
||||
};
|
||||
|
||||
it('writes inside its grant on the legacy routes', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
expect(
|
||||
(await post('/mkdir', token, { path: `${docs}/d` })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/touch', token, { path: `${docs}/d/t.txt` })).status,
|
||||
).toBe(200);
|
||||
const t = await post('/stat', token, { path: `${docs}/d/t.txt` });
|
||||
expect(
|
||||
(
|
||||
await post('/rename', token, {
|
||||
uid: t.json!.uid,
|
||||
new_name: 'r.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/copy', token, {
|
||||
source: `${docs}/d/r.txt`,
|
||||
destination: docs,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/move', token, {
|
||||
source: `${docs}/r.txt`,
|
||||
destination: `${docs}/d`,
|
||||
new_name: 'm.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/delete', token, { paths: [`${docs}/d/m.txt`] }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const batch = await post('/batch', token, {
|
||||
operations: [
|
||||
{ op: 'mkdir', path: `${docs}/b` },
|
||||
{ op: 'delete', path: `${docs}/d/r.txt` },
|
||||
],
|
||||
});
|
||||
expect(batch.status, batch.text).toBe(200);
|
||||
|
||||
expect(await exists(`${docs}/b`)).toBe(true);
|
||||
expect(await exists(`${docs}/d/r.txt`)).toBe(false);
|
||||
expect(await exists(`${docs}/d/m.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('writes inside its grant on the v2 routes', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
expect(
|
||||
(await post('/fs/mkdir', token, { path: `${docs}/v` })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/fs/touch', token, { path: `${docs}/v/t.txt` }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const written = await post('/fs/write', token, {
|
||||
fileMetadata: {
|
||||
path: `${docs}/v/w.txt`,
|
||||
size: 3,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: 'abc',
|
||||
});
|
||||
expect(written.status, written.text).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/rename', token, {
|
||||
path: `${docs}/v/t.txt`,
|
||||
new_name: 'r.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/copy', token, {
|
||||
source: `${docs}/v/r.txt`,
|
||||
destination: docs,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/move', token, {
|
||||
source: `${docs}/r.txt`,
|
||||
destination: `${docs}/v`,
|
||||
new_name: 'm.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/mkshortcut', token, {
|
||||
parent: docs,
|
||||
target: `${docs}/v/w.txt`,
|
||||
name: 'w-link',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/fs/delete', token, { path: `${docs}/v/m.txt` }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const started = await post('/fs/startWrite', token, {
|
||||
fileMetadata: { path: `${docs}/v/up.bin`, size: 4 },
|
||||
});
|
||||
expect(started.status, started.text).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/abortWrite', token, {
|
||||
uploadId: started.json!.sessionId,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
expect(await exists(`${docs}/v/w.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/w-link`)).toBe(true);
|
||||
expect(await exists(`${docs}/v/m.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('is refused everywhere outside its grant', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
const desktop = `${home}/Desktop`;
|
||||
|
||||
const attempts = [
|
||||
['/mkdir', { path: `${desktop}/x` }],
|
||||
['/touch', { path: `${desktop}/x.txt` }],
|
||||
['/move', { source: `${docs}/in.txt`, destination: desktop }],
|
||||
['/copy', { source: `${desktop}/kept.txt`, destination: docs }],
|
||||
['/delete', { paths: [`${desktop}/kept.txt`] }],
|
||||
['/delete', { paths: [home] }],
|
||||
['/fs/mkdir', { path: `${desktop}/x` }],
|
||||
[
|
||||
'/fs/write',
|
||||
{
|
||||
fileMetadata: {
|
||||
path: `${desktop}/kept.txt`,
|
||||
size: 1,
|
||||
overwrite: true,
|
||||
},
|
||||
fileContent: 'x',
|
||||
},
|
||||
],
|
||||
['/fs/move', { source: `${docs}/in.txt`, destination: desktop }],
|
||||
['/fs/copy', { source: `${desktop}/kept.txt`, destination: docs }],
|
||||
['/fs/rename', { path: `${desktop}/kept.txt`, new_name: 'y.txt' }],
|
||||
['/fs/delete', { path: `${desktop}/kept.txt` }],
|
||||
['/fs/delete', { path: home }],
|
||||
] as const;
|
||||
for (const [route, body] of attempts) {
|
||||
const res = await post(route, token, body);
|
||||
expect(
|
||||
[403, 404],
|
||||
`${route} ${JSON.stringify(body)} → ${res.status}`,
|
||||
).toContain(res.status);
|
||||
}
|
||||
|
||||
const batch = await post('/batch', token, {
|
||||
operations: [{ op: 'mkdir', path: `${desktop}/b` }],
|
||||
});
|
||||
expect(batch.status).not.toBe(200);
|
||||
|
||||
expect(await exists(`${desktop}/kept.txt`)).toBe(true);
|
||||
expect(await exists(`${desktop}/x`)).toBe(false);
|
||||
expect(await exists(`${desktop}/b`)).toBe(false);
|
||||
expect(await exists(`${docs}/in.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/kept.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('refuses every write to a read-only token', async () => {
|
||||
const { home, token, exists } = await setup('read');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
const attempts = [
|
||||
['/mkdir', { path: `${docs}/x` }],
|
||||
['/touch', { path: `${docs}/x.txt` }],
|
||||
['/delete', { paths: [`${docs}/in.txt`] }],
|
||||
['/fs/mkdir', { path: `${docs}/x` }],
|
||||
[
|
||||
'/fs/write',
|
||||
{
|
||||
fileMetadata: { path: `${docs}/x.txt`, size: 1 },
|
||||
fileContent: 'x',
|
||||
},
|
||||
],
|
||||
[
|
||||
'/fs/startWrite',
|
||||
{ fileMetadata: { path: `${docs}/x.bin`, size: 1 } },
|
||||
],
|
||||
['/fs/rename', { path: `${docs}/in.txt`, new_name: 'y.txt' }],
|
||||
['/fs/delete', { path: `${docs}/in.txt` }],
|
||||
] as const;
|
||||
for (const [route, body] of attempts) {
|
||||
const res = await post(route, token, body);
|
||||
expect(res.status, `${route} → ${res.status}`).toBe(403);
|
||||
}
|
||||
expect(await exists(`${docs}/in.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/x`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
// An upload session belongs to the account, not to the credential that
|
||||
// opened it, so each later step re-checks the target against the caller.
|
||||
it('cannot touch an upload the session opened outside its grant', async () => {
|
||||
const { user, home, token } = await setup('write');
|
||||
const started = await post('/fs/startWrite', user.token, {
|
||||
fileMetadata: { path: `${home}/Desktop/up.bin`, size: 4 },
|
||||
});
|
||||
expect(started.status).toBe(200);
|
||||
const uploadId = started.json!.sessionId as string;
|
||||
|
||||
for (const [route, body] of [
|
||||
['/fs/abortWrite', { uploadId }],
|
||||
['/fs/completeWrite', { uploadId }],
|
||||
['/fs/signMultipartParts', { uploadId, partNumbers: [1] }],
|
||||
] as const) {
|
||||
const res = await post(route, token, body);
|
||||
expect(
|
||||
[403, 404],
|
||||
`${route} → ${res.status} ${res.text}`,
|
||||
).toContain(res.status);
|
||||
}
|
||||
|
||||
// Still open: the session's own abort finds it to abort.
|
||||
const sessions = await env.server.services.fs.getUploadSessions(
|
||||
(await env.server.stores.user.getByUsername(user.username))!.id,
|
||||
[uploadId],
|
||||
);
|
||||
expect(sessions[0]?.status).not.toBe('aborted');
|
||||
expect(
|
||||
(await post('/fs/abortWrite', user.token, { uploadId })).status,
|
||||
).toBe(200);
|
||||
}, 180_000);
|
||||
|
||||
it('aborting an unknown upload stays a no-op', async () => {
|
||||
const { token } = await setup('write');
|
||||
const res = await post('/fs/abortWrite', token, {
|
||||
uploadId: 'no-such-upload',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}, 180_000);
|
||||
|
||||
it('keeps the routes that reach past one entry closed', async () => {
|
||||
const { home, token } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
for (const [route, body] of [
|
||||
['/sign', { items: [{ path: `${docs}/in.txt`, action: 'read' }] }],
|
||||
['/search', { text: 'in' }],
|
||||
['/fs/search', { query: 'in' }],
|
||||
['/df', {}],
|
||||
['/readdir-subdomains', {}],
|
||||
] as const) {
|
||||
const res = await post(route, token, body);
|
||||
expect(res.status, `${route} → ${res.status}`).toBe(403);
|
||||
expect(res.json?.message ?? res.text).toMatch(
|
||||
/Access tokens are not allowed/,
|
||||
);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -194,4 +194,87 @@ describe('writes inside a folder shared with write', () => {
|
||||
(moved.json!.moved as { path: string }).path,
|
||||
).toBe(`${s.maskedDir}/renamed.txt`);
|
||||
}, 180_000);
|
||||
|
||||
// The thumbnail update is scoped to the writer's own rows, so a file the
|
||||
// owner ends up owning keeps none, while the writer's own file in the same
|
||||
// batch gets its thumbnail.
|
||||
it('batchWrite stores thumbnails on the writer’s own files only', async () => {
|
||||
const s = await shareAFolder();
|
||||
const png =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
const ownPath = `/${s.recipient.username}/Documents/own.png`;
|
||||
const written = await post('/fs/batchWrite', s.recipient.token, [
|
||||
{
|
||||
fileMetadata: { path: `${s.maskedDir}/shared.png`, size: 1 },
|
||||
fileContent: 'a',
|
||||
thumbnailData: png,
|
||||
},
|
||||
{
|
||||
fileMetadata: { path: ownPath, size: 1 },
|
||||
fileContent: 'b',
|
||||
thumbnailData: png,
|
||||
},
|
||||
]);
|
||||
expect(written.status).toBe(200);
|
||||
|
||||
const stored = async (path: string) =>
|
||||
env.server.stores.fsEntry.getEntryByUuidFromPrimary(
|
||||
(await env.server.stores.fsEntry.getEntryByPath(path))!.uuid,
|
||||
);
|
||||
expect((await stored(`${s.realDir}/shared.png`))?.thumbnail).toBeNull();
|
||||
expect((await stored(ownPath))?.thumbnail).toMatch(/^s3:\/\//);
|
||||
}, 180_000);
|
||||
|
||||
// The recipient only knows the masked path, so refusals name entries the
|
||||
// way the recipient addressed them.
|
||||
it('keeps the owner’s real path out of error messages', async () => {
|
||||
const s = await shareAFolder();
|
||||
await post('/touch', s.owner.token, {
|
||||
path: `${s.realDir}/other.txt`,
|
||||
});
|
||||
|
||||
const cases = [
|
||||
{
|
||||
res: await post('/fs/stat', s.recipient.token, {
|
||||
path: `${s.maskedDir}/missing.txt`,
|
||||
}),
|
||||
status: 404,
|
||||
message: 'Subject does not exist',
|
||||
},
|
||||
{
|
||||
res: await post('/fs/mkdir', s.recipient.token, {
|
||||
path: `${s.maskedDir}/note.txt`,
|
||||
}),
|
||||
status: 409,
|
||||
message: 'An entry named `note.txt` already exists',
|
||||
},
|
||||
{
|
||||
res: await post('/fs/mkdir', s.recipient.token, {
|
||||
path: `${s.maskedDir}/missing/child`,
|
||||
}),
|
||||
status: 404,
|
||||
message: 'Parent path does not exist: missing',
|
||||
},
|
||||
{
|
||||
res: await post('/fs/rename', s.recipient.token, {
|
||||
uid: s.noteUid,
|
||||
new_name: 'other.txt',
|
||||
}),
|
||||
status: 409,
|
||||
message: 'An entry named `other.txt` already exists',
|
||||
},
|
||||
{
|
||||
res: await post('/mkdir', s.recipient.token, {
|
||||
path: `${s.maskedDir}/note.txt`,
|
||||
}),
|
||||
status: 409,
|
||||
message: 'An entry named `note.txt` already exists',
|
||||
},
|
||||
];
|
||||
for (const { res, status, message } of cases) {
|
||||
expect(res.status).toBe(status);
|
||||
expect(res.json!.message).toBe(message);
|
||||
expect(JSON.stringify(res.json)).not.toContain(s.realDir);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -17,6 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -466,3 +469,63 @@ describe('HomepageController GET /show/*splat', () => {
|
||||
expect(html).toContain('/alice/Documents');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /sw.js ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('HomepageController GET /sw.js', () => {
|
||||
const headersOf = (res: Response): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
(res.set as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
([key, value]) => [String(key).toLowerCase(), String(value)],
|
||||
),
|
||||
);
|
||||
|
||||
it('serves the worker with root scope, as JavaScript, revalidated each time', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'puter-sw-route-'));
|
||||
await mkdir(path.join(root, 'dist'));
|
||||
await writeFile(path.join(root, 'dist', 'sw.js'), '// worker\n');
|
||||
const config = server.services.homepage.config as {
|
||||
gui_assets_root?: string;
|
||||
env?: string;
|
||||
};
|
||||
const prev = { root: config.gui_assets_root, env: config.env };
|
||||
config.gui_assets_root = root;
|
||||
config.env = 'prod';
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute('get', '/sw.js', makeReq({ path: '/sw.js' }), res);
|
||||
} finally {
|
||||
config.gui_assets_root = prev.root;
|
||||
config.env = prev.env;
|
||||
}
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(captured.body).toBe('// worker\n');
|
||||
expect(headersOf(res)).toMatchObject({
|
||||
'content-type': 'text/javascript; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
'service-worker-allowed': '/',
|
||||
});
|
||||
});
|
||||
|
||||
it('is a 404 when there is no worker to serve', async () => {
|
||||
const config = server.services.homepage.config as {
|
||||
gui_assets_root?: string;
|
||||
};
|
||||
const prev = config.gui_assets_root;
|
||||
config.gui_assets_root = path.join(tmpdir(), 'no-such-gui-root');
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute('get', '/sw.js', makeReq({ path: '/sw.js' }), res);
|
||||
} finally {
|
||||
config.gui_assets_root = prev;
|
||||
}
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('is registered for the root subdomain only', () => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === '/sw.js',
|
||||
);
|
||||
expect(route?.options.subdomain).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -198,6 +198,20 @@ export class HomepageController extends PuterController {
|
||||
return sendShell(req, res, {}, launch);
|
||||
});
|
||||
|
||||
// -- /sw.js - the service worker, scoped to the whole origin --
|
||||
|
||||
router.get('/sw.js', { subdomain: '' }, async (_req, res) => {
|
||||
const source = await homepage.serviceWorkerSource();
|
||||
if (source === null) {
|
||||
res.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
res.set('Content-Type', 'text/javascript; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-cache');
|
||||
res.set('Service-Worker-Allowed', '/');
|
||||
res.send(source);
|
||||
});
|
||||
|
||||
// -- Fallback static mount -----------------------------------
|
||||
// Serves lingering GUI assets (images, fonts, lib files, etc.)
|
||||
// out of <gui_assets_root>/src. Falls through to the 404 handler
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('HostingController /delete-site', () => {
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
'subdomain.delete',
|
||||
{ subdomain: site.subdomain, uid: String(site.uuid) },
|
||||
{ subdomain: site.subdomain, uid: String(site.uuid), userId },
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -27,12 +27,12 @@ import { EventsController } from './events/EventsController.js';
|
||||
import { FSController } from './fs/FSController.js';
|
||||
import { HomepageController } from './homepage/HomepageController.js';
|
||||
import { HostingController } from './hosting/HostingController.js';
|
||||
import { KvShareController } from './kvShare/KvShareController.js';
|
||||
import { LegacyFSController } from './fs/LegacyFSController.js';
|
||||
import { NotificationController } from './notification/NotificationController.js';
|
||||
import { OIDCController } from './oidc/OIDCController.js';
|
||||
import { PuterAIController } from './puterai/PuterAIController.js';
|
||||
import { ShareController } from './share/ShareController.js';
|
||||
import { TeamController } from './team/TeamController.js';
|
||||
import { StaticAssetsController } from './static/StaticAssetsController.js';
|
||||
import { StaticPagesController } from './static/StaticPagesController.js';
|
||||
import { SystemController } from './system/SystemController.js';
|
||||
@@ -59,8 +59,8 @@ export const puterControllers = {
|
||||
broadcast: BroadcastController,
|
||||
notification: NotificationController,
|
||||
events: EventsController,
|
||||
kvShare: KvShareController,
|
||||
share: ShareController,
|
||||
team: TeamController,
|
||||
webdav: WebDAVController,
|
||||
oidc: OIDCController,
|
||||
wisp: WispController,
|
||||
|
||||
@@ -0,0 +1,807 @@
|
||||
/*
|
||||
* 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 { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
type TestUserCredentials,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { claimRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { seedKvShareHandles } from '../../services/kvShare/kvShareTestUtil.js';
|
||||
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
|
||||
import {
|
||||
EVENTS_KV_HANDLE_LIMIT,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
KV_SHARE_BATCH_MAX,
|
||||
KV_SHARE_GRANT_LIMITS,
|
||||
} from '../events/limits.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
const call = (
|
||||
env: PuterTestEnv,
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const resetBudgets = async (env: PuterTestEnv) => {
|
||||
for (const name of [env.users.user.username, env.users.other.username]) {
|
||||
const user = await env.server.stores.user.getByUsername(name);
|
||||
for (const scope of [
|
||||
EVENTS_KV_HANDLE_LIMIT.scope,
|
||||
...KV_SHARE_GRANT_LIMITS.map((window) => window.scope),
|
||||
])
|
||||
await env.server.clients.redis.del(`rate:${scope}:${user!.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
describe('/kv/shares', () => {
|
||||
let env: PuterTestEnv;
|
||||
let ownerUuid: string;
|
||||
const req = (
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: unknown,
|
||||
) => call(env, method, path, token, body);
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
} as IConfig);
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
ownerUuid = owner!.uuid as string;
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetBudgets(env);
|
||||
});
|
||||
|
||||
const shareWithOther = async (body: Record<string, unknown> = {}) => {
|
||||
const res = await req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: `http-${uuidv4().slice(0, 8)}:`,
|
||||
recipient: env.users.other.username,
|
||||
...body,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
it('shares at read by default and answers in the owner`s shape', async () => {
|
||||
const shared = await shareWithOther({ label: 'Groceries' });
|
||||
expect(Object.keys(shared).sort()).toEqual(
|
||||
[
|
||||
'access',
|
||||
'appUid',
|
||||
'createdAt',
|
||||
'handle',
|
||||
'holder',
|
||||
'label',
|
||||
'prefix',
|
||||
].sort(),
|
||||
);
|
||||
expect(shared).toMatchObject({
|
||||
access: 'read',
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Groceries',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the recipient as an object too, and refuses an address', async () => {
|
||||
await shareWithOther({
|
||||
recipient: { username: env.users.other.username },
|
||||
access: 'write',
|
||||
});
|
||||
const refused = await req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: 'address:',
|
||||
recipient: '[email protected]',
|
||||
});
|
||||
expect(refused.status).toBe(400);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'invalid_recipient',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists what was shared out, and what was shared with the holder without the owner', async () => {
|
||||
const shared = await shareWithOther({ access: 'write', label: 'Trip' });
|
||||
|
||||
const byMe = await req(
|
||||
'GET',
|
||||
'/kv/shares?limit=200',
|
||||
env.users.user.token,
|
||||
);
|
||||
const byMePage = (await byMe.json()) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
byMePage.items.find((row) => row.handle === shared.handle),
|
||||
).toEqual({
|
||||
handle: shared.handle,
|
||||
prefix: shared.prefix,
|
||||
access: 'write',
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Trip',
|
||||
createdAt: shared.createdAt,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
});
|
||||
|
||||
const withMe = await req(
|
||||
'GET',
|
||||
'/kv/shares/with-me?limit=200',
|
||||
env.users.other.token,
|
||||
);
|
||||
const withMeText = await withMe.text();
|
||||
const withMePage = JSON.parse(withMeText) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
withMePage.items.find((row) => row.handle === shared.handle),
|
||||
).toEqual({
|
||||
handle: shared.handle,
|
||||
access: 'write',
|
||||
appUid: null,
|
||||
label: 'Trip',
|
||||
createdAt: shared.createdAt,
|
||||
});
|
||||
expect(withMeText).not.toContain(env.users.user.username);
|
||||
expect(withMeText).not.toContain(ownerUuid);
|
||||
expect(withMeText).not.toContain(String(shared.prefix));
|
||||
});
|
||||
|
||||
it('lets the holder unshare, and shows the owner they did', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
await expect(removed.json()).resolves.toMatchObject({
|
||||
handle: shared.handle,
|
||||
});
|
||||
|
||||
const byMe = (await (
|
||||
await req('GET', '/kv/shares?limit=200', env.users.user.token)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
expect(
|
||||
byMe.items.find((row) => row.handle === shared.handle),
|
||||
).toMatchObject({ revokedBy: 'holder' });
|
||||
});
|
||||
|
||||
it('keeps the owner and prefix out of the holder`s grant audit after they unshare', async () => {
|
||||
const shared = await shareWithOther({ access: 'write' });
|
||||
const segment = String(shared.prefix).slice(0, -1);
|
||||
const other = await env.server.stores.user.getByUsername(
|
||||
env.users.other.username,
|
||||
);
|
||||
// A row an earlier build filed under the holder.
|
||||
await env.server.stores.permission.auditUserUserPerm({
|
||||
holder_user_id: other!.id,
|
||||
issuer_user_id: other!.id,
|
||||
permission: `kv-share-write:${ownerUuid}:os-global:${segment}`,
|
||||
action: 'revoke',
|
||||
reason: 'kv share handle unshared',
|
||||
});
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
|
||||
// The revoke is filed under the owner, who granted it.
|
||||
const auditOf = async (token: string) => {
|
||||
const res = await req('GET', '/share/audit?limit=200', token);
|
||||
expect(res.status).toBe(200);
|
||||
return await res.text();
|
||||
};
|
||||
await vi.waitFor(async () => {
|
||||
const owners = JSON.parse(await auditOf(env.users.user.token)) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(owners.items).toContainEqual(
|
||||
expect.objectContaining({
|
||||
action: 'revoke',
|
||||
issuer: env.users.user.username,
|
||||
holder: env.users.other.username,
|
||||
permission: expect.stringContaining(segment),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const holders = await auditOf(env.users.other.token);
|
||||
expect(holders).not.toContain(ownerUuid);
|
||||
expect(holders).not.toContain(segment);
|
||||
});
|
||||
|
||||
it('lets the owner revoke', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
});
|
||||
|
||||
it('answers anyone else, and an unknown handle, with the same 404', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const stranger = await createTestUser(env.server, {
|
||||
username: `kvs-${uuidv4().slice(0, 8)}`,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
const strangers = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
stranger.token,
|
||||
);
|
||||
const unknownHandle = `kvh-${uuidv4()}`;
|
||||
const unknown = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${unknownHandle}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(strangers.status).toBe(404);
|
||||
expect(unknown.status).toBe(404);
|
||||
const a = (await strangers.json()) as Record<string, unknown>;
|
||||
const b = (await unknown.json()) as Record<string, unknown>;
|
||||
expect(a.code).toBe(b.code);
|
||||
expect(a.message).toBe(`No such handle: ${shared.handle}`);
|
||||
expect(b.message).toBe(`No such handle: ${unknownHandle}`);
|
||||
});
|
||||
|
||||
it('turns an anonymous caller away', async () => {
|
||||
const res = await req('GET', '/kv/shares');
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
describe('filtering a listing', () => {
|
||||
type Page = { items: Array<Record<string, unknown>>; total?: number };
|
||||
let appUid: string;
|
||||
let inApp: Record<string, unknown>;
|
||||
let global: Record<string, unknown>;
|
||||
|
||||
const page = async (path: string, token: string) => {
|
||||
const res = await req('GET', path, token);
|
||||
return { status: res.status, body: (await res.json()) as Page };
|
||||
};
|
||||
const handlesOf = (body: Page) => body.items.map((row) => row.handle);
|
||||
|
||||
const makeApp = async () => {
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const name = `kv-http-${uuidv4().slice(0, 8)}`;
|
||||
return env.server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'Listing App',
|
||||
index_url: `https://${name}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: owner!.id },
|
||||
);
|
||||
};
|
||||
|
||||
/** The token the desktop launches the app with, for `token`'s user. */
|
||||
const appToken = async (token: string, uid: string) => {
|
||||
const res = await req('POST', '/auth/get-user-app-token', token, {
|
||||
app_uid: uid,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetBudgets(env);
|
||||
appUid = (await makeApp()).uid;
|
||||
inApp = await shareWithOther({ appUid });
|
||||
global = await shareWithOther();
|
||||
});
|
||||
|
||||
it('lists every app`s shares for the account, or one app`s, or the app-less store`s with `null`', async () => {
|
||||
for (const path of ['/kv/shares', '/kv/shares/with-me']) {
|
||||
const token =
|
||||
path === '/kv/shares'
|
||||
? env.users.user.token
|
||||
: env.users.other.token;
|
||||
const all = await page(`${path}?limit=200`, token);
|
||||
expect(handlesOf(all.body)).toEqual(
|
||||
expect.arrayContaining([inApp.handle, global.handle]),
|
||||
);
|
||||
|
||||
const one = await page(
|
||||
`${path}?limit=200&appUid=${appUid}`,
|
||||
token,
|
||||
);
|
||||
expect(handlesOf(one.body)).toEqual([inApp.handle]);
|
||||
|
||||
const appLess = await page(
|
||||
`${path}?limit=200&appUid=null`,
|
||||
token,
|
||||
);
|
||||
expect(handlesOf(appLess.body)).toContain(global.handle);
|
||||
expect(
|
||||
appLess.body.items.every((row) => row.appUid === null),
|
||||
).toBe(true);
|
||||
|
||||
const bad = await page(`${path}?appUid=not-an-app`, token);
|
||||
expect(bad.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an app to its own namespace, refusing any other it names', async () => {
|
||||
const token = await appToken(env.users.other.token, appUid);
|
||||
const own = await page('/kv/shares/with-me?limit=200', token);
|
||||
expect(handlesOf(own.body)).toEqual([inApp.handle]);
|
||||
const named = await page(
|
||||
`/kv/shares/with-me?limit=200&appUid=${appUid}`,
|
||||
token,
|
||||
);
|
||||
expect(handlesOf(named.body)).toEqual([inApp.handle]);
|
||||
|
||||
const otherApp = (await makeApp()).uid;
|
||||
for (const appUidParam of ['null', otherApp]) {
|
||||
const refused = await page(
|
||||
`/kv/shares/with-me?appUid=${appUidParam}`,
|
||||
token,
|
||||
);
|
||||
expect(refused.status).toBe(403);
|
||||
expect(refused.body).toMatchObject({
|
||||
code: 'events_kv_handle_outside_namespace',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('narrows what was shared out to live or revoked shares, all by default', async () => {
|
||||
const gone = await shareWithOther({ appUid });
|
||||
await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${gone.handle}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
const at = async (state?: string) =>
|
||||
page(
|
||||
`/kv/shares?limit=200&appUid=${appUid}` +
|
||||
(state ? `&state=${state}` : ''),
|
||||
env.users.user.token,
|
||||
);
|
||||
|
||||
expect(handlesOf((await at()).body)).toEqual([
|
||||
inApp.handle,
|
||||
gone.handle,
|
||||
]);
|
||||
expect(handlesOf((await at('all')).body)).toEqual([
|
||||
inApp.handle,
|
||||
gone.handle,
|
||||
]);
|
||||
expect(handlesOf((await at('live')).body)).toEqual([inApp.handle]);
|
||||
expect(handlesOf((await at('revoked')).body)).toEqual([
|
||||
gone.handle,
|
||||
]);
|
||||
const bad = await at('stopped');
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with several recipients', () => {
|
||||
let third: { username: string; uuid: string; id: number };
|
||||
// Holds seeded rows, so no recipient's per-holder cap is what trips.
|
||||
let filler: number;
|
||||
let ownerId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const username = `kv-batch-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
third = { username, uuid: row!.uuid as string, id: row!.id! };
|
||||
ownerId = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!.id!;
|
||||
const fillerName = `kv-filler-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username: fillerName,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
filler = (await env.server.stores.user.getByUsername(fillerName))!
|
||||
.id!;
|
||||
});
|
||||
|
||||
const shareMany = (body: Record<string, unknown>) =>
|
||||
req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: `batch-${uuidv4().slice(0, 8)}:`,
|
||||
...body,
|
||||
});
|
||||
|
||||
it('reports each recipient on its own item, in order', async () => {
|
||||
const res = await shareMany({
|
||||
recipients: [
|
||||
env.users.other.username,
|
||||
'nobody-by-this-name',
|
||||
env.users.user.username,
|
||||
'[email protected]',
|
||||
{ uid: third.uuid },
|
||||
],
|
||||
access: 'write',
|
||||
label: 'Team list',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const { items } = (await res.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(items).toHaveLength(5);
|
||||
expect(items[0]).toMatchObject({
|
||||
recipient: env.users.other.username,
|
||||
handle: expect.any(String),
|
||||
access: 'write',
|
||||
holder: env.users.other.username,
|
||||
label: 'Team list',
|
||||
appUid: null,
|
||||
});
|
||||
expect(items[1]).toEqual({
|
||||
recipient: 'nobody-by-this-name',
|
||||
error: {
|
||||
message: expect.any(String),
|
||||
code: 'subject_does_not_exist',
|
||||
},
|
||||
});
|
||||
expect(items[2]).toMatchObject({
|
||||
error: { code: 'bad_request' },
|
||||
});
|
||||
expect(items[3]).toMatchObject({
|
||||
error: { code: 'invalid_recipient' },
|
||||
});
|
||||
expect(items[4]).toMatchObject({
|
||||
recipient: { uid: third.uuid },
|
||||
holder: third.username,
|
||||
});
|
||||
expect(items[4].handle).not.toBe(items[0].handle);
|
||||
});
|
||||
|
||||
it('answers a recipient named twice with the one share', async () => {
|
||||
const res = await shareMany({
|
||||
recipients: [third.username, { uid: third.uuid }],
|
||||
});
|
||||
const { items } = (await res.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(items[0].handle).toEqual(expect.any(String));
|
||||
expect(items[1].handle).toBe(items[0].handle);
|
||||
});
|
||||
|
||||
it('refuses a malformed batch outright', async () => {
|
||||
for (const body of [
|
||||
{ recipients: [] },
|
||||
{ recipients: 'alice' },
|
||||
{
|
||||
recipients: Array.from(
|
||||
{ length: KV_SHARE_BATCH_MAX + 1 },
|
||||
(_, i) => `user-${i}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
recipient: env.users.other.username,
|
||||
recipients: [env.users.other.username],
|
||||
},
|
||||
]) {
|
||||
const res = await shareMany(body);
|
||||
expect(res.status).toBe(400);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
code: 'bad_request',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses the whole batch when it would pass the account cap', async () => {
|
||||
const prefix = `batch-cap-${uuidv4().slice(0, 8)}:`;
|
||||
const existing = await shareMany({
|
||||
prefix,
|
||||
recipients: [env.users.other.username],
|
||||
});
|
||||
const [standing] = (
|
||||
(await existing.json()) as { items: { handle: string }[] }
|
||||
).items;
|
||||
try {
|
||||
const { total } =
|
||||
await env.server.stores.kvShareHandle.countLiveForOwner(
|
||||
ownerId,
|
||||
);
|
||||
// One slot left.
|
||||
await seedKvShareHandles(env.server, {
|
||||
ownerUserId: ownerId,
|
||||
ownerUuid,
|
||||
granteeUserId: filler,
|
||||
count: EVENTS_KV_HANDLES_PER_USER.limit - total - 1,
|
||||
appUid: () => KV_GLOBAL_APP_KEY,
|
||||
});
|
||||
|
||||
const refused = await shareMany({
|
||||
prefix: `batch-over-${uuidv4().slice(0, 8)}:`,
|
||||
recipients: [env.users.other.username, third.username],
|
||||
});
|
||||
expect(refused.status).toBe(409);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'events_kv_handle_limit_reached',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await env.server.stores.kvShareHandle.countLiveForOwner(
|
||||
ownerId,
|
||||
)
|
||||
).total,
|
||||
).toBe(EVENTS_KV_HANDLES_PER_USER.limit - 1);
|
||||
|
||||
// The standing share takes no slot, so one new one still fits.
|
||||
const fits = await shareMany({
|
||||
prefix,
|
||||
recipients: [env.users.other.username, third.username],
|
||||
});
|
||||
expect(fits.status).toBe(200);
|
||||
const { items } = (await fits.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(items[0].handle).toBe(standing.handle);
|
||||
expect(items[1]).toMatchObject({ holder: third.username });
|
||||
} finally {
|
||||
await env.server.clients.db.write(
|
||||
'DELETE FROM `kv_share_handles` WHERE `owner_user_id` = ?',
|
||||
[ownerId],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('spends the grant budget per recipient, all or nothing', async () => {
|
||||
const [minute] = KV_SHARE_GRANT_LIMITS;
|
||||
await claimRateLimit(
|
||||
`${minute.scope}:${ownerId}`,
|
||||
minute.limit,
|
||||
minute.window,
|
||||
minute.limit - 1,
|
||||
);
|
||||
try {
|
||||
const refused = await shareMany({
|
||||
recipients: [env.users.other.username, third.username],
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'too_many_requests',
|
||||
});
|
||||
} finally {
|
||||
await resetBudgets(env);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/events/kv-handles beside /kv/shares', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
} as IConfig);
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('keeps its own request and response shapes, at watch by default', async () => {
|
||||
const minted = await call(
|
||||
env,
|
||||
'POST',
|
||||
'/events/kv-handles',
|
||||
env.users.user.token,
|
||||
{
|
||||
granteeUsername: env.users.other.username,
|
||||
prefix: 'alias-shape:',
|
||||
},
|
||||
);
|
||||
expect(minted.status).toBe(200);
|
||||
const body = (await minted.json()) as Record<string, unknown>;
|
||||
expect(body).toEqual({ handle: body.handle, prefix: 'alias-shape:' });
|
||||
|
||||
const listed = (await (
|
||||
await call(
|
||||
env,
|
||||
'GET',
|
||||
'/events/kv-handles?limit=200',
|
||||
env.users.user.token,
|
||||
)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
const row = listed.items.find((one) => one.handle === body.handle);
|
||||
expect(row).toMatchObject({
|
||||
prefix: 'alias-shape:',
|
||||
appUid: 'os-global',
|
||||
granteeUsername: env.users.other.username,
|
||||
access: 'watch',
|
||||
revokedBy: null,
|
||||
});
|
||||
expect(row).not.toHaveProperty('holder');
|
||||
|
||||
// The alias revoke stays the owner's: the holder is told it is absent.
|
||||
const byHolder = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/events/kv-handles/${body.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(byHolder.status).toBe(404);
|
||||
});
|
||||
|
||||
it('hands back a share it finds at the level it has, unless the body names one', async () => {
|
||||
const shared = (await (
|
||||
await call(env, 'POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: 'alias-keep:',
|
||||
recipient: env.users.other.username,
|
||||
access: 'write',
|
||||
})
|
||||
).json()) as Record<string, unknown>;
|
||||
const accessOf = async () => {
|
||||
const listed = (await (
|
||||
await call(
|
||||
env,
|
||||
'GET',
|
||||
'/kv/shares?limit=200',
|
||||
env.users.user.token,
|
||||
)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
return listed.items.find((one) => one.handle === shared.handle)
|
||||
?.access;
|
||||
};
|
||||
const mintAlias = (extra: Record<string, unknown> = {}) =>
|
||||
call(env, 'POST', '/events/kv-handles', env.users.user.token, {
|
||||
granteeUsername: env.users.other.username,
|
||||
prefix: 'alias-keep:',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const again = await mintAlias();
|
||||
expect(again.status).toBe(200);
|
||||
await expect(again.json()).resolves.toEqual({
|
||||
handle: shared.handle,
|
||||
prefix: 'alias-keep:',
|
||||
});
|
||||
expect(await accessOf()).toBe('write');
|
||||
|
||||
expect((await mintAlias({ access: 'read' })).status).toBe(200);
|
||||
expect(await accessOf()).toBe('read');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Sharing asks for a confirmed email and nothing else. Booted with an SMS
|
||||
* provider configured, so a phone requirement would bite here if one were ever
|
||||
* added to these routes.
|
||||
*/
|
||||
describe('verification on the mint routes', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
strict_email_verification_required: true,
|
||||
prelude: { apiKey: 'test-key' },
|
||||
} as IConfig);
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const makeUser = async (
|
||||
patch: Record<string, unknown> = {},
|
||||
): Promise<TestUserCredentials> => {
|
||||
const username = `kvv${uuidv4().slice(0, 8)}`;
|
||||
const creds = await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.stores.user.update(row!.id, patch);
|
||||
}
|
||||
return creds;
|
||||
};
|
||||
|
||||
const mintBoth = (owner: TestUserCredentials, prefix: string) =>
|
||||
Promise.all([
|
||||
call(env, 'POST', '/kv/shares', owner.token, {
|
||||
prefix: `${prefix}a:`,
|
||||
recipient: env.users.other.username,
|
||||
}),
|
||||
call(env, 'POST', '/events/kv-handles', owner.token, {
|
||||
prefix: `${prefix}b:`,
|
||||
granteeUsername: env.users.other.username,
|
||||
}),
|
||||
]);
|
||||
|
||||
it('refuses an account whose email is not confirmed, on both routes', async () => {
|
||||
const owner = await makeUser();
|
||||
for (const res of await mintBoth(owner, 'unconfirmed')) {
|
||||
expect(res.status).toBe(403);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
code: 'account_is_not_verified',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a confirmed account share with no phone and no card', async () => {
|
||||
const owner = await makeUser({
|
||||
email: `kvv-${uuidv4().slice(0, 8)}@example.invalid`,
|
||||
email_confirmed: 1,
|
||||
});
|
||||
for (const res of await mintBoth(owner, 'confirmed'))
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('never gates listing or unsharing', async () => {
|
||||
const owner = await makeUser();
|
||||
for (const path of ['/kv/shares', '/kv/shares/with-me'])
|
||||
expect((await call(env, 'GET', path, owner.token)).status).toBe(
|
||||
200,
|
||||
);
|
||||
const removed = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/kv/shares/kvh-${uuidv4()}`,
|
||||
owner.token,
|
||||
);
|
||||
expect(removed.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import {
|
||||
assertShareableAppUid,
|
||||
parseKvShareRecipient,
|
||||
} from '../../services/kvShare/kvShares.js';
|
||||
import type {
|
||||
KvShareHandleListOptions,
|
||||
KvShareOwnerListOptions,
|
||||
KvShareOwnerListState,
|
||||
} from '../../stores/events/KvShareHandleStore.js';
|
||||
import {
|
||||
KV_HANDLE_LIST_LIMIT_CAP,
|
||||
KV_SHARE_OWNER_LIST_STATES,
|
||||
} from '../../stores/events/KvShareHandleStore.js';
|
||||
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import type { KvShareView } from '../../services/kvShare/KvShareService.js';
|
||||
import { EVENTS_LIST_LIMIT, KV_SHARE_BATCH_MAX } from '../events/limits.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/** The app-less namespace is no app, so the wire says `null` for it. */
|
||||
const publicAppUid = (appUid: string): string | null =>
|
||||
appUid === KV_GLOBAL_APP_KEY ? null : appUid;
|
||||
|
||||
/**
|
||||
* The namespace a listing's `appUid` names, in the form responses use: an app
|
||||
* uid, or `null` for the app-less namespace.
|
||||
*/
|
||||
const listAppUid = (value: unknown): string | undefined => {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
if (typeof value !== 'string')
|
||||
throw new HttpError(400, '`appUid` must be a string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
return value === 'null' ? KV_GLOBAL_APP_KEY : assertShareableAppUid(value);
|
||||
};
|
||||
|
||||
const badRequest = (message: string): HttpError =>
|
||||
new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
|
||||
/** A share as `POST /kv/shares` reports it. */
|
||||
const shareBody = (share: KvShareView) => ({
|
||||
handle: share.handle,
|
||||
prefix: share.prefix,
|
||||
access: share.access,
|
||||
appUid: publicAppUid(share.appUid),
|
||||
holder: share.holderUsername,
|
||||
label: share.label,
|
||||
createdAt: share.createdAt,
|
||||
});
|
||||
|
||||
/** `recipients` when the body names it: 1 to `KV_SHARE_BATCH_MAX` entries. */
|
||||
const batchRecipients = (
|
||||
body: Record<string, unknown>,
|
||||
): unknown[] | undefined => {
|
||||
const { recipient, recipients } = body;
|
||||
if (recipients === undefined) return undefined;
|
||||
if (recipient !== undefined)
|
||||
throw badRequest('Send either `recipient` or `recipients`, not both');
|
||||
if (
|
||||
!Array.isArray(recipients) ||
|
||||
recipients.length === 0 ||
|
||||
recipients.length > KV_SHARE_BATCH_MAX
|
||||
)
|
||||
throw badRequest(
|
||||
`\`recipients\` must be an array of 1 to ${KV_SHARE_BATCH_MAX} recipients`,
|
||||
);
|
||||
return recipients;
|
||||
};
|
||||
|
||||
const listState = (value: unknown): KvShareOwnerListState | undefined => {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
if (!(KV_SHARE_OWNER_LIST_STATES as readonly unknown[]).includes(value))
|
||||
throw new HttpError(400, '`state` must be `live`, `revoked` or `all`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
return value as KvShareOwnerListState;
|
||||
};
|
||||
|
||||
const pageOptions = (req: Request): KvShareHandleListOptions => {
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
limit: normalizeLimit(query.limit, { cap: KV_HANDLE_LIST_LIMIT_CAP }),
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
includeTotal: query.includeTotal === 'true',
|
||||
appUid: listAppUid(query.appUid),
|
||||
};
|
||||
};
|
||||
|
||||
const ownerPageOptions = (req: Request): KvShareOwnerListOptions => ({
|
||||
...pageOptions(req),
|
||||
state: listState((req.query ?? {}).state),
|
||||
});
|
||||
|
||||
/**
|
||||
* Sharing a region of one account's key-value data with another account.
|
||||
*
|
||||
* Gated like the events surface: `subdomain: 'api'` plus `allowAccessToken`,
|
||||
* with app-ness decided from `effectiveApp` in the service, which is where the
|
||||
* owner, holder, delegation and namespace rules live. Only sharing asks for a
|
||||
* confirmed email; listing, revoking and unsharing only narrow or report.
|
||||
*/
|
||||
@Controller('/kv')
|
||||
export class KvShareController extends PuterController {
|
||||
/**
|
||||
* POST /kv/shares — share a region with another account, `read` unless the
|
||||
* body asks otherwise. Sharing the same region with the same account again
|
||||
* changes its level or label in place. `recipients` shares it with several
|
||||
* at once and reports each one on its own item.
|
||||
*/
|
||||
@Post('/shares', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
})
|
||||
async share(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = bodyRecord(req);
|
||||
const fields = {
|
||||
prefix: body.prefix,
|
||||
access: body.access,
|
||||
appUid: body.appUid,
|
||||
label: body.label,
|
||||
};
|
||||
const recipients = batchRecipients(body);
|
||||
if (recipients) {
|
||||
const items = await this.services.kvShare.mintKvHandles(
|
||||
actor,
|
||||
{ ...fields, recipients },
|
||||
{ defaultAccess: 'read' },
|
||||
);
|
||||
res.json({
|
||||
items: items.map((item) =>
|
||||
item.share
|
||||
? {
|
||||
recipient: item.recipient,
|
||||
...shareBody(item.share),
|
||||
}
|
||||
: {
|
||||
recipient: item.recipient,
|
||||
error: {
|
||||
message: item.error?.message ?? 'Not shared',
|
||||
code:
|
||||
item.error?.legacyCode ??
|
||||
item.error?.code ??
|
||||
'internal_error',
|
||||
},
|
||||
},
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const share = await this.services.kvShare.mintKvHandle(
|
||||
actor,
|
||||
{ ...fields, recipient: parseKvShareRecipient(body.recipient) },
|
||||
{ defaultAccess: 'read' },
|
||||
);
|
||||
res.json(shareBody(share));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /kv/shares — what this account has shared out, retired included
|
||||
* unless `state` narrows it.
|
||||
*/
|
||||
@Get('/shares', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSharedByMe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const page = await this.services.kvShare.listKvHandles(
|
||||
actor,
|
||||
ownerPageOptions(req),
|
||||
);
|
||||
res.json({
|
||||
items: page.items.map((row) => ({
|
||||
handle: row.handle,
|
||||
prefix: row.prefix,
|
||||
access: row.access,
|
||||
appUid: publicAppUid(row.appUid),
|
||||
holder: row.holderUsername,
|
||||
label: row.label,
|
||||
createdAt: row.createdAt,
|
||||
revokedAt: row.revokedAt,
|
||||
revokedBy: row.revokedBy,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /kv/shares/with-me — live shares this account holds. */
|
||||
@Get('/shares/with-me', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSharedWithMe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const page = await this.services.kvShare.listKvSharesWithMe(
|
||||
actor,
|
||||
pageOptions(req),
|
||||
);
|
||||
res.json({
|
||||
items: page.items.map((row) => ({
|
||||
handle: row.handle,
|
||||
access: row.access,
|
||||
appUid: publicAppUid(row.appUid),
|
||||
label: row.label,
|
||||
createdAt: row.createdAt,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /kv/shares/:handle — the owner revokes, the holder gives it up,
|
||||
* and anyone else is told it is not there.
|
||||
*/
|
||||
@Delete('/shares/:handle', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async unshare(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.kvShare.removeKvShare(actor, req.params.handle),
|
||||
);
|
||||
}
|
||||
|
||||
#requireActor(req: Request): Actor {
|
||||
const actor = req.actor;
|
||||
if (!actor?.user)
|
||||
throw new HttpError(401, 'Unauthorized', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -233,20 +234,14 @@ const callRoute = async (
|
||||
);
|
||||
};
|
||||
|
||||
const oidc = (): OIDCService =>
|
||||
server.services.oidc as unknown as OIDCService;
|
||||
const oidc = (): OIDCService => server.services.oidc as unknown as OIDCService;
|
||||
|
||||
// ── GET /auth/oidc/providers ────────────────────────────────────────
|
||||
|
||||
describe('OIDCController GET /auth/oidc/providers', () => {
|
||||
it('lists every provider whose config validates', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/providers',
|
||||
makeReq({}),
|
||||
res,
|
||||
);
|
||||
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
|
||||
// We configured a single `custom` provider; the live service
|
||||
// walks the config and returns its id.
|
||||
expect(captured.body).toEqual({ providers: ['custom'] });
|
||||
@@ -417,9 +412,9 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const state = new URL(
|
||||
captured.redirectUrl ?? '',
|
||||
).searchParams.get('state');
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
const decoded = oidc().verifyState(state!);
|
||||
expect(String(decoded?.redirect_uri)).toBe(TEST_ORIGIN);
|
||||
}
|
||||
@@ -624,10 +619,7 @@ describe('OIDCController login callback', () => {
|
||||
|
||||
// The real OIDCService linked the new user — verify via the
|
||||
// live store.
|
||||
const linkedUser = await oidc().findUserByProviderSub(
|
||||
'custom',
|
||||
sub,
|
||||
);
|
||||
const linkedUser = await oidc().findUserByProviderSub('custom', sub);
|
||||
expect(linkedUser).not.toBeNull();
|
||||
expect(linkedUser?.email).toBe(email);
|
||||
});
|
||||
@@ -791,14 +783,12 @@ describe('OIDCController login callback', () => {
|
||||
// Seed by running the linked-sub path first via createUserFromOIDC.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
const firstUserId = created.user!.id;
|
||||
@@ -838,14 +828,12 @@ describe('OIDCController login callback', () => {
|
||||
it('redirects with auth_error when the user is suspended', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
// Flip the suspended bit on the existing row.
|
||||
@@ -980,14 +968,12 @@ describe('OIDCController login callback', () => {
|
||||
it('keeps an /app/<name> landing in the error redirect (suspended user)', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `sus-app-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
await server.stores.user.update(created.user!.id, { suspended: 1 });
|
||||
@@ -1075,14 +1061,12 @@ describe('OIDCController login callback', () => {
|
||||
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `pop-sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
await server.stores.user.update(created.user!.id, { suspended: 1 });
|
||||
@@ -1232,13 +1216,7 @@ describe('OIDCController login callback', () => {
|
||||
email,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
});
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
// Provisioning issues a temporary password once the seat exists.
|
||||
await server.stores.user.update(seat.id, { password: 'hashed' });
|
||||
return seat;
|
||||
@@ -1334,14 +1312,12 @@ describe('OIDCController signup veto (abuse harness)', () => {
|
||||
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,
|
||||
}),
|
||||
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');
|
||||
@@ -1676,14 +1652,12 @@ describe('OIDCController signup callback', () => {
|
||||
// resolves to `linked-sub` rather than creating a new user.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `existing-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
@@ -1755,14 +1729,12 @@ describe('OIDCController signup callback', () => {
|
||||
it('redirects suspended users to action=signup with message=account_suspended', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
await server.stores.user.update(created.user!.id, { suspended: 1 });
|
||||
@@ -1899,14 +1871,12 @@ describe('OIDCController revalidate callback', () => {
|
||||
// The linked account exists, but state.user_uuid points at someone else.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
@@ -1938,14 +1908,12 @@ describe('OIDCController revalidate callback', () => {
|
||||
it('happy path: sets the revalidation cookie and redirects to redirect-done', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
const userUuid = created.user!.uuid;
|
||||
@@ -1992,14 +1960,12 @@ describe('OIDCController revalidate callback', () => {
|
||||
it('falls back to /auth/revalidate-done when state.redirect_uri is cross-origin', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
const userUuid = created.user!.uuid;
|
||||
@@ -2038,12 +2004,7 @@ describe('OIDCController revalidate callback', () => {
|
||||
describe('OIDCController GET /auth/revalidate-done', () => {
|
||||
it('renders the postMessage HTML body with the configured origin', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/revalidate-done',
|
||||
makeReq({}),
|
||||
res,
|
||||
);
|
||||
await callRoute('get', '/auth/revalidate-done', makeReq({}), res);
|
||||
expect(captured.contentType).toContain('text/html');
|
||||
const body = String(captured.body);
|
||||
expect(body).toContain('puter-revalidate-done');
|
||||
@@ -2057,9 +2018,9 @@ describe('OIDCController GET /auth/revalidate-done', () => {
|
||||
/**
|
||||
* The proof exists because the popup return leg states two things the popup
|
||||
* cannot check — the opener's origin and that a login completed — and a URL
|
||||
* built from a verified `state` is byte-identical to one anybody can type.
|
||||
* The opener's origin picks the app a token is minted for, so it has to be
|
||||
* attested rather than read.
|
||||
* built from a verified `state` is byte-identical to one anybody can type. The
|
||||
* opener's origin picks the app a token is minted for, so it has to be attested
|
||||
* rather than read.
|
||||
*/
|
||||
describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
const NONCE = 'binding-nonce';
|
||||
|
||||
@@ -24,6 +24,12 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { startWebSession } from '../auth/webSession.js';
|
||||
import {
|
||||
completeOidcLoginReturn,
|
||||
mintOidcPopupReturn,
|
||||
OIDC_POPUP_RETURN_COOKIE_NAME,
|
||||
type OidcLoginContinuation,
|
||||
} from '../auth/oidcLoginReturn.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { normalizeBonusCode } from '../../util/signupBonus.js';
|
||||
@@ -43,11 +49,6 @@ const REVALIDATION_EXPIRY_SEC = 300;
|
||||
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
|
||||
const OIDC_NONCE_EXPIRY_SEC = 600;
|
||||
|
||||
// Single-use, and binds a popup-return proof to the browser that earned it.
|
||||
// Expiry mirrors POPUP_RETURN_EXPIRY_SEC in OIDCService.
|
||||
const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
|
||||
const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
|
||||
|
||||
// The popup branch below hard-codes this return target.
|
||||
const POPUP_RETURN_ACTION = 'sign-in';
|
||||
|
||||
@@ -284,11 +285,6 @@ function buildErrorRedirectUrl(
|
||||
return `${base}${pagePath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function appendQueryParam(url: string, key: string, value: string): string {
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
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);
|
||||
@@ -803,18 +799,9 @@ if (window.opener) {
|
||||
|
||||
/** Sign a popup-return proof, bound to this browser and this popup. */
|
||||
#mintPopupReturn(res: Response, payload: Record<string, unknown>): string {
|
||||
const nonce = crypto.randomBytes(32).toString('base64url');
|
||||
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
|
||||
// Redeemed same-origin.
|
||||
...sessionCookieFlags(this.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
return this.services.oidc.signPopupReturn({
|
||||
action: POPUP_RETURN_ACTION,
|
||||
...payload,
|
||||
nonce,
|
||||
return mintOidcPopupReturn(res, payload, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -872,7 +859,7 @@ if (window.opener) {
|
||||
byEmail !== null &&
|
||||
!byEmail.email_confirmed &&
|
||||
userinfo.email_verified === true &&
|
||||
(await this.services.team.releaseUnconfirmedSeatEmail(
|
||||
(await this.services.team?.releaseUnconfirmedSeatEmail(
|
||||
byEmail.id,
|
||||
));
|
||||
if (byEmail && !releasedFromSeat) {
|
||||
@@ -1012,49 +999,71 @@ if (window.opener) {
|
||||
stateDecoded: Record<string, unknown>,
|
||||
extraQueryParams?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
const continuation: OidcLoginContinuation = {
|
||||
userUuid: user.uuid,
|
||||
redirectUri:
|
||||
typeof stateDecoded.redirect_uri === 'string'
|
||||
? stateDecoded.redirect_uri
|
||||
: origin || '/',
|
||||
embeddedInPopup: stateDecoded.embedded_in_popup === true,
|
||||
openerOrigin:
|
||||
typeof stateDecoded.opener_origin === 'string'
|
||||
? stateDecoded.opener_origin
|
||||
: null,
|
||||
messageId:
|
||||
stateDecoded.msg_id == null
|
||||
? null
|
||||
: String(stateDecoded.msg_id),
|
||||
switchedToLogin: extraQueryParams?.oidc_switched === 'login',
|
||||
};
|
||||
if (user.otp_enabled) {
|
||||
const challenge = this.services.token.sign(
|
||||
'otp',
|
||||
{
|
||||
purpose: 'otp-login',
|
||||
user_uid: user.uuid,
|
||||
jti: crypto.randomUUID(),
|
||||
oidcContinuation: continuation,
|
||||
},
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Referrer-Policy', 'no-referrer');
|
||||
let retryUrl = `${origin}/action/login`;
|
||||
if (continuation.embeddedInPopup) {
|
||||
const retry = new URL(
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
'unauthorized',
|
||||
stateDecoded,
|
||||
undefined,
|
||||
(payload) => this.#mintPopupReturn(res, payload),
|
||||
),
|
||||
);
|
||||
retry.searchParams.delete('auth_error');
|
||||
retry.searchParams.delete('message');
|
||||
retryUrl = retry.href;
|
||||
}
|
||||
const fragment = new URLSearchParams({
|
||||
token: challenge,
|
||||
retryUrl,
|
||||
});
|
||||
res.redirect(302, `${origin}/action/oidc-2fa#${fragment}`);
|
||||
return;
|
||||
}
|
||||
await startWebSession(req, res, user, {
|
||||
config: this.config,
|
||||
auth: this.services.auth,
|
||||
});
|
||||
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
let target = (stateDecoded.redirect_uri as string) || origin || '/';
|
||||
if (!isSameOrigin(target, origin)) {
|
||||
target = origin || '/';
|
||||
}
|
||||
|
||||
if (stateDecoded.embedded_in_popup) {
|
||||
target = appendQueryParam(target, 'oidc_login', 'true');
|
||||
// `opener_origin` and `oidc_login` reach the popup as bare query
|
||||
// parameters, which say nothing about where they came from: the
|
||||
// URL a verified state produces is byte-identical to one anybody
|
||||
// can type. The popup treats the opener's origin as the app
|
||||
// identity to mint a token for, so it needs the integrity this
|
||||
// state already carries — re-signed here, at the one point where
|
||||
// the round trip is known to have actually happened.
|
||||
//
|
||||
// `user_uuid` binds the proof to the account that just completed
|
||||
// OIDC. Without it a proof from one login could be replayed in
|
||||
// another signed-in browser to skip its account picker; the popup
|
||||
// only honors `oidc_login` when this matches its current user.
|
||||
target = appendQueryParam(
|
||||
target,
|
||||
'opener_state',
|
||||
this.#mintPopupReturn(res, {
|
||||
opener_origin: stateDecoded.opener_origin ?? null,
|
||||
msg_id: stateDecoded.msg_id ?? null,
|
||||
oidc_login: true,
|
||||
user_uuid: user.uuid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (extraQueryParams) {
|
||||
for (const [k, v] of Object.entries(extraQueryParams)) {
|
||||
if (v != null) target = appendQueryParam(target, k, v);
|
||||
}
|
||||
}
|
||||
|
||||
res.redirect(302, target);
|
||||
res.redirect(
|
||||
302,
|
||||
completeOidcLoginReturn(res, user, continuation, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { TOTP } from 'otpauth';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
createTestUser,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
|
||||
describe('OIDC account second-factor login', () => {
|
||||
let env: PuterTestEnv;
|
||||
let claims: { sub: string; email: string; email_verified: boolean };
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
cookie_name: 'puter_auth_token',
|
||||
oidc: {
|
||||
providers: {
|
||||
custom: {
|
||||
client_id: 'test-client',
|
||||
client_secret: 'test-secret',
|
||||
authorization_endpoint: 'https://idp.example/authorize',
|
||||
token_endpoint: 'https://idp.example/token',
|
||||
userinfo_endpoint: 'https://idp.example/userinfo',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as IConfig);
|
||||
vi.spyOn(
|
||||
env.server.services.oidc,
|
||||
'exchangeCodeForTokens',
|
||||
).mockResolvedValue({ access_token: 'test-access' } as never);
|
||||
vi.spyOn(env.server.services.oidc, 'getUserInfo').mockImplementation(
|
||||
async () => claims,
|
||||
);
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const account = async (enabled = true, linked = true) => {
|
||||
const username = `oidc2fa-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'oidc-test-password',
|
||||
});
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
const otp = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
label: username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
});
|
||||
claims = {
|
||||
sub: uuidv4(),
|
||||
email: `${username}@example.com`,
|
||||
email_verified: true,
|
||||
};
|
||||
await env.server.stores.user.update(user.id, {
|
||||
email: claims.email,
|
||||
email_confirmed: true,
|
||||
otp_enabled: enabled,
|
||||
otp_secret: otp.secret.base32,
|
||||
otp_recovery_codes: hashRecoveryCode('ABCDEFG2'),
|
||||
});
|
||||
if (linked) {
|
||||
await env.server.services.oidc.linkProviderToUser(
|
||||
user.id,
|
||||
'custom',
|
||||
claims,
|
||||
);
|
||||
}
|
||||
return { user, otp };
|
||||
};
|
||||
const callback = async (
|
||||
flow = 'login',
|
||||
extra: Record<string, string> = {},
|
||||
) => {
|
||||
const start = new URL(
|
||||
`/auth/oidc/custom/start?flow=${flow}`,
|
||||
env.origin,
|
||||
);
|
||||
Object.entries(extra).forEach(([key, value]) =>
|
||||
start.searchParams.set(key, value),
|
||||
);
|
||||
const begun = await fetch(start, { redirect: 'manual' });
|
||||
expect(begun.status).toBe(302);
|
||||
const state = new URL(begun.headers.get('location')!).searchParams.get(
|
||||
'state',
|
||||
)!;
|
||||
const nonce = begun.headers
|
||||
.getSetCookie()
|
||||
.find((value) => value.startsWith('puter_oidc_nonce='))!
|
||||
.split(';')[0]!;
|
||||
return fetch(
|
||||
new URL(
|
||||
`/auth/oidc/callback/${flow}?code=test-code&state=${encodeURIComponent(state)}`,
|
||||
env.origin,
|
||||
),
|
||||
{ redirect: 'manual', headers: { cookie: nonce } },
|
||||
);
|
||||
};
|
||||
const verify = (token: string, code: string, recovery = false) =>
|
||||
fetch(
|
||||
new URL(
|
||||
recovery ? '/login/recovery-code' : '/login/otp',
|
||||
env.origin,
|
||||
),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token, code }),
|
||||
},
|
||||
);
|
||||
const challenge = (response: Response) => {
|
||||
expect(response.status).toBe(302);
|
||||
expect(
|
||||
response.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_auth_token=')),
|
||||
).toBe(false);
|
||||
const location = new URL(response.headers.get('location')!);
|
||||
expect(location.pathname).toBe('/action/oidc-2fa');
|
||||
const token = new URLSearchParams(location.hash.slice(1)).get('token');
|
||||
expect(token).toBeTruthy();
|
||||
return token!;
|
||||
};
|
||||
|
||||
it.each(['login', 'signup'])(
|
||||
'requires the authenticator before completing existing-account %s',
|
||||
async (flow) => {
|
||||
const { otp } = await account();
|
||||
const token = challenge(
|
||||
await callback(flow, { return_to: '/dashboard' }),
|
||||
);
|
||||
const response = await verify(token, otp.generate());
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
redirectUrl: `${env.origin}/dashboard${flow === 'signup' ? '?oidc_switched=login' : ''}`,
|
||||
});
|
||||
expect(
|
||||
response.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_auth_token=')),
|
||||
).toBe(true);
|
||||
expect((await verify(token, otp.generate())).status).toBe(400);
|
||||
},
|
||||
);
|
||||
|
||||
it('incorrect codes do not start a session and allow a valid recovery-code retry', async () => {
|
||||
const { otp } = await account();
|
||||
const token = challenge(await callback());
|
||||
const wrong = ['000000', '000001', '000002'].find(
|
||||
(code) => otp.validate({ token: code }) === null,
|
||||
)!;
|
||||
const rejected = await verify(token, wrong);
|
||||
expect(await rejected.json()).toMatchObject({ proceed: false });
|
||||
expect(rejected.headers.getSetCookie()).toHaveLength(0);
|
||||
const accepted = await verify(token, 'ABCDEFG2', true);
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(await accepted.json()).toMatchObject({
|
||||
proceed: true,
|
||||
redirectUrl: env.origin,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an unauthenticated popup retry and attests login only after verification', async () => {
|
||||
const { otp, user } = await account();
|
||||
const response = await callback('login', {
|
||||
embedded_in_popup: 'true',
|
||||
msg_id: 'message-123',
|
||||
opener_origin: 'https://app.example',
|
||||
});
|
||||
const token = challenge(response);
|
||||
const fragment = new URLSearchParams(
|
||||
new URL(response.headers.get('location')!).hash.slice(1),
|
||||
);
|
||||
const retry = new URL(fragment.get('retryUrl')!);
|
||||
expect(retry.origin).toBe(env.origin);
|
||||
expect(retry.searchParams.get('action')).toBe('login');
|
||||
expect(retry.searchParams.get('embedded_in_popup')).toBe('true');
|
||||
expect(retry.searchParams.get('msg_id')).toBe('message-123');
|
||||
expect(retry.searchParams.has('auth_error')).toBe(false);
|
||||
const retryNonce = response.headers
|
||||
.get('set-cookie')!
|
||||
.match(/puter_oidc_popup_return=([^;, ]+)/)![1];
|
||||
const retried = await fetch(
|
||||
new URL('/auth/oidc/verify-popup-return', env.origin),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
cookie: `puter_oidc_popup_return=${retryNonce}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
opener_state: retry.searchParams.get('opener_state'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(retried.status).toBe(200);
|
||||
expect(await retried.json()).toMatchObject({
|
||||
action: 'login',
|
||||
oidc_login: false,
|
||||
user_uuid: null,
|
||||
msg_id: 'message-123',
|
||||
opener_origin: 'https://app.example',
|
||||
});
|
||||
const accepted = await verify(token, otp.generate());
|
||||
const data = await accepted.json();
|
||||
const target = new URL(data.redirectUrl);
|
||||
expect(target.pathname).toBe('/action/sign-in');
|
||||
expect(target.searchParams.get('msg_id')).toBe('message-123');
|
||||
expect(target.searchParams.get('oidc_login')).toBe('true');
|
||||
const proof = env.server.services.oidc.verifyPopupReturn(
|
||||
target.searchParams.get('opener_state')!,
|
||||
);
|
||||
expect(proof).toMatchObject({
|
||||
user_uuid: user.uuid,
|
||||
opener_origin: 'https://app.example',
|
||||
msg_id: 'message-123',
|
||||
oidc_login: true,
|
||||
});
|
||||
expect(
|
||||
accepted.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_oidc_popup_return=')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the authenticator when verified email first links an existing account', async () => {
|
||||
const { otp } = await account(true, false);
|
||||
const token = challenge(await callback());
|
||||
expect((await verify(token, otp.generate())).status).toBe(200);
|
||||
});
|
||||
|
||||
it('requires the authenticator for an OIDC-only account', async () => {
|
||||
const { user, otp } = await account();
|
||||
await env.server.stores.user.update(user.id, { password: null });
|
||||
const token = challenge(await callback());
|
||||
const response = await verify(token, otp.generate());
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses distinct short-lived challenges and refuses a modified token', async () => {
|
||||
const { otp } = await account();
|
||||
const first = challenge(await callback());
|
||||
const second = challenge(await callback());
|
||||
expect(first).not.toBe(second);
|
||||
const decoded = env.server.services.token.verify<{
|
||||
purpose: string;
|
||||
jti: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}>('otp', first);
|
||||
expect(decoded.purpose).toBe('otp-login');
|
||||
expect(decoded.jti).toBeTruthy();
|
||||
expect(decoded.exp - decoded.iat).toBe(300);
|
||||
const modified = `${first.slice(0, -4)}abcd`;
|
||||
const rejected = await verify(modified, otp.generate());
|
||||
expect(rejected.status).toBe(400);
|
||||
expect(rejected.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('validates continuation account binding before creating any session', async () => {
|
||||
const { otp } = await account();
|
||||
const issued = challenge(await callback());
|
||||
const decoded = env.server.services.token.verify<
|
||||
Record<string, unknown>
|
||||
>('otp', issued);
|
||||
const continuation = decoded.oidcContinuation as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const mismatched = env.server.services.token.sign('otp', {
|
||||
...decoded,
|
||||
jti: uuidv4(),
|
||||
oidcContinuation: { ...continuation, userUuid: uuidv4() },
|
||||
});
|
||||
const rejected = await verify(mismatched, otp.generate());
|
||||
expect(rejected.status).toBe(400);
|
||||
expect(rejected.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps a signed continuation redirect to the configured origin', async () => {
|
||||
const { otp } = await account();
|
||||
const issued = challenge(await callback());
|
||||
const decoded = env.server.services.token.verify<
|
||||
Record<string, unknown>
|
||||
>('otp', issued);
|
||||
const continuation = decoded.oidcContinuation as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const redirected = env.server.services.token.sign('otp', {
|
||||
...decoded,
|
||||
jti: uuidv4(),
|
||||
oidcContinuation: {
|
||||
...continuation,
|
||||
redirectUri: 'https://elsewhere.example/',
|
||||
},
|
||||
});
|
||||
const accepted = await verify(redirected, otp.generate());
|
||||
expect(await accepted.json()).toMatchObject({
|
||||
proceed: true,
|
||||
redirectUrl: env.origin,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
|
||||
await account(false);
|
||||
const response = await callback('login', { return_to: '/desktop' });
|
||||
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
|
||||
expect(
|
||||
response.headers.get('set-cookie')?.includes('puter_auth_token='),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { ProfileController } from './ProfileController.js';
|
||||
|
||||
// GET /profile without a username: the caller's own profile, read alongside
|
||||
// their account row.
|
||||
describe('ProfileController own profile', () => {
|
||||
let server: PuterServer;
|
||||
let controller: ProfileController;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
controller = server.controllers.profile as unknown as ProfileController;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const makeUser = async (): Promise<UserRow> => {
|
||||
const username = `pc-${Math.random().toString(36).slice(2, 10)}`;
|
||||
return server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
requires_email_confirmation: false,
|
||||
} as Parameters<typeof server.stores.user.create>[0]);
|
||||
};
|
||||
|
||||
const getOwn = async (uuid: string) => {
|
||||
let body: unknown;
|
||||
const res = {
|
||||
json: (value: unknown) => {
|
||||
body = value;
|
||||
},
|
||||
} as unknown as Response;
|
||||
await controller.get(
|
||||
{ query: {}, actor: { user: { uuid } } } as unknown as Request,
|
||||
res,
|
||||
);
|
||||
return body;
|
||||
};
|
||||
|
||||
const isProfilePath = (path: unknown) =>
|
||||
String(path).startsWith('/system/profiles/');
|
||||
|
||||
it('starts reading the profile before the account lookup returns', async () => {
|
||||
const user = await makeUser();
|
||||
await server.services.profile.updateProfile(user, { bio: 'hi' });
|
||||
|
||||
const order: string[] = [];
|
||||
const getByUuid = server.stores.user.getByUuid.bind(server.stores.user);
|
||||
vi.spyOn(server.stores.user, 'getByUuid').mockImplementation(
|
||||
async (...args: Parameters<typeof getByUuid>) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
order.push('account');
|
||||
return getByUuid(...args);
|
||||
},
|
||||
);
|
||||
const getEntryByPath = server.stores.fsEntry.getEntryByPath.bind(
|
||||
server.stores.fsEntry,
|
||||
);
|
||||
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockImplementation(
|
||||
async (...args: Parameters<typeof getEntryByPath>) => {
|
||||
if (isProfilePath(args[0])) order.push('profile');
|
||||
return getEntryByPath(...args);
|
||||
},
|
||||
);
|
||||
|
||||
await expect(getOwn(user.uuid)).resolves.toEqual({
|
||||
picture: null,
|
||||
displayName: null,
|
||||
bio: 'hi',
|
||||
});
|
||||
expect(order).toEqual(['profile', 'account']);
|
||||
});
|
||||
|
||||
it('404s a caller with no account, whatever the profile read did', async () => {
|
||||
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
|
||||
new Error('profile read failed'),
|
||||
);
|
||||
await expect(getOwn(uuidv4())).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces the account lookup failure before the profile read failure', async () => {
|
||||
vi.spyOn(server.stores.user, 'getByUuid').mockRejectedValue(
|
||||
new Error('account lookup failed'),
|
||||
);
|
||||
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
|
||||
new Error('profile read failed'),
|
||||
);
|
||||
await expect(getOwn(uuidv4())).rejects.toThrow('account lookup failed');
|
||||
});
|
||||
|
||||
it('surfaces a failed profile read for an existing account', async () => {
|
||||
const user = await makeUser();
|
||||
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
|
||||
new Error('profile read failed'),
|
||||
);
|
||||
await expect(getOwn(user.uuid)).rejects.toThrow('profile read failed');
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,10 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { ProfileService } from '../../services/profile/ProfileService.js';
|
||||
import type {
|
||||
ProfileService,
|
||||
UserProfile,
|
||||
} from '../../services/profile/ProfileService.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
// Shape only; the store decides whether the account exists.
|
||||
@@ -56,13 +59,21 @@ export class ProfileController extends PuterController {
|
||||
const username = req.query.username;
|
||||
|
||||
let target;
|
||||
let ownProfile: PromiseSettledResult<UserProfile> | undefined;
|
||||
if (username === undefined) {
|
||||
if (!caller?.uuid) {
|
||||
throw new HttpError(401, 'Sign in, or pass a username', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
target = await this.stores.user.getByUuid(caller.uuid);
|
||||
// Read alongside the account; discarded if there's no account.
|
||||
const [found, profile] = await Promise.allSettled([
|
||||
this.stores.user.getByUuid(caller.uuid),
|
||||
service.getProfile({ uuid: caller.uuid }),
|
||||
]);
|
||||
if (found.status === 'rejected') throw found.reason;
|
||||
target = found.value;
|
||||
ownProfile = profile;
|
||||
} else {
|
||||
if (
|
||||
typeof username !== 'string' ||
|
||||
@@ -80,6 +91,11 @@ export class ProfileController extends PuterController {
|
||||
if (!isOwner && !(await service.isPubliclyVisible(target))) {
|
||||
throw notFound();
|
||||
}
|
||||
if (ownProfile && isOwner) {
|
||||
if (ownProfile.status === 'rejected') throw ownProfile.reason;
|
||||
res.json(ownProfile.value);
|
||||
return;
|
||||
}
|
||||
res.json(await service.getProfile(target));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -1530,25 +1531,34 @@ describe('PuterAIController videoProxy upstream handling', () => {
|
||||
} as unknown as Response);
|
||||
|
||||
const handler = captureGet('/puterai/video/proxy');
|
||||
const { res, captured } = makeRes();
|
||||
// The body is piped, so the response has to be a real stream.
|
||||
const sink: Buffer[] = [];
|
||||
(res as unknown as Record<string, unknown>).write = (
|
||||
chunk: Buffer,
|
||||
) => {
|
||||
sink.push(Buffer.from(chunk));
|
||||
return true;
|
||||
};
|
||||
(res as unknown as Record<string, unknown>).emit = () => true;
|
||||
const headers: Record<string, string> = {};
|
||||
const res = Object.assign(
|
||||
new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
sink.push(Buffer.from(chunk));
|
||||
cb();
|
||||
},
|
||||
}),
|
||||
{
|
||||
setHeader: (k: string, v: string) => {
|
||||
headers[k] = v;
|
||||
},
|
||||
},
|
||||
) as unknown as Response;
|
||||
|
||||
await handler(makeReq({ query: signedQuery('vid1') }), res);
|
||||
await settleStream();
|
||||
await finished(res as unknown as Writable);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'vid1:download?alt=media&key=gemini-test-key',
|
||||
),
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
expect(captured.headers['Content-Type']).toBe('video/mp4');
|
||||
expect(headers['Content-Type']).toBe('video/mp4');
|
||||
expect(Buffer.concat(sink).toString('utf8')).toBe('video-bytes');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Context } from '../../core/context.js';
|
||||
import { abortOnDisconnect } from '../../core/http/abortOnDisconnect.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import {
|
||||
@@ -337,8 +338,10 @@ export class PuterAIController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
// A visitor who leaves stops the download, not just the reply.
|
||||
const upstream = await fetch(
|
||||
`${GEMINI_DOWNLOAD_BASE}/${fileId}:download?alt=media&key=${apiKey}`,
|
||||
{ signal: abortOnDisconnect(res) },
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
res.status(upstream.status).send('Failed to fetch video');
|
||||
@@ -351,9 +354,12 @@ export class PuterAIController extends PuterController {
|
||||
res.status(500).send('Empty response body');
|
||||
return;
|
||||
}
|
||||
Readable.fromWeb(
|
||||
upstream.body as unknown as import('node:stream/web').ReadableStream,
|
||||
).pipe(res);
|
||||
void pipeToResponse(
|
||||
Readable.fromWeb(
|
||||
upstream.body as unknown as import('node:stream/web').ReadableStream,
|
||||
),
|
||||
res,
|
||||
);
|
||||
};
|
||||
|
||||
#listModels(driverKey: 'aiChat' | 'aiImage' | 'aiVideo') {
|
||||
@@ -539,7 +545,8 @@ export class PuterAIController extends PuterController {
|
||||
}
|
||||
} else if (ev.type === 'usage') {
|
||||
usageDetails = ev.usageDetails as
|
||||
UsageDetails | undefined;
|
||||
| UsageDetails
|
||||
| undefined;
|
||||
finishReason = ev.finish_reason as string | undefined;
|
||||
} else if (ev.type === 'error') {
|
||||
res.write(
|
||||
@@ -687,7 +694,8 @@ export class PuterAIController extends PuterController {
|
||||
sendChunk(ev.text);
|
||||
} else if (ev.type === 'usage') {
|
||||
usageDetails = ev.usageDetails as
|
||||
UsageDetails | undefined;
|
||||
| UsageDetails
|
||||
| undefined;
|
||||
finishReason = ev.finish_reason as string | undefined;
|
||||
} else if (ev.type === 'error') {
|
||||
res.write(
|
||||
@@ -741,7 +749,8 @@ export class PuterAIController extends PuterController {
|
||||
text: extractTextContent(
|
||||
(
|
||||
messageResult.message as
|
||||
Record<string, unknown> | undefined
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.content,
|
||||
),
|
||||
index: 0,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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 crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { allocateEphemeralPort, setupTestServer } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
|
||||
/**
|
||||
* The video proxy over a real socket: the provider download is the only thing
|
||||
* stubbed, so what reaches the client and what a hang-up does upstream are the
|
||||
* server's own behavior.
|
||||
*/
|
||||
describe('video proxy over HTTP', () => {
|
||||
const DOWNLOAD_BASE = 'https://generativelanguage.googleapis.com/';
|
||||
const SECRET = 'video-proxy-test-secret';
|
||||
let server: PuterServer;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await allocateEphemeralPort();
|
||||
server = await setupTestServer(
|
||||
{
|
||||
port,
|
||||
domain: 'puter.localhost',
|
||||
origin: `http://puter.localhost:${port}`,
|
||||
api_base_url: `http://api.puter.localhost:${port}`,
|
||||
url_signature_secret: SECRET,
|
||||
providers: {
|
||||
'gemini-video-generation': { apiKey: 'test-key' },
|
||||
},
|
||||
} as unknown as IConfig,
|
||||
{ listen: true },
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const signedPath = (fileId: string) => {
|
||||
const expires = String(Math.floor(Date.now() / 1000) + 60);
|
||||
const signature = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${fileId}/video-proxy/${SECRET}/${expires}`)
|
||||
.digest('hex');
|
||||
return `/puterai/video/proxy?${new URLSearchParams({
|
||||
fileId,
|
||||
expires,
|
||||
signature,
|
||||
provider: 'gemini',
|
||||
})}`;
|
||||
};
|
||||
|
||||
/** Serves `body` as the provider download and records what upstream sees. */
|
||||
const stubDownload = (body: ReadableStream<Uint8Array>) => {
|
||||
const seen: { signal?: AbortSignal } = {};
|
||||
const realFetch = globalThis.fetch;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input, init) => {
|
||||
if (!String(input).startsWith(DOWNLOAD_BASE)) {
|
||||
return realFetch(input, init);
|
||||
}
|
||||
seen.signal = init?.signal ?? undefined;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
});
|
||||
},
|
||||
);
|
||||
return seen;
|
||||
};
|
||||
|
||||
const get = (path: string) =>
|
||||
http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
headers: { host: `api.puter.localhost:${port}` },
|
||||
});
|
||||
|
||||
const waitFor = async (check: () => boolean) => {
|
||||
for (let i = 0; i < 100 && !check(); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
return check();
|
||||
};
|
||||
|
||||
it('streams the provider download to the client byte for byte', async () => {
|
||||
const parts = ['video', '-', 'bytes'].map((s) =>
|
||||
new TextEncoder().encode(s),
|
||||
);
|
||||
stubDownload(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const part of parts) controller.enqueue(part);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { status, contentType, body } = await new Promise<{
|
||||
status?: number;
|
||||
contentType?: string;
|
||||
body: string;
|
||||
}>((resolve, reject) => {
|
||||
const req = get(signedPath('vid-ok'));
|
||||
req.on('response', (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
contentType: res.headers['content-type'],
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
}),
|
||||
);
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(contentType).toBe('video/mp4');
|
||||
expect(body).toBe('video-bytes');
|
||||
});
|
||||
|
||||
it('streams without a listener-leak warning on the response', async () => {
|
||||
stubDownload(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('video'));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const warnings: string[] = [];
|
||||
const onWarning = (warning: Error) => warnings.push(warning.name);
|
||||
process.on('warning', onWarning);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = get(signedPath('vid-listeners'));
|
||||
req.on('response', (res) => {
|
||||
res.on('data', () => {});
|
||||
res.on('end', resolve);
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
} finally {
|
||||
process.off('warning', onWarning);
|
||||
}
|
||||
expect(warnings).not.toContain('MaxListenersExceededWarning');
|
||||
});
|
||||
|
||||
it('stops the provider download when the client hangs up', async () => {
|
||||
let cancelled = false;
|
||||
const seen = stubDownload(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('first'));
|
||||
},
|
||||
// Never finishes on its own: only a cancel ends it.
|
||||
pull: () => new Promise(() => {}),
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = get(signedPath('vid-leave'));
|
||||
req.on('response', (res) => {
|
||||
res.once('data', () => {
|
||||
req.destroy();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
if (!req.destroyed) reject(err);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(await waitFor(() => cancelled)).toBe(true);
|
||||
expect(seen.signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts the reply off when the provider download breaks mid-stream', async () => {
|
||||
stubDownload(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('partial'));
|
||||
setTimeout(
|
||||
() => controller.error(new Error('upstream broke')),
|
||||
10,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const outcome = await new Promise<string>((resolve) => {
|
||||
const req = get(signedPath('vid-broken'));
|
||||
req.on('response', (res) => {
|
||||
res.on('data', () => {});
|
||||
res.on('end', () => resolve('ended'));
|
||||
res.on('error', () => resolve('aborted'));
|
||||
res.on('aborted', () => resolve('aborted'));
|
||||
});
|
||||
req.on('error', () => resolve('aborted'));
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(outcome).toBe('aborted');
|
||||
});
|
||||
});
|
||||
@@ -341,6 +341,61 @@ describe('share endpoints over HTTP', () => {
|
||||
expect(replayed.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops what a folder delegate re-shared once the item leaves the folder', async () => {
|
||||
const owner = await makeUser();
|
||||
const delegate = await makeUser();
|
||||
const holder = await makeUser();
|
||||
|
||||
const dir = (await (
|
||||
await post('/mkdir', owner.token, {
|
||||
path: `/${owner.username}/Proj`,
|
||||
})
|
||||
).json()) as { uid: string };
|
||||
await post('/touch', owner.token, {
|
||||
path: `/${owner.username}/Proj/plan.txt`,
|
||||
});
|
||||
const file = (await (
|
||||
await post('/stat', owner.token, {
|
||||
path: `/${owner.username}/Proj/plan.txt`,
|
||||
})
|
||||
).json()) as { uid: string };
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [delegate.username],
|
||||
items: [{ uid: dir.uid }],
|
||||
mode: 'manage',
|
||||
});
|
||||
await post('/share', delegate.token, {
|
||||
recipients: [holder.username],
|
||||
items: [{ uid: file.uid }],
|
||||
mode: 'read',
|
||||
});
|
||||
const listedTo = async (token: string) =>
|
||||
(
|
||||
(await (
|
||||
await get('/share/shared-by-me', token, {})
|
||||
).json()) as { items: Array<{ uid_entry: string }> }
|
||||
).items.map((i) => i.uid_entry);
|
||||
expect(await listedTo(delegate.token)).toContain(file.uid);
|
||||
|
||||
const moved = await post('/move', owner.token, {
|
||||
source: file.uid,
|
||||
destination: `/${owner.username}`,
|
||||
});
|
||||
expect(moved.status).toBe(200);
|
||||
// A move doesn't bump readings; this stands in for the scan-cache TTL.
|
||||
const delegateRow = await env.server.stores.user.getByUsername(
|
||||
delegate.username,
|
||||
);
|
||||
await env.server.stores.permission.bumpCacheGeneration(
|
||||
`user:${delegateRow!.uuid}`,
|
||||
);
|
||||
|
||||
expect(await listedTo(delegate.token)).not.toContain(file.uid);
|
||||
// The holder's grant still stands, so its owner still sees it.
|
||||
expect(await listedTo(owner.token)).toContain(file.uid);
|
||||
});
|
||||
|
||||
// A caller who believes they filtered must not silently receive
|
||||
// everything: a duplicated param arrives as an array, and an empty
|
||||
// string names nothing.
|
||||
|
||||
@@ -151,6 +151,12 @@ describe('StaticAssetsController client_libs_root', () => {
|
||||
|
||||
const sentPutility = await callGetHandler(router, '/putility/v1');
|
||||
expect(sentPutility).toEqual({ filename: 'putility.js/v1.js', root });
|
||||
|
||||
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
|
||||
expect(findRoute(router, 'get', route)?.options.subdomain).toBe('js');
|
||||
const sentWorker = await callGetHandler(router, route);
|
||||
expect(sentWorker).toEqual({ filename: 'puter.js/push-sw.js', root });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not register the client_libs routes when the root is unset', () => {
|
||||
@@ -186,6 +192,11 @@ describe('StaticAssetsController puterjs_root', () => {
|
||||
expect(v1.root).toBe(root);
|
||||
const bareV1 = await callGetHandler(router, '/v1');
|
||||
expect(bareV1.filename).toBe('puter.js');
|
||||
|
||||
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
|
||||
const worker = await callGetHandler(router, route);
|
||||
expect(worker).toEqual({ filename: 'push-sw.js', root });
|
||||
}
|
||||
});
|
||||
|
||||
it('serves puter.dev.js when present (dev webpack build)', async () => {
|
||||
|
||||
@@ -26,9 +26,9 @@ import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
/**
|
||||
* Static asset routes.
|
||||
*
|
||||
* /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /putility/v1 → js
|
||||
* subdomain /sdk/* → root subdomain — puter-js bundle /dist/_, /src/_,
|
||||
* /assets/* → root subdomain
|
||||
* /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /push-sw.js,
|
||||
* /putility/v1 → js subdomain /sdk/* → root subdomain — puter-js bundle
|
||||
* /dist/_, /src/_, /assets/* → root subdomain
|
||||
*
|
||||
* Each block depends on its config root (`client_libs_root`, `gui_assets_root`,
|
||||
* `puterjs_root`). When unset, that block is skipped — deployments that don't
|
||||
@@ -55,6 +55,12 @@ export class StaticAssetsController extends PuterController {
|
||||
router.get('/putility/v1', { subdomain: 'js' }, (_req, res) => {
|
||||
res.sendFile('putility.js/v1.js', { root });
|
||||
});
|
||||
// `/v2/push-sw.js` is the path older docs gave.
|
||||
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
|
||||
router.get(route, { subdomain: 'js' }, (_req, res) => {
|
||||
res.sendFile('puter.js/push-sw.js', { root });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// puter-js SDK mount. GUI loads it at `/sdk/puter.dev.js`; the
|
||||
@@ -100,6 +106,11 @@ export class StaticAssetsController extends PuterController {
|
||||
router.get('/v2', { subdomain: 'js' }, (_req, res) => {
|
||||
res.sendFile(puterJsFile, { root: puterjsRoot });
|
||||
});
|
||||
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
|
||||
router.get(route, { subdomain: 'js' }, (_req, res) => {
|
||||
res.sendFile('push-sw.js', { root: puterjsRoot });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,978 +0,0 @@
|
||||
/**
|
||||
* 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 { makeActor } from '../../core/actor.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
const randomHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
describe('team endpoints over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
// The cap has its own suite; these tests need many teams.
|
||||
env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
max_teams_per_user: 100,
|
||||
} as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
/** An app token acting for `owner`, minted the way the dashboard does. */
|
||||
const makeAppToken = async (owner: { username: string }) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `team-http-app-${crypto.randomUUID()}`,
|
||||
title: 'Team app',
|
||||
index_url: `https://team-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id! },
|
||||
);
|
||||
return env.server.services.auth.getUserAppToken(actor, app.uid);
|
||||
};
|
||||
|
||||
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
|
||||
const makeScopedToken = async (owner: {
|
||||
username: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
|
||||
await env.server.services.fs.mkdir(user!.id, {
|
||||
path: anchor,
|
||||
createMissingParents: true,
|
||||
});
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
owner.token,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(
|
||||
actor!,
|
||||
[[`fs:${entry!.uid}:list`]],
|
||||
{ label: 'teams-scope' },
|
||||
);
|
||||
};
|
||||
|
||||
/** A team owned by `user`, with one provisioned member. */
|
||||
const makeTeam = async () => {
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Acme',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
// Unasserted, a broken create means tests run on `/teams/undefined`.
|
||||
expect(res.status).toBe(200);
|
||||
const team = (await res.json()) as { uid: string };
|
||||
expect(team.uid).toBeTruthy();
|
||||
|
||||
const username = `http_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const member = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
{ username, email: `${username}@test.local` },
|
||||
);
|
||||
expect(member.status).toBe(200);
|
||||
return { team, memberUsername: username };
|
||||
};
|
||||
|
||||
// -- the owner-account gate --------------------------------------
|
||||
|
||||
it('creates a team and reports the caller as its owner', async () => {
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Acme Design',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).toMatchObject({ name: 'Acme Design', is_owner: true });
|
||||
expect(body.uid).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
// `id` is internal and must not reach the wire.
|
||||
expect(body).not.toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('returns 404, not 403, to a non-member', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
// 403 would confirm the team exists.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses every administrative route to a member who is not the owner', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
// An activated seat: provisioning leaves the email unconfirmed, which
|
||||
// `requireVerified` rejects, so an unactivated one cannot call at all.
|
||||
const provisioned = await env.server.stores.user.getByUsername(
|
||||
memberUsername,
|
||||
);
|
||||
await env.server.stores.user.update(provisioned!.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
const seat = await env.server.stores.user.getByUsername(memberUsername);
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
seat!,
|
||||
);
|
||||
|
||||
for (const [method, path] of [
|
||||
['PUT', `/teams/${team.uid}`],
|
||||
['DELETE', `/teams/${team.uid}`],
|
||||
['POST', `/teams/${team.uid}/members`],
|
||||
['GET', `/teams/${team.uid}/audit`],
|
||||
] as const) {
|
||||
const res = await call(
|
||||
method,
|
||||
path,
|
||||
token,
|
||||
method === 'GET' ? undefined : { name: 'nope' },
|
||||
);
|
||||
expect(res.status, `${method} ${path}`).toBe(403);
|
||||
}
|
||||
|
||||
// Still a member, so reads it is entitled to still work.
|
||||
const readable = await call('GET', `/teams/${team.uid}`, token);
|
||||
expect(readable.status).toBe(200);
|
||||
});
|
||||
|
||||
// -- the org_owned guard ------------------------------------------
|
||||
|
||||
it('refuses the team owner as the target of a member route', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members/${env.users.user.username}/disable`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes a seat over HTTP only once it is disabled', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
const path = `/teams/${team.uid}/members/${memberUsername}`;
|
||||
|
||||
const live = await call('DELETE', path, env.users.user.token);
|
||||
expect(live.status).toBe(409);
|
||||
expect(await live.json()).toMatchObject({
|
||||
code: 'account_must_be_disabled_first',
|
||||
});
|
||||
|
||||
await call('POST', `${path}/disable`, env.users.user.token);
|
||||
const gone = await call('DELETE', path, env.users.user.token);
|
||||
|
||||
expect(gone.status).toBe(200);
|
||||
expect(
|
||||
await env.server.stores.user.getByUsername(memberUsername),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
// -- the directory ------------------------------------------------
|
||||
|
||||
it('keeps the directory shut until the team opts in', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const before = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.user.token,
|
||||
);
|
||||
// 404, not 403: whether this is on is not an app's to probe for.
|
||||
expect(before.status).toBe(404);
|
||||
|
||||
const on = await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
|
||||
directory_enabled: true,
|
||||
});
|
||||
expect(on.status).toBe(200);
|
||||
expect(await on.json()).toMatchObject({ directory_enabled: true });
|
||||
|
||||
const after = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as { items: { username: string }[] };
|
||||
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
|
||||
|
||||
// A scoped token was minted for one thing; a roster is not it.
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
const narrow = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
typeof scoped === 'string' ? scoped : scoped.token,
|
||||
);
|
||||
expect(narrow.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses the directory to someone outside the team', async () => {
|
||||
const { team } = await makeTeam();
|
||||
await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
|
||||
directory_enabled: true,
|
||||
});
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- app actors, gated by the same opt-in as the directory --------
|
||||
//
|
||||
// Built through the service, not the wire: these routes share a
|
||||
// rate-limit budget with the mutation tests above, and every HTTP
|
||||
// create/update here would eat into that same window.
|
||||
|
||||
/** A team owned by `user`, created and toggled through the service. */
|
||||
const makeServiceTeam = async () => {
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
return { team, ownerId: owner.id };
|
||||
};
|
||||
|
||||
it('omits a team from an app token until the directory opts in', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call('GET', '/teams', appToken);
|
||||
expect(before.status).toBe(200);
|
||||
const beforeBody = (await before.json()) as { items: { uid: string }[] };
|
||||
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call('GET', '/teams', appToken);
|
||||
expect(after.status).toBe(200);
|
||||
const afterBody = (await after.json()) as { items: { uid: string }[] };
|
||||
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count a team it hides from an app token', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', '/teams?includeTotal=true', appToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { uid: string }[];
|
||||
total: number;
|
||||
};
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
// A count over rows the page withholds reports the hidden team.
|
||||
expect(body.total).toBe(body.items.length);
|
||||
});
|
||||
|
||||
it('still lists a team to its owner session with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.token);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('lists a team to a full-access API token with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.apiToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username,
|
||||
});
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(before.status).toBe(404);
|
||||
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(body.items.length).toBeGreaterThan(0);
|
||||
// An app gets username only — no org_owned, created_at, or uuid.
|
||||
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
|
||||
});
|
||||
|
||||
it('still refuses an app token on GET /teams/:uid', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', `/teams/${team.uid}`, appToken);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username: neverActivated,
|
||||
});
|
||||
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const suspended = await env.server.services.team.provisionAccount(
|
||||
team.uid,
|
||||
ownerId,
|
||||
{ username: suspendedUsername },
|
||||
);
|
||||
await env.server.services.team.disableMember(
|
||||
team.uid,
|
||||
ownerId,
|
||||
suspended.userId,
|
||||
);
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
const appView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(appView.status).toBe(200);
|
||||
const appItems = (
|
||||
(await appView.json()) as { items: Record<string, unknown>[] }
|
||||
).items;
|
||||
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
|
||||
const appUsernames = appItems.map((m) => m.username);
|
||||
expect(appUsernames).not.toContain(neverActivated);
|
||||
expect(appUsernames).not.toContain(suspendedUsername);
|
||||
// The owner's own seat is active, so it still shows.
|
||||
expect(appUsernames).toContain(env.users.user.username);
|
||||
|
||||
// The owner's own session sees every seat regardless of status.
|
||||
const ownerView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(ownerView.status).toBe(200);
|
||||
const ownerUsernames = (
|
||||
(await ownerView.json()) as { items: { username: string }[] }
|
||||
).items.map((m) => m.username);
|
||||
expect(ownerUsernames).toContain(neverActivated);
|
||||
expect(ownerUsernames).toContain(suspendedUsername);
|
||||
});
|
||||
|
||||
it('gives a full-access API token the owner`s uuid on /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.apiToken,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { username: string; uuid?: string }[];
|
||||
};
|
||||
const owner = body.items.find(
|
||||
(m) => m.username === env.users.user.username,
|
||||
);
|
||||
expect(owner?.uuid).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('refuses a scoped access token on GET /teams and /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
|
||||
const teamsRes = await call('GET', '/teams', scoped);
|
||||
expect(teamsRes.status).toBe(403);
|
||||
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
|
||||
const membersRes = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
scoped,
|
||||
);
|
||||
expect(membersRes.status).toBe(403);
|
||||
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('lists members with org_owned distinguishing the owner', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { username: string; org_owned: boolean }[];
|
||||
};
|
||||
const owner = body.items.find(
|
||||
(m) => m.username === env.users.user.username,
|
||||
);
|
||||
const member = body.items.find((m) => m.username === memberUsername);
|
||||
expect(owner?.org_owned).toBe(false);
|
||||
expect(member?.org_owned).toBe(true);
|
||||
});
|
||||
|
||||
it('gives seat uuids to the owner and to nobody else', async () => {
|
||||
// Built through the service, not the wire: provisioning shares a
|
||||
// rate-limit budget with the seat tests below, and one more HTTP
|
||||
// provision here 429s them.
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const slug0 = Math.random().toString(36).slice(2, 9);
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: `uuidt-${slug0}`,
|
||||
});
|
||||
const memberUsername = `seat_${slug0}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, owner.id, {
|
||||
username: memberUsername,
|
||||
});
|
||||
|
||||
const owned = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, env.users.user.token)
|
||||
).json()) as { items: { username: string; uuid?: string }[] };
|
||||
const seat = owned.items.find((m) => m.username === memberUsername);
|
||||
// Billing keys a seat's plan on this, so the owner cannot act without it.
|
||||
expect(seat?.uuid).toEqual(expect.any(String));
|
||||
|
||||
// A throwaway member, not the shared fixture: joining a team is
|
||||
// permanent and would follow `other` into every later test.
|
||||
const slug = Math.random().toString(36).slice(2, 9);
|
||||
const joiner = await env.server.stores.user.create({
|
||||
username: `joiner_${slug}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: 'hashed',
|
||||
email: `joiner_${slug}@test.local`,
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, joiner.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
const { token } =
|
||||
await env.server.services.auth.createSessionToken(joiner);
|
||||
|
||||
const seen = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, token)
|
||||
).json()) as { items: { uuid?: string }[] };
|
||||
expect(seen.items.length).toBeGreaterThan(0);
|
||||
for (const m of seen.items) expect(m.uuid).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- provisioning over the wire -----------------------------------
|
||||
|
||||
it('never returns the activation link to the administrator', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const username = `secret_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
{ username, email: `${username}@test.local` },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const raw = JSON.stringify(await res.json());
|
||||
// Emailed to the member; returning it would let an admin use it.
|
||||
expect(raw).not.toContain('set-new-password');
|
||||
expect(raw).not.toContain('token');
|
||||
});
|
||||
|
||||
it('refuses a taken username and offers alternatives', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
{
|
||||
username: env.users.other.username,
|
||||
email: '[email protected]',
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as {
|
||||
suggestions?: string[];
|
||||
fields?: { suggestions?: string[] };
|
||||
};
|
||||
const suggestions = body.suggestions ?? body.fields?.suggestions ?? [];
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions).not.toContain(env.users.other.username);
|
||||
});
|
||||
|
||||
// -- audit --------------------------------------------------------
|
||||
|
||||
it('exposes the team audit to the team owner only', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const mine = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(mine.status).toBe(200);
|
||||
const body = (await mine.json()) as { items: { action: string }[] };
|
||||
expect(body.items.map((e) => e.action)).toContain('provision');
|
||||
|
||||
const theirs = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(theirs.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- sharing with a team -------------------------------------
|
||||
|
||||
it('names the team it shared with instead of an empty recipient', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const file = `/${env.users.user.username}/Documents/label-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
const written = await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
expect(written.status).toBe(200);
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ team: team.uid }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string; status: string }[];
|
||||
};
|
||||
|
||||
expect(body.results[0].status).toBe('success');
|
||||
// Echoes the identifier the caller named; '' would leave a client with
|
||||
// nothing to render or to match its request against.
|
||||
expect(body.results[0].recipient).toBe(team.uid);
|
||||
});
|
||||
|
||||
it('shares with two teams rather than collapsing them into one', async () => {
|
||||
const second = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Second',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
expect(second.status).toBe(200);
|
||||
const teamB = (await second.json()) as { uid: string };
|
||||
const { team: teamA } = await makeTeam();
|
||||
|
||||
const file = `/${env.users.user.username}/Documents/two-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
const written = await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
const fileUid = ((await written.json()) as { fsEntry: { uid: string } })
|
||||
.fsEntry.uid;
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ team: teamA.uid }, { team: teamB.uid }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string; status: string }[];
|
||||
};
|
||||
|
||||
expect(body.results).toHaveLength(2);
|
||||
expect(body.results.every((r) => r.status === 'success')).toBe(true);
|
||||
|
||||
// The response shape alone proves nothing: `results` is built from the
|
||||
// request's pairs, so a collapsed pair still reports two successes with
|
||||
// the right labels. Only the shares that exist afterwards show it.
|
||||
const mine = await call(
|
||||
'GET',
|
||||
'/share/shared-by-me?limit=100',
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(mine.status).toBe(200);
|
||||
const listing = (await mine.json()) as {
|
||||
items: { uid_entry?: string; holder_team?: { uid: string } }[];
|
||||
};
|
||||
const holders = listing.items
|
||||
.filter((i) => i.uid_entry === fileUid)
|
||||
.map((i) => i.holder_team?.uid)
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
expect(holders).toEqual([teamA.uid, teamB.uid].sort());
|
||||
});
|
||||
|
||||
it('names the team by handle when the caller shared by handle', async () => {
|
||||
const handle = randomHandle();
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Byhandle',
|
||||
handle,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const file = `/${env.users.user.username}/Documents/handle-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ teamHandle: handle }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string }[];
|
||||
};
|
||||
expect(body.results[0].recipient).toBe(handle);
|
||||
});
|
||||
|
||||
it('gives a non-member 404 on the member view, not an empty page', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit/me`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- the forced-change gate ---------------------------------------
|
||||
|
||||
/**
|
||||
* A seat that has signed in on its temporary password. Email confirmation
|
||||
* is cleared first so the gate under test is the one that answers.
|
||||
*/
|
||||
const signedInSeat = async (teamUid: string) => {
|
||||
const username = `seat_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${teamUid}/members`,
|
||||
env.users.user.token,
|
||||
{ username, email: `${username}@test.local` },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const { temporary_password: password } = (await res.json()) as {
|
||||
temporary_password: string;
|
||||
};
|
||||
|
||||
const row = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(row.id, {
|
||||
requires_email_confirmation: false,
|
||||
email_confirmed: true,
|
||||
});
|
||||
await env.server.stores.user.invalidateById(row.id);
|
||||
|
||||
const fresh = (await env.server.stores.user.getById(row.id))!;
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
fresh,
|
||||
{ user_agent: 'puter-test-seat' },
|
||||
);
|
||||
return { username, password, userId: row.id, token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Cookie-credentialed on the GUI origin, as the user-protected gate insists.
|
||||
* Sent with the seat's own user agent: the session touch otherwise rewrites
|
||||
* it to Node's once a second boundary passes.
|
||||
*/
|
||||
const changePassword = (token: string, password: string, next: string) =>
|
||||
fetch(new URL('/user-protected/change-password', env.origin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'puter-test-seat',
|
||||
authorization: `Bearer ${token}`,
|
||||
cookie: `puter_auth_token=${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password, new_pass: next }),
|
||||
});
|
||||
|
||||
it('signs the seat in but refuses every authenticated route after that', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
|
||||
const res = await call('GET', '/teams', seat.token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.text()).toContain('password_change_required');
|
||||
});
|
||||
|
||||
it('leaves change-password reachable, since it is what clears the gate', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
|
||||
const changed = await changePassword(
|
||||
seat.token,
|
||||
seat.password,
|
||||
'chosen-by-the-member',
|
||||
);
|
||||
expect(changed.status).toBe(200);
|
||||
|
||||
const row = await env.server.stores.user.getByProperty(
|
||||
'id',
|
||||
seat.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(Number(row?.requires_password_change)).toBe(0);
|
||||
expect(row?.temp_password_expires_at ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('admits the seat to the product once it has chosen a password', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
expect(
|
||||
(await changePassword(seat.token, seat.password, 'my-own-password'))
|
||||
.status,
|
||||
).toBe(200);
|
||||
|
||||
// The session that changed the password is the one that survives.
|
||||
const res = await call('GET', '/teams', seat.token);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('closes re-issue behind a seat that has activated', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'a-password-of-mine');
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members/${seat.username}/activation`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('shows the member the reset and their own sign-in, and nothing else', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'chosen-once-already');
|
||||
|
||||
const reset = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members/${seat.username}/password-reset`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(reset.status).toBe(200);
|
||||
const { temporary_password: issued } = (await reset.json()) as {
|
||||
temporary_password: string;
|
||||
};
|
||||
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getById(seat.userId))!,
|
||||
{ user_agent: 'puter-test-seat' },
|
||||
);
|
||||
const mine = await call('GET', `/teams/${team.uid}/audit/me`, token);
|
||||
// The seat owes a password change again, so its own view is all it reaches.
|
||||
expect(mine.status).toBe(403);
|
||||
|
||||
const admin = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit`,
|
||||
env.users.user.token,
|
||||
);
|
||||
const body = (await admin.json()) as { items: { action: string }[] };
|
||||
expect(body.items.map((e) => e.action)).toContain(
|
||||
'reset_member_password',
|
||||
);
|
||||
expect(JSON.stringify(body)).not.toContain(issued);
|
||||
});
|
||||
|
||||
it('shows an activated seat its own reset and sign-in over the wire', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'the-one-i-picked');
|
||||
|
||||
const res = await call('GET', `/teams/${team.uid}/audit/me`, seat.token);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: {
|
||||
action: string;
|
||||
username: string | null;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
}[];
|
||||
};
|
||||
expect(body.items.map((e) => e.action)).toContain('activate');
|
||||
|
||||
const tell = body.items.find((e) => e.action === 'sign_in');
|
||||
expect(tell?.username).toBe(seat.username);
|
||||
expect(tell?.user_agent).toBe('puter-test-seat');
|
||||
// Only their own; the team owner's entries are not theirs to read.
|
||||
expect(
|
||||
body.items.every((e) => e.username === seat.username),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a temporary password that was never used in time', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await env.server.stores.user.update(seat.userId, {
|
||||
temp_password_expires_at: Math.floor(Date.now() / 1000) - 1,
|
||||
});
|
||||
await env.server.stores.user.invalidateById(seat.userId);
|
||||
|
||||
const res = await fetch(new URL('/login', env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: seat.username,
|
||||
password: seat.password,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.text()).toContain('temporary_password_expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('team endpoints with teams_enabled off', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ teams_enabled: false } as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('404s every team route, so no team code is reachable', async () => {
|
||||
// Real team: 200 with the flag on, so a 404 means no route.
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const team = await env.server.stores.team.create({
|
||||
ownerUserId: owner!.id,
|
||||
name: 'Unreachable',
|
||||
handle: `off-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, owner!.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
|
||||
const paths: [string, string][] = [
|
||||
['POST', '/teams'],
|
||||
['GET', '/teams'],
|
||||
['GET', `/teams/${team.uid}`],
|
||||
['PUT', `/teams/${team.uid}`],
|
||||
['DELETE', `/teams/${team.uid}`],
|
||||
['GET', `/teams/${team.uid}/members`],
|
||||
['POST', `/teams/${team.uid}/members`],
|
||||
['GET', `/teams/${team.uid}/audit`],
|
||||
['GET', `/teams/${team.uid}/audit/me`],
|
||||
];
|
||||
|
||||
for (const [method, path] of paths) {
|
||||
const res = await fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
...(method === 'POST' || method === 'PUT'
|
||||
? { body: JSON.stringify({ name: 'x' }) }
|
||||
: {}),
|
||||
});
|
||||
expect(res.status, `${method} ${path}`).toBe(404);
|
||||
|
||||
// Ours would carry a team legacyCode; the framework's does not.
|
||||
const body = await res.text();
|
||||
expect(body, `${method} ${path}`).not.toContain('team_not_found');
|
||||
expect(body, `${method} ${path}`).not.toContain(
|
||||
'not_the_team_owner',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('registers no team routes at all with the flag off', async () => {
|
||||
// The controller exists and is constructed; only its routes are absent.
|
||||
const controller = env.server.controllers.team as unknown as {
|
||||
isEnabled?: () => boolean;
|
||||
};
|
||||
expect(controller).toBeTruthy();
|
||||
expect(controller.isEnabled?.()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the schema inert rather than absent', async () => {
|
||||
// The flag gates reachability, not DDL.
|
||||
await expect(
|
||||
env.server.stores.team.getByHandle('no-such-team'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,559 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
} from '../../core/http/decorators.js';
|
||||
import { isAccountContext } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { TeamRow } from '../../stores/team/TeamStore.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/** Mirrors ShareController's dual-window shape. */
|
||||
const TEAM_LIMIT = [
|
||||
{ scope: 'team:mutate', limit: 60, window: 60_000, key: 'user' as const },
|
||||
{
|
||||
scope: 'team:mutate-daily',
|
||||
limit: 500,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/** Tighter than TEAM_LIMIT: a reset hands the admin a working credential. */
|
||||
const TEAM_RESET_LIMIT = [
|
||||
...TEAM_LIMIT,
|
||||
{
|
||||
scope: 'team:reset-password',
|
||||
limit: 20,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const TEAM_READ_LIMIT = {
|
||||
scope: 'team:read',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'user' as const,
|
||||
};
|
||||
|
||||
/** What a team looks like on the wire. `id` stays internal. */
|
||||
const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
|
||||
uid: team.uid,
|
||||
name: team.name,
|
||||
handle: team.handle,
|
||||
is_owner: isOwner,
|
||||
created_at: team.created_at,
|
||||
directory_enabled: Number(team.directory_enabled) === 1,
|
||||
require_2fa: Number(team.require_2fa) === 1,
|
||||
});
|
||||
|
||||
@Controller('/teams')
|
||||
export class TeamController extends PuterController {
|
||||
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
|
||||
// use `requireAuth` so an app can read a team whose owner opted into the
|
||||
// directory (see `directory_enabled`).
|
||||
|
||||
/** Off means `/teams` 404s and no team route is registered at all. */
|
||||
isEnabled(): boolean {
|
||||
return this.config.teams_enabled === true;
|
||||
}
|
||||
|
||||
@Post('', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async createTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const body = this.#body(req);
|
||||
|
||||
const team = await this.services.team.createTeam(userId, {
|
||||
name: this.#requireString(body.name, 'name'),
|
||||
handle:
|
||||
body.handle === undefined || body.handle === null
|
||||
? null
|
||||
: this.#requireString(body.handle, 'handle'),
|
||||
});
|
||||
res.json(toClientTeam(team, true));
|
||||
}
|
||||
|
||||
@Get('', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listTeams(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
// In SQL, or cursor and total describe teams the app cannot see.
|
||||
const page = await this.stores.team.listTeamsForUser(userId, {
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
includeTotal: req.query.includeTotal === 'true',
|
||||
openOnly: !isAccountContext(req.actor),
|
||||
});
|
||||
res.json({
|
||||
items: page.items.map((t) =>
|
||||
toClientTeam(t, t.owner_user_id === userId),
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total === undefined ? {} : { total: page.total }),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async getTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const team = await this.services.team.requireMembership(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
res.json(toClientTeam(team, team.owner_user_id === userId));
|
||||
}
|
||||
|
||||
@Put('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async updateTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.services.team.requireOwner(this.#param(req, 'uid'), userId);
|
||||
const body = this.#body(req);
|
||||
|
||||
const changes: {
|
||||
name?: string;
|
||||
handle?: string | null;
|
||||
directoryEnabled?: boolean;
|
||||
require2fa?: boolean;
|
||||
} = {};
|
||||
if (body.name !== undefined)
|
||||
changes.name = this.#requireString(body.name, 'name');
|
||||
if (body.handle !== undefined)
|
||||
changes.handle = body.handle === null ? null : String(body.handle);
|
||||
if (body.directory_enabled !== undefined)
|
||||
changes.directoryEnabled = body.directory_enabled === true;
|
||||
if (body.require_2fa !== undefined)
|
||||
changes.require2fa = body.require_2fa === true;
|
||||
|
||||
// Through the service: turning the directory on writes an audit row.
|
||||
const team = await this.services.team.updateTeam(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
changes,
|
||||
);
|
||||
res.json(toClientTeam(team, true));
|
||||
}
|
||||
|
||||
@Delete('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async deleteTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.services.team.deleteTeam(this.#param(req, 'uid'), userId);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// -- Members ------------------------------------------------------
|
||||
|
||||
@Get('/:uid/members', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listMembers(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const team = await this.services.team.requireMembership(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
const accountContext = isAccountContext(req.actor);
|
||||
// Same consent as the directory: an app only reads a team that opted in.
|
||||
if (!accountContext) this.services.team.assertDirectoryOpen(team);
|
||||
|
||||
// uuid only when the team owner calls with their own session or API
|
||||
// token; an app needing a stable id uses `listDirectory()` instead.
|
||||
const showUuid = accountContext && team.owner_user_id === userId;
|
||||
|
||||
const page = await this.stores.team.listMembers(
|
||||
this.#param(req, 'uid'),
|
||||
{
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
// An app's view matches the directory's: active seats only.
|
||||
activeOnly: !accountContext,
|
||||
},
|
||||
);
|
||||
// An app only ever gets a username; org_owned/created_at are account-only.
|
||||
res.json({
|
||||
items: page.items.map((m) =>
|
||||
accountContext
|
||||
? {
|
||||
username: m.username,
|
||||
org_owned: Number(m.org_owned) === 1,
|
||||
created_at: m.created_at,
|
||||
...(showUuid ? { uuid: m.uuid } : {}),
|
||||
}
|
||||
: { username: m.username },
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** App actors too, once opted in; carries the `uuid` `/members` hides. */
|
||||
@Get('/:uid/directory', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listDirectory(req: Request, res: Response): Promise<void> {
|
||||
// The membership is the person's, never the app's.
|
||||
const userId = this.#requireUserId(req);
|
||||
// A scoped token holds only what it was minted for; a roster is not that.
|
||||
const token = req.actor?.accessToken;
|
||||
if (token && token.fullAccess !== true) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is not available to scoped access tokens',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
const page = await this.services.team.listDirectory(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
{
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
res.json(page);
|
||||
}
|
||||
|
||||
@Post('/:uid/members', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async createMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority before shape, or a stranger learns if their body parsed.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
|
||||
const body = this.#body(req);
|
||||
const result = await this.services.team.provisionAccount(uid, userId, {
|
||||
username: this.#requireString(body.username, 'username'),
|
||||
email: this.#optionalString(body.email, 'email'),
|
||||
});
|
||||
// Shown once; the admin delivers it out of band.
|
||||
res.json({
|
||||
username: result.username,
|
||||
temporary_password: result.temporaryPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// Same budget as a reset: both hand the administrator a working credential,
|
||||
// and a reset re-arms the flag this route needs.
|
||||
@Post('/:uid/members/:username/activation', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async reissueCredential(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
const { temporaryPassword } =
|
||||
await this.services.team.reissueCredential(uid, userId, target);
|
||||
// Shown once for the admin to deliver out of band.
|
||||
res.json({ temporary_password: temporaryPassword });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/password-reset', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async resetMemberPassword(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
const { temporaryPassword } =
|
||||
await this.services.team.resetMemberPassword(uid, userId, target);
|
||||
// Shown once; it is not retrievable afterwards.
|
||||
res.json({ temporary_password: temporaryPassword });
|
||||
}
|
||||
|
||||
// The gate closes the seat's own route to fix it, so the owner must.
|
||||
@Post('/:uid/members/:username/email', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async updateSeatEmail(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.updateSeatEmail(
|
||||
uid,
|
||||
userId,
|
||||
target,
|
||||
(req.body as { email?: unknown } | undefined)?.email ?? null,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// Same budget as a password reset: both are a step toward the account.
|
||||
@Post('/:uid/members/:username/2fa-reset', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async resetMemberTwoFactor(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.resetMemberTwoFactor(uid, userId, target);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/disable', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async disableMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.disableMember(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
target,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/enable', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async enableMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.enableMember(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
target,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Delete('/:uid/members/:username', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async deleteMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first (anti-oracle); deleted team included, see the service.
|
||||
await this.services.team.requireOwnedTeam(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.deleteMember(uid, userId, target);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// -- Audit --------------------------------------------------------
|
||||
|
||||
@Get('/:uid/audit', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listAudit(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
res.json(
|
||||
await this.services.team.listAudit(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
this.#pageOpts(req),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/:uid/audit/me', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listOwnAudit(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
res.json(
|
||||
await this.services.team.listOwnAudit(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
this.#pageOpts(req),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
|
||||
/** `limit` and `cursor` as doc/pagination.md defines them. */
|
||||
#pageOpts(req: Request): { limit?: unknown; cursor?: string } {
|
||||
return {
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain-allowlist gate, on the two routes that enter the feature. Same
|
||||
* 404 as a teams-off deployment; other routes bound by membership.
|
||||
*/
|
||||
async #requireTeamsAvailable(req: Request, userId: number): Promise<void> {
|
||||
const email = (
|
||||
req.actor as { user?: { email?: string | null } } | undefined
|
||||
)?.user?.email;
|
||||
if (await this.services.team.teamsAvailableTo(userId, email)) return;
|
||||
throw new HttpError(404, 'Not found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
#requireUserId(req: Request): number {
|
||||
const id = (req.actor as { user?: { id?: number } } | undefined)?.user
|
||||
?.id;
|
||||
if (!id)
|
||||
throw new HttpError(401, 'User required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Express types a param as `string | string[]`; routes here take one. */
|
||||
#param(req: Request, name: string): string {
|
||||
const value = req.params[name];
|
||||
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '');
|
||||
}
|
||||
|
||||
#body(req: Request): Record<string, unknown> {
|
||||
return (req.body ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Absent or empty means "not given"; a wrong type is still a 400. */
|
||||
#optionalString(value: unknown, field: string): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, `${field} must be a string`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value.trim() === '' ? null : value;
|
||||
}
|
||||
|
||||
#requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new HttpError(400, `${field} is required`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
#notFound(): HttpError {
|
||||
return new HttpError(404, 'Team not found', {
|
||||
legacyCode: 'team_not_found',
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves `:username` to an id; the service decides whether it may act. */
|
||||
async #requireTargetUserId(req: Request): Promise<number> {
|
||||
const user = await this.stores.user.getByUsername(
|
||||
this.#param(req, 'username'),
|
||||
);
|
||||
if (!user)
|
||||
throw new HttpError(404, 'Not an account of this team', {
|
||||
legacyCode: 'not_an_org_account',
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A team manages accounts and cannot read them. Nobody writes that grant
|
||||
* deliberately, but a new implicator or a widened actor would create it and no
|
||||
* other test would fail. Asserts outcomes, never that an implicator is absent.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
describe('a team cannot read its members data', () => {
|
||||
let env: PuterTestEnv;
|
||||
let teamUid: string;
|
||||
let memberUsername: string;
|
||||
let memberFile: string;
|
||||
let ownerFile: string;
|
||||
let memberToken: string;
|
||||
|
||||
/** Written directly: these tests are about reading, not about writing. */
|
||||
const makeFile = async (username: string, label: string) => {
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `${label}-${uid.slice(0, 8)}.txt`;
|
||||
const path = `/${username}/${name}`;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
name,
|
||||
path,
|
||||
user!.id,
|
||||
// `is_dir` is a real boolean on postgres.
|
||||
env.server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return path;
|
||||
};
|
||||
|
||||
const stat = (path: string, token: string) =>
|
||||
fetch(new URL('/stat', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
|
||||
const readdir = (path: string, token: string) =>
|
||||
fetch(new URL('/readdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// The cap has its own suite; these tests need many teams.
|
||||
env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
max_teams_per_user: 100,
|
||||
} as IConfig);
|
||||
|
||||
// A provisioned account cannot authenticate until it activates.
|
||||
const res = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
body: JSON.stringify({ name: 'Isolation', handle: `iso-${Date.now()}` }),
|
||||
});
|
||||
teamUid = ((await res.json()) as { uid: string }).uid;
|
||||
|
||||
// A real provisioned seat, activated so it can authenticate.
|
||||
memberUsername = `iso_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(
|
||||
teamUid,
|
||||
(await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!.id,
|
||||
{ username: memberUsername, email: `${memberUsername}@test.local` },
|
||||
);
|
||||
const member = await env.server.stores.user.getByUsername(memberUsername);
|
||||
await env.server.stores.user.update(member!.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
memberToken = (
|
||||
await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getByUsername(memberUsername))!,
|
||||
)
|
||||
).token;
|
||||
|
||||
memberFile = await makeFile(memberUsername, 'member');
|
||||
ownerFile = await makeFile(env.users.user.username, 'owner');
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
// -- refused ------------------------------------------------------
|
||||
|
||||
it('refuses the team owner a members file', async () => {
|
||||
const res = await stat(memberFile, env.users.user.token);
|
||||
// Administering, paying for, and reading an account are three things.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('refuses the team owner a members home directory', async () => {
|
||||
const res = await readdir(`/${memberUsername}`, env.users.user.token);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('refuses a full-access token no less than a session', async () => {
|
||||
// `#scanAccessToken` re-scans the issuer as a plain user actor.
|
||||
const res = await stat(memberFile, env.users.user.apiToken);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('grants no team-wide reach through the team routes', async () => {
|
||||
// There is no route to a member's files or KV, and none should appear.
|
||||
for (const path of [
|
||||
`/teams/${teamUid}/files`,
|
||||
`/teams/${teamUid}/members/${memberUsername}/files`,
|
||||
`/teams/${teamUid}/kv`,
|
||||
]) {
|
||||
const res = await fetch(new URL(path, env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${env.users.user.token}` },
|
||||
});
|
||||
expect(res.status, path).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
// -- allowed ------------------------------------------------------
|
||||
|
||||
it('lets the team owner read its own files', async () => {
|
||||
const res = await stat(ownerFile, env.users.user.token);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a member read their own files', async () => {
|
||||
const res = await stat(memberFile, memberToken);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('lets an explicit grant through, as an ordinary share', async () => {
|
||||
const member = await env.server.stores.user.getByUsername(
|
||||
memberUsername,
|
||||
);
|
||||
const [entry] = (await env.server.clients.db.read(
|
||||
'SELECT `uuid` FROM `fsentries` WHERE `path` = ?',
|
||||
[memberFile],
|
||||
)) as { uuid: string }[];
|
||||
|
||||
// Only the member's own grant changes, never team authority.
|
||||
const before = await stat(memberFile, env.users.user.token);
|
||||
expect(before.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
await env.server.services.permission.grantUserUserPermission(
|
||||
makeActor({ user: member!, app: null, accessToken: null } as never),
|
||||
env.users.user.username,
|
||||
`fs:${entry.uuid}:read`,
|
||||
);
|
||||
|
||||
const after = await stat(memberFile, env.users.user.token);
|
||||
expect(after.status).toBe(200);
|
||||
});
|
||||
|
||||
it('disabling a member neither grants nor transfers their files', async () => {
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const member = await env.server.stores.user.getByUsername(
|
||||
memberUsername,
|
||||
);
|
||||
// A fresh file: the earlier test granted the owner read on `memberFile`.
|
||||
const untouched = await makeFile(memberUsername, 'untouched');
|
||||
|
||||
await env.server.services.team.disableMember(
|
||||
teamUid,
|
||||
owner!.id,
|
||||
member!.id,
|
||||
);
|
||||
|
||||
// The member loses access to their own files...
|
||||
const asMember = await stat(untouched, memberToken);
|
||||
expect(asMember.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// ...and the team still does not gain it.
|
||||
const asOwner = await stat(untouched, env.users.user.token);
|
||||
expect(asOwner.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
TEST_OTHER_USER_CREDENTIALS,
|
||||
TEST_USER_CREDENTIALS,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
/** PROPFIND has no paging, so a folder must list whole however large. */
|
||||
describe('PROPFIND on a folder larger than one query', () => {
|
||||
let env: PuterTestEnv;
|
||||
const total = 10_050;
|
||||
let dir: string;
|
||||
let dirUuid: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
const { username } = env.users.user;
|
||||
dir = `/${username}/Documents/big-${randomUUID().slice(0, 8)}`;
|
||||
const owner = (await env.server.stores.user.getByUsername(username))!;
|
||||
const parent = (await env.server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
dirUuid = randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const insert = (rows: unknown[][]) =>
|
||||
env.server.clients.db.write(
|
||||
`INSERT INTO fsentries (uuid, user_id, parent_id, parent_uid, name, path, is_dir, modified, created, accessed, size) VALUES ${rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`,
|
||||
rows.flat(),
|
||||
);
|
||||
await insert([
|
||||
[
|
||||
dirUuid,
|
||||
owner.id,
|
||||
parent.id,
|
||||
parent.uuid,
|
||||
dir.split('/').at(-1),
|
||||
dir,
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
0,
|
||||
],
|
||||
]);
|
||||
const folder = (await env.server.stores.fsEntry.getEntryByPath(dir))!;
|
||||
for (let start = 0; start < total; start += 500) {
|
||||
const rows: unknown[][] = [];
|
||||
for (let i = start; i < Math.min(total, start + 500); i++) {
|
||||
const name = `f${String(i).padStart(5, '0')}.txt`;
|
||||
rows.push([
|
||||
randomUUID(),
|
||||
owner.id,
|
||||
folder.id,
|
||||
folder.uuid,
|
||||
name,
|
||||
`${dir}/${name}`,
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
0,
|
||||
]);
|
||||
}
|
||||
await insert(rows);
|
||||
}
|
||||
|
||||
const actor = { user: owner };
|
||||
await runWithContext({ actor: actor as never }, () =>
|
||||
env.server.services.share.share(actor as never, {
|
||||
uid: dirUuid,
|
||||
recipient: { username: env.users.other.username },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const propfind = async (
|
||||
path: string,
|
||||
credentials: { username: string; password: string },
|
||||
) => {
|
||||
const port = new URL(env.apiOrigin).port;
|
||||
const auth = Buffer.from(
|
||||
`${credentials.username}:${credentials.password}`,
|
||||
).toString('base64');
|
||||
const response = await fetch(
|
||||
`http://dav.puter.localhost:${port}${encodeURI(path)}`,
|
||||
{
|
||||
method: 'PROPFIND',
|
||||
headers: { Authorization: `Basic ${auth}`, Depth: '1' },
|
||||
},
|
||||
);
|
||||
return { status: response.status, body: await response.text() };
|
||||
};
|
||||
|
||||
const hrefsOf = (xml: string) =>
|
||||
[...xml.matchAll(/<D:href>([^<]*)<\/D:href>/g)].map((m) => m[1]!);
|
||||
|
||||
it('lists every child', async () => {
|
||||
const { status, body } = await propfind(dir, TEST_USER_CREDENTIALS);
|
||||
expect(status).toBe(207);
|
||||
expect(body.endsWith('</D:multistatus>')).toBe(true);
|
||||
const hrefs = hrefsOf(body);
|
||||
expect(hrefs).toHaveLength(1 + total);
|
||||
expect(new Set(hrefs).size).toBe(1 + total);
|
||||
expect(body).toContain(`${dir}/f10049.txt`);
|
||||
}, 60_000);
|
||||
|
||||
it("masks every page of a share recipient's listing", async () => {
|
||||
const masked = `/${env.users.user.username}/${dirUuid}/${dir.split('/').at(-1)}`;
|
||||
const { status, body } = await propfind(
|
||||
masked,
|
||||
TEST_OTHER_USER_CREDENTIALS,
|
||||
);
|
||||
expect(status).toBe(207);
|
||||
const hrefs = hrefsOf(body);
|
||||
expect(hrefs).toHaveLength(1 + total);
|
||||
expect(hrefs.every((href) => href.startsWith(masked))).toBe(true);
|
||||
expect(body).not.toContain('/Documents/');
|
||||
}, 60_000);
|
||||
});
|
||||
Loaded 100 of 886 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user