Compare commits

...

15 Commits

Author SHA1 Message Date
renovate[bot] 3bf06722b7 chore: bump up android.gradle.plugin to v9 2026-05-22 17:52:50 +00:00
steffenrapp 925c95ce88 feat(i18n): update German translation (#15011)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Localization**
  * German language completeness raised to 100%.
* Added German translations for Markdown export/copy labels and success
text, import formats (including Bear backup and Word .docx), editor
settings (auto-date-title formats, add-icon option), AI BYOK
workspace/provider-key UI and notifications, and a recording/importing
UI prompt.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15011?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-22 01:33:38 +08:00
DarkSky 3098b3b14b feat(server): bump models (#15013)
#### PR Dependency Tree


* **PR #15013** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Expanded AI capabilities with the addition of Gemini 3.5 Flash model,
providing enhanced options for AI-powered features.

* **Updates**
* Updated Claude Sonnet to the latest version for improved performance.
  * Refreshed pro models configuration with optimized selections.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15013?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-21 15:29:00 +08:00
DarkSky dd1cd77ca0 chore(server): improve migration compatibility (#15014)
#### PR Dependency Tree


* **PR #15014** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Remove orphaned legacy subscription and entitlement records during
backfill.
* Repair workspaces missing active owners by promoting eligible members
and cleaning up empty workspaces.
* Skip cloud subscription backfill when target user/workspace no longer
exists to avoid dangling data.

* **Tests**
  * Added tests verifying legacy data cleanup during backfill.
* Added tests verifying workspace ownership repair and migration
behavior.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15014?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-21 15:28:51 +08:00
Waqar Bin zafar d20dbfd6a2 feat(editor): add page emoji display toggle #14987 (#14999)
This PR adds a display toggle for Page Emoji, so users can choose
whether the add emoji option is shown in the page header when no emoji
is set.

What changed
read editor setting for display add icon option
hide emoji placeholder entry when the setting is disabled
keep existing behavior for readonly mode and for pages that already have
an emoji
Why
This implements the feature request to control Page Emoji visibility and
improves header cleanliness for users who prefer a minimal UI.

Issue
Closes #14093
<img width="1277" height="726" alt="Screenshot 2026-05-19 at 3 44 14 PM"
src="https://github.com/user-attachments/assets/caa29272-35c0-410d-bd54-2e038e4e0db2"
/>
<img width="1511" height="779" alt="Screenshot 2026-05-19 at 3 44 35 PM"
src="https://github.com/user-attachments/assets/3504136a-d34c-45cc-992b-0056b018ff92"
/>

Testing
verified in editable mode:
setting ON: add emoji placeholder is visible when page has no emoji
setting OFF: add emoji placeholder is hidden when page has no emoji
verified in readonly mode:
no emoji: nothing shown
with emoji: existing emoji is shown
verified no regression for selecting/changing/removing emoji
Screenshots
I will attach screenshots in this section.

Quick rule checks before submit

Base branch is canary.
PR title follows conventional format: type(scope): subject.
Scope editor is valid for this repo.
Include Closes #14093 in the body.
Add your screenshots before creating or right after opening the PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an editor setting to toggle whether the "add icon" option is
shown when creating new documents (default: enabled).
* **User Experience**
* When disabled, the add-icon trigger is hidden for documents that use a
placeholder icon; readonly display remains unchanged.
* **Tests**
  * Updated tests to cover the new setting and toggle behavior.
* **Localization**
* Added translations and updated i18n typings and completeness metrics.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14999?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 22:20:12 +08:00
renovate[bot] 41145961f9 chore: bump up RevenueCat/purchases-ios-spm version to from: "5.73.0" (#15008)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[RevenueCat/purchases-ios-spm](https://redirect.github.com/RevenueCat/purchases-ios-spm)
| minor | `from: "5.66.0"` → `from: "5.73.0"` |

---

### Release Notes

<details>
<summary>RevenueCat/purchases-ios-spm
(RevenueCat/purchases-ios-spm)</summary>

###
[`v5.73.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5730)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.72.0...5.73.0)

#### 5.73.0

###
[`v5.72.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5720)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.71.0...5.72.0)

#### 5.72.0

###
[`v5.71.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5710)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.70.0...5.71.0)

#### 5.71.0

###
[`v5.70.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5700)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.69.0...5.70.0)

#### 5.70.0

###
[`v5.69.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5690)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.68.0...5.69.0)

#### 5.69.0

###
[`v5.68.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5680)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.2...5.68.0)

#### 5.68.0

###
[`v5.67.2`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5672)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.1...5.67.2)

#### 5.67.2

###
[`v5.67.1`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5671)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.0...5.67.1)

#### 5.67.1

###
[`v5.67.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5670)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.66.0...5.67.0)

#### 5.67.0

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 18:41:32 +08:00
DarkSky 1f2119e273 fix: migration timeout 2026-05-20 18:39:08 +08:00
renovate[bot] 6e97aff7ba chore: bump up oxlint-tsgolint version to ^0.23.0 (#15007)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [oxlint-tsgolint](https://redirect.github.com/oxc-project/tsgolint) |
[`^0.19.0` →
`^0.23.0`](https://renovatebot.com/diffs/npm/oxlint-tsgolint/0.19.0/0.23.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/oxlint-tsgolint/0.23.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/oxlint-tsgolint/0.19.0/0.23.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/tsgolint (oxlint-tsgolint)</summary>

###
[`v0.23.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.23.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.22.1...v0.23.0)

#### What's Changed

- chore(deps): update crate-ci/typos action to v1.45.2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;915](https://redirect.github.com/oxc-project/tsgolint/pull/915)
- feat: add skill for upgrading typescript-go by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;918](https://redirect.github.com/oxc-project/tsgolint/pull/918)
- chore(deps): update pnpm to v10.33.2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;921](https://redirect.github.com/oxc-project/tsgolint/pull/921)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;922](https://redirect.github.com/oxc-project/tsgolint/pull/922)
- fix: attach tsconfig path to diagnostics by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;923](https://redirect.github.com/oxc-project/tsgolint/pull/923)
- fix(prefer-nullish-coalescing): parenthesize mixed logical fixes by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;924](https://redirect.github.com/oxc-project/tsgolint/pull/924)
- tests(return-await): cover non-async arrow functions by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;926](https://redirect.github.com/oxc-project/tsgolint/pull/926)
- chore(deps): update github.com/go-json-experiment/json digest to
[`b6187a3`](https://redirect.github.com/oxc-project/tsgolint/commit/b6187a3)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;927](https://redirect.github.com/oxc-project/tsgolint/pull/927)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;928](https://redirect.github.com/oxc-project/tsgolint/pull/928)
- chore(deps): update crate-ci/typos action to v1.46.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;929](https://redirect.github.com/oxc-project/tsgolint/pull/929)
- chore(deps): update module github.com/dlclark/regexp2 to v2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;930](https://redirect.github.com/oxc-project/tsgolint/pull/930)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;931](https://redirect.github.com/oxc-project/tsgolint/pull/931)
- chore(deps): update typescript-go digest to
[`48e2953`](https://redirect.github.com/oxc-project/tsgolint/commit/48e2953)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;933](https://redirect.github.com/oxc-project/tsgolint/pull/933)
- chore(deps): update typescript-go digest to
[`5eb880f`](https://redirect.github.com/oxc-project/tsgolint/commit/5eb880f)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;936](https://redirect.github.com/oxc-project/tsgolint/pull/936)
- fix(no-misused-promises): handle empty JSX attributes by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;938](https://redirect.github.com/oxc-project/tsgolint/pull/938)
- fix(no-unsafe-enum-comparison): flag string literal unions by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;937](https://redirect.github.com/oxc-project/tsgolint/pull/937)
- chore(deps): update typescript-go digest to
[`e1f8f97`](https://redirect.github.com/oxc-project/tsgolint/commit/e1f8f97)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;939](https://redirect.github.com/oxc-project/tsgolint/pull/939)
- chore(deps): update typescript-go digest to
[`092b34f`](https://redirect.github.com/oxc-project/tsgolint/commit/092b34f)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;940](https://redirect.github.com/oxc-project/tsgolint/pull/940)
- chore: configure typescript-go renovate schedule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;941](https://redirect.github.com/oxc-project/tsgolint/pull/941)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;945](https://redirect.github.com/oxc-project/tsgolint/pull/945)
- chore(deps): update dependency dprint-typescript to v0.96.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;947](https://redirect.github.com/oxc-project/tsgolint/pull/947)
- chore(deps): update gomod by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;946](https://redirect.github.com/oxc-project/tsgolint/pull/946)
- chore(deps): update crate-ci/typos action to v1.46.1 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;948](https://redirect.github.com/oxc-project/tsgolint/pull/948)
- fix(prefer-nullish-coalescing): emit suggestion over fix by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;951](https://redirect.github.com/oxc-project/tsgolint/pull/951)
- chore: update packageManager to pnpm 11.0.4 by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;953](https://redirect.github.com/oxc-project/tsgolint/pull/953)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;955](https://redirect.github.com/oxc-project/tsgolint/pull/955)
- fix(no-nullable-type-assertion-style): use suggestion instead of fix
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;956](https://redirect.github.com/oxc-project/tsgolint/pull/956)
- docs: Update Go version requirement to 1.26 in CONTRIBUTING.md. by
[@&#8203;connorshea](https://redirect.github.com/connorshea) in
[#&#8203;957](https://redirect.github.com/oxc-project/tsgolint/pull/957)
- fix: allow safe promise intersection members by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;959](https://redirect.github.com/oxc-project/tsgolint/pull/959)
- ci: switch security workflow to ubuntu-latest by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;962](https://redirect.github.com/oxc-project/tsgolint/pull/962)
- chore(deps): update dependency vitest to v4.1.6 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;963](https://redirect.github.com/oxc-project/tsgolint/pull/963)
- chore(deps): update module github.com/dlclark/regexp2/v2 to v2.0.3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;964](https://redirect.github.com/oxc-project/tsgolint/pull/964)
- chore(deps): update dependency dprint-markdown to v0.22.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;965](https://redirect.github.com/oxc-project/tsgolint/pull/965)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;966](https://redirect.github.com/oxc-project/tsgolint/pull/966)
- perf(no-unnecessary-type-parameters): stop counting settled candidates
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;967](https://redirect.github.com/oxc-project/tsgolint/pull/967)
- chore: add `dprint` to pnpm `allowBuilds` by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;968](https://redirect.github.com/oxc-project/tsgolint/pull/968)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.22.1...v0.23.0>

###
[`v0.22.1`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.22.1)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.22.0...v0.22.1)

#### What's Changed

- fix: clarify `AGENTS.md` submodule guidance by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;909](https://redirect.github.com/oxc-project/tsgolint/pull/909)
- feat(no-unsafe-enum-comparison): implement suggestion by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;910](https://redirect.github.com/oxc-project/tsgolint/pull/910)
- feat(no-unnecessary-template-expression): implement fix by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;911](https://redirect.github.com/oxc-project/tsgolint/pull/911)
- chore(deps): update dependency vitest to v4.1.5 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;912](https://redirect.github.com/oxc-project/tsgolint/pull/912)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;913](https://redirect.github.com/oxc-project/tsgolint/pull/913)
- fix(prefer-optional-chain): avoid access comparison false positive by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;914](https://redirect.github.com/oxc-project/tsgolint/pull/914)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.22.0...v0.22.1>

###
[`v0.22.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.22.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.0)

#### What's Changed

- chore: convert renovate config to json by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;893](https://redirect.github.com/oxc-project/tsgolint/pull/893)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;895](https://redirect.github.com/oxc-project/tsgolint/pull/895)
- ci: replace OXC\_BOT\_PAT with GitHub App tokens by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;894](https://redirect.github.com/oxc-project/tsgolint/pull/894)
- ci: add security analysis workflow by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;898](https://redirect.github.com/oxc-project/tsgolint/pull/898)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;899](https://redirect.github.com/oxc-project/tsgolint/pull/899)
- chore(deps): update module github.com/dlclark/regexp2 to v1.12.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;900](https://redirect.github.com/oxc-project/tsgolint/pull/900)
- chore(deps): update dependency typescript to v6.0.3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;901](https://redirect.github.com/oxc-project/tsgolint/pull/901)
- ci: make security analysis required-check friendly by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;902](https://redirect.github.com/oxc-project/tsgolint/pull/902)
- feat(require-await): implement suggestions by
[@&#8203;younggglcy](https://redirect.github.com/younggglcy) in
[#&#8203;896](https://redirect.github.com/oxc-project/tsgolint/pull/896)
- fix: add warning for unsupported tsgolint CLI entrypoint by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;903](https://redirect.github.com/oxc-project/tsgolint/pull/903)
- fix: resolve ancestor tsconfig for excluded nearest config by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;904](https://redirect.github.com/oxc-project/tsgolint/pull/904)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;905](https://redirect.github.com/oxc-project/tsgolint/pull/905)
- fix: handle UTF-16 diagnostics by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;906](https://redirect.github.com/oxc-project/tsgolint/pull/906)
- fix(no-useless-default-assignment): make default assignment removal a
suggestion by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;907](https://redirect.github.com/oxc-project/tsgolint/pull/907)
- fix(no-unnecessary-type-arguments): preserve shadowed type arguments
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;908](https://redirect.github.com/oxc-project/tsgolint/pull/908)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.0>

###
[`v0.21.1`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.21.1)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.21.0...v0.21.1)

##### What's Changed

- fix(no-unnecessary-condition): handle null overlap in narrowed generic
intersections by [@&#8203;camc314](https://redirect.github.com/camc314)
in
[#&#8203;891](https://redirect.github.com/oxc-project/tsgolint/pull/891)
- revert(no-unnecessary-type-arguments): drop inference reporting by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;892](https://redirect.github.com/oxc-project/tsgolint/pull/892)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.21.0...v0.21.1>

###
[`v0.21.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.21.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.0)

##### What's Changed

- chore: migrate gen-json-schemas to TS by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;874](https://redirect.github.com/oxc-project/tsgolint/pull/874)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;879](https://redirect.github.com/oxc-project/tsgolint/pull/879)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;883](https://redirect.github.com/oxc-project/tsgolint/pull/883)
- chore(deps): update gomod by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;884](https://redirect.github.com/oxc-project/tsgolint/pull/884)
- chore(deps): update npm packages by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;885](https://redirect.github.com/oxc-project/tsgolint/pull/885)
- feat: improve `consistent-type-exports` diagnostics quality by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;880](https://redirect.github.com/oxc-project/tsgolint/pull/880)
- chore(deps): update softprops/action-gh-release action to v3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;886](https://redirect.github.com/oxc-project/tsgolint/pull/886)
- feat: enrich the `no-array-delete` diagnostic by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;881](https://redirect.github.com/oxc-project/tsgolint/pull/881)
- feat: enrich `no-duplicate-type-constituents` diagnostic by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;882](https://redirect.github.com/oxc-project/tsgolint/pull/882)
- fix(no-meaningless-void-operator): align with typescript-eslint union
handling by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;887](https://redirect.github.com/oxc-project/tsgolint/pull/887)
- chore(deps): update crate-ci/typos action to v1.45.1 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;888](https://redirect.github.com/oxc-project/tsgolint/pull/888)
- fix(no-deprecated): avoid false positive on array destructuring
bindings by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;890](https://redirect.github.com/oxc-project/tsgolint/pull/890)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.0>

### [`v0.20.0`]()

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.19.0...v0.20.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 13:17:46 +08:00
renovate[bot] 276b0db625 chore: bump up eslint-plugin-oxlint version to v1.66.0 (#15006)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[eslint-plugin-oxlint](https://redirect.github.com/oxc-project/eslint-plugin-oxlint)
| [`1.64.0` →
`1.66.0`](https://renovatebot.com/diffs/npm/eslint-plugin-oxlint/1.64.0/1.66.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-oxlint/1.66.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-oxlint/1.64.0/1.66.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/eslint-plugin-oxlint
(eslint-plugin-oxlint)</summary>

###
[`v1.66.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.66.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.65.0...v1.66.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.65.0...v1.66.0)

###
[`v1.65.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.65.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.64.0...v1.65.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.64.0...v1.65.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 10:26:38 +08:00
renovate[bot] bac346f304 chore: bump up nestjs to v13.4.1 (#15002) 2026-05-20 05:51:24 +08:00
DarkSky 9f33d37add feat(core): integrate realtime features (#15003) 2026-05-20 05:48:03 +08:00
renovate[bot] 3e42bbf4fa chore: bump up apple/swift-collections version to from: "1.5.1" (#15001)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[apple/swift-collections](https://redirect.github.com/apple/swift-collections)
| patch | `from: "1.5.0"` → `from: "1.5.1"` |

---

### Release Notes

<details>
<summary>apple/swift-collections (apple/swift-collections)</summary>

###
[`v1.5.1`](https://redirect.github.com/apple/swift-collections/releases/tag/1.5.1):
Swift Collections 1.5.1

[Compare
Source](https://redirect.github.com/apple/swift-collections/compare/1.5.0...1.5.1)

This is a patch release resolving three issues uncovered since 1.5.0 was
tagged, including a source breaking regression introduced in 1.4.0,
affecting clients importing the `Collections` module.

#### What's Changed

- Import error from `HashTreeCollections`, reported by
[@&#8203;vanvoorden](https://redirect.github.com/vanvoorden) in
[#&#8203;653](https://redirect.github.com/apple/swift-collections/issues/653)
- Resolve source break in the Collections module by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;654](https://redirect.github.com/apple/swift-collections/pull/654)
- Linker error around RigidArray when using in Embedded Swift for
WebAssembly, reported by
[@&#8203;sliemeobn](https://redirect.github.com/sliemeobn) in
[#&#8203;648](https://redirect.github.com/apple/swift-collections/issues/648)
- \[BasicContainers] Don’t define LLDB formatter symbol on Wasm by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;650](https://redirect.github.com/apple/swift-collections/pull/650)
- Guard `UniqueBox.borrow` correctly by
[@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) in
[#&#8203;649](https://redirect.github.com/apple/swift-collections/pull/649)

**Full Changelog**:
<https://github.com/apple/swift-collections/compare/1.5.0...1.5.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 23:58:03 +08:00
renovate[bot] b5e5f0708a chore: bump up Lakr233/MarkdownView version to from: "3.9.1" (#14861)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[Lakr233/MarkdownView](https://redirect.github.com/Lakr233/MarkdownView)
| minor | `from: "3.8.2"` → `from: "3.9.1"` |

---

### Release Notes

<details>
<summary>Lakr233/MarkdownView (Lakr233/MarkdownView)</summary>

###
[`v3.9.1`](https://redirect.github.com/Lakr233/MarkdownView/compare/3.9.0...3.9.1)

[Compare
Source](https://redirect.github.com/Lakr233/MarkdownView/compare/3.9.0...3.9.1)

###
[`v3.9.0`](https://redirect.github.com/Lakr233/MarkdownView/compare/3.8.2...3.9.0)

[Compare
Source](https://redirect.github.com/Lakr233/MarkdownView/compare/3.8.2...3.9.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjMuOCIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 22:52:20 +08:00
Abdul Rehman f96bf3dd24 feat(i18n): expand Urdu translation (#14995)
Closes #14994
🇵🇰 Urdu Translation for Pakistani Users
## Summary

`ur.json` previously had only 31 of 2404 keys translated (~1%), leaving
most of the AFFiNE UI in English for Urdu-speaking users. This PR fills
in the remaining ~2400 keys so Pakistani / Urdu users get a fully
localized experience.

- `packages/frontend/i18n/src/resources/ur.json` — expanded from 31 →
2404 keys
- `packages/frontend/i18n/src/i18n-completenesses.json` — `ur: 2` → `ur:
100`

Existing hand-translated keys were preserved.

## Screenshots

<img width="1600" height="716" alt="image"
src="https://github.com/user-attachments/assets/1e3395b9-7cb0-44ba-a29f-a484419eb9fd"
/>
--------

<img width="1600" height="716" alt="image"
src="https://github.com/user-attachments/assets/f03cb1ac-dde8-4425-a898-c56acebe45b6"
/>



## Test plan

- [x] Switch app language to Urdu (اردو) in Settings → Appearance →
Language
- [x] Sidebar, top bar, calendar, doc list, settings panels all render
in Urdu
- [x] RTL layout flows correctly
- [x] Prettier + lint clean

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Localization**
* Urdu language support is now fully available across the application,
including translated UI text and locale-specific content.
* Users can select Urdu as their preferred language and experience
consistent translations and messaging throughout the product.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14995?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-19 22:49:22 +08:00
DarkSky c53457691d feat(server): entitlement based model (#14996)
#### PR Dependency Tree


* **PR #14996** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Admin mutations to grant/revoke commercial entitlements.
  * New Doc comment-update permission.
  * Realtime user/workspace quota-state endpoints and live-update rooms.

* **Bug Fixes**
  * More accurate readable-doc filtering and permission evaluation.

* **Refactor**
* Workspace feature management moved to entitlement-based model;
permission and quota pipelines redesigned.
  * Admin workspace UI now edits flags only (feature toggles removed).

* **Tests**
* Extensive new and updated tests for permissions, entitlements, quota,
projection, and backfills.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-19 22:48:05 +08:00
253 changed files with 19338 additions and 5168 deletions
+16
View File
@@ -300,6 +300,22 @@
}
}
},
"permission": {
"type": "object",
"description": "Configuration for permission module",
"properties": {
"readModel": {
"type": "string",
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
"default": "projection"
},
"fallbackLegacyLoader": {
"type": "boolean",
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
"default": false
}
}
},
"storages": {
"type": "object",
"description": "Configuration for storages module",
Generated
+2 -2
View File
@@ -3748,9 +3748,9 @@ dependencies = [
[[package]]
name = "llm_adapter"
version = "0.2.6"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca30267ba36e247d1ff7a916a03db2ceb1de7f0bfcab7250cde006cdda68c19"
checksum = "332397a6ccde5ac47fc32b29a2eed447135eb4ff6fd05ffb88dfe937ea9c8211"
dependencies = [
"base64",
"jsonschema",
+3 -3
View File
@@ -16,10 +16,10 @@ resolver = "3"
edition = "2024"
[workspace.dependencies]
aes-gcm = "0.10"
affine_common = { path = "./packages/common/native" }
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
ahash = "0.8"
aes-gcm = "0.10"
anyhow = "1"
arbitrary = { version = "1.3", features = ["derive"] }
assert-json-diff = "2.0"
@@ -40,6 +40,7 @@ resolver = "3"
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
hex = "0.4"
homedir = "0.3"
image = { version = "0.25.9", default-features = false, features = [
"bmp",
@@ -81,6 +82,7 @@ resolver = "3"
ogg = "0.9"
once_cell = "1"
ordered-float = "5"
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
parking_lot = "0.12"
path-ext = "0.1.2"
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
@@ -99,8 +101,6 @@ resolver = "3"
screencapturekit = "0.3"
serde = "1"
serde_json = "1"
hex = "0.4"
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
sha2 = "0.10"
sha3 = "0.10"
smol_str = "0.3"
+2 -2
View File
@@ -74,7 +74,7 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-lit": "^2.2.1",
"eslint-plugin-oxlint": "1.64.0",
"eslint-plugin-oxlint": "1.66.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
@@ -84,7 +84,7 @@
"lint-staged": "^16.0.0",
"msw": "^2.13.2",
"oxlint": "1.58.0",
"oxlint-tsgolint": "^0.19.0",
"oxlint-tsgolint": "^0.23.0",
"prettier": "^3.7.4",
"semver": "^7.7.3",
"typescript": "^5.9.3",
@@ -706,8 +706,8 @@
"optionalModels": [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3.1-pro-preview",
"claude-sonnet-4-5@20250929"
"gemini-3.5-flash",
"claude-sonnet-4-6"
],
"config": {
"tools": [
@@ -722,11 +722,7 @@
"codeArtifact",
"blobRead"
],
"proModels": [
"gemini-2.5-pro",
"gemini-3.1-pro-preview",
"claude-sonnet-4-5@20250929"
]
"proModels": ["gemini-2.5-pro", "gemini-3.5-flash", "claude-sonnet-4-6"]
},
"builtins": [
"date",
@@ -61,12 +61,12 @@ mod tests {
fn should_resolve_backend_scoped_alias() {
let response = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
backend_kind: Some("anthropic_vertex".to_string()),
model_id: "claude-sonnet-4.5".to_string(),
model_id: "claude-sonnet-4.6".to_string(),
})
.unwrap();
assert_eq!(response.matched_by.as_deref(), Some("canonical"));
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-5@20250929");
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-6");
}
#[test]
@@ -84,6 +84,10 @@ fn restricted_decision(input: &PermissionEvaluationInputV1, action: &str) -> Vec
return Vec::new();
}
if input.legacy_compat_mode && input.subject.allow_local && input.workspace.local {
return Vec::new();
}
let mut restrictions = Vec::new();
if !input.runtime.known {
restrictions.push(PermissionDecisionRestrictionV1 {
@@ -347,9 +347,12 @@ mod tests {
local: true,
..Default::default()
};
input.runtime.known = false;
input.runtime.stale = true;
input.workspace_actions = vec!["Workspace.Delete".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.workspace.decisions, "Workspace.Delete").allowed);
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
}
#[test]
@@ -5,6 +5,7 @@ import ava, { type ExecutionContext, type TestFn } from 'ava';
import Sinon from 'sinon';
import { Cache, CryptoHelper } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { Models, WorkspaceRole } from '../../models';
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
import { ByokService } from '../../plugins/copilot/byok';
@@ -14,6 +15,11 @@ import {
ByokKeyTestStatus,
ByokProvider,
} from '../../plugins/copilot/byok/types';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../plugins/payment/types';
import { createTestingModule, type TestingModule } from '../utils';
interface Context {
@@ -24,11 +30,18 @@ interface Context {
byok: ByokService;
crypto: CryptoHelper;
cache: Cache;
entitlement: EntitlementService;
}
const test = ava as TestFn<Context>;
const test = ava.serial as TestFn<Context>;
const originalNamespace = globalThis.env.NAMESPACE;
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
test.before(async t => {
Object.assign(globalThis.env, {
NAMESPACE: 'dev',
DEPLOYMENT_TYPE: 'affine',
});
const module = await createTestingModule();
t.context.module = module;
t.context.models = module.get(Models);
@@ -37,6 +50,7 @@ test.before(async t => {
t.context.byok = module.get(ByokService);
t.context.crypto = module.get(CryptoHelper);
t.context.cache = module.get(Cache);
t.context.entitlement = module.get(EntitlementService);
});
test.beforeEach(async t => {
@@ -45,6 +59,10 @@ test.beforeEach(async t => {
test.after.always(async t => {
await t.context.module.close();
Object.assign(globalThis.env, {
NAMESPACE: originalNamespace,
DEPLOYMENT_TYPE: originalDeploymentType,
});
});
async function createUserWorkspace(t: ExecutionContext<Context>) {
@@ -59,6 +77,73 @@ function workspaceHash(workspaceId: string) {
return createHash('sha256').update(workspaceId).digest('hex').slice(0, 12);
}
async function grantUserPlan(
t: ExecutionContext<Context>,
userId: string,
feature: ByokUserPlanFeature = 'pro_plan_v1'
) {
if (feature === 'unlimited_copilot') {
await t.context.entitlement.upsertFromCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
return;
}
await t.context.entitlement.upsertFromCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.Pro,
recurring:
feature === 'lifetime_pro_plan_v1'
? SubscriptionRecurring.Lifetime
: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
}
async function revokeUserPlan(
t: ExecutionContext<Context>,
userId: string,
feature: ByokUserPlanFeature = 'pro_plan_v1'
) {
if (feature === 'unlimited_copilot') {
await t.context.entitlement.revokeCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.AI,
});
return;
}
await t.context.entitlement.revokeCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.Pro,
});
}
async function grantTeamPlan(
t: ExecutionContext<Context>,
workspaceId: string
) {
await t.context.entitlement.upsertFromCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
});
}
async function revokeTeamPlan(
t: ExecutionContext<Context>,
workspaceId: string
) {
await t.context.entitlement.revokeCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
});
}
type ByokMatrixCase = {
name: string;
role: WorkspaceRole;
@@ -110,25 +195,13 @@ async function createByokMatrixWorkspace(
);
}
if (input.team) {
await t.context.models.workspaceFeature.add(
workspace.id,
'team_plan_v1',
'test'
);
await grantTeamPlan(t, workspace.id);
}
if (input.ownerPlan) {
await t.context.models.userFeature.add(
owner.id,
input.ownerPlanFeature ?? 'pro_plan_v1',
'test'
);
await grantUserPlan(t, owner.id, input.ownerPlanFeature);
}
if (input.actorPlan && actor.id !== owner.id) {
await t.context.models.userFeature.add(
actor.id,
input.actorPlanFeature ?? 'pro_plan_v1',
'test'
);
await grantUserPlan(t, actor.id, input.actorPlanFeature);
}
return { owner, actor, workspace };
@@ -252,7 +325,7 @@ for (const matrixCase of byokManagementMatrix) {
test('byok service persists encrypted server keys and never returns plaintext', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const primary = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -325,7 +398,7 @@ test('byok service persists encrypted server keys and never returns plaintext',
test('byok service preserves server key fields during partial updates', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -381,7 +454,7 @@ test('byok service preserves server key fields during partial updates', async t
test('local leases are short lived and do not persist keys to server configs', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const before = Date.now();
const lease = await t.context.byok.createLocalLease({
@@ -486,7 +559,7 @@ test('local leases persist normalized custom endpoints', async t => {
).get(() => true);
t.teardown(() => customEndpointSupported.restore());
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const lease = await t.context.byok.createLocalLease({
workspaceId: workspace.id,
@@ -659,13 +732,10 @@ for (const matrixCase of byokProfileAvailabilityMatrix) {
}
if (matrixCase.revokeOwnerPlan) {
await t.context.models.userFeature.remove(owner.id, 'pro_plan_v1');
await revokeUserPlan(t, owner.id);
}
if (matrixCase.revokeTeam) {
await t.context.models.workspaceFeature.remove(
workspace.id,
'team_plan_v1'
);
await revokeTeamPlan(t, workspace.id);
}
if (matrixCase.demoteActor) {
await t.context.models.workspaceUser.set(
@@ -695,7 +765,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const profiles = await t.context.byok.getProfiles({
workspaceId: randomUUID(),
@@ -707,7 +777,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
test('test key failure disables a saved key and success restores it', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -778,7 +848,7 @@ test('test key failure disables a saved key and success restores it', async t =>
test('local key test does not mutate saved server config', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -817,7 +887,7 @@ test('local key test does not mutate saved server config', async t => {
test('Gemini key test sends key in header and returns safe failure message', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
new Response(
@@ -852,7 +922,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
test('FAL key test uses read-only platform API probe endpoint', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
new Response('{}', { status: 200 })
@@ -877,7 +947,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
test('provider test failures do not return raw provider response body', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const cases = [
{
body: 'authorization: Bearer token=a+b%2F==',
@@ -925,7 +995,7 @@ test('provider test failures do not return raw provider response body', async t
test('dispatch failure disables server BYOK key by provider id', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -956,7 +1026,7 @@ test('dispatch failure disables server BYOK key by provider id', async t => {
test('dispatch accounting ignores provider ids from another workspace hash', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const otherWorkspace = await t.context.models.workspace.create(user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -996,7 +1066,7 @@ test('dispatch accounting ignores provider ids from another workspace hash', asy
test('effective profiles use local lease before server keys and skip disabled keys', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const serverKey = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -1067,7 +1137,7 @@ test('effective profiles use local lease before server keys and skip disabled ke
test('capability warnings match server Gemini background coverage', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const emptySettings = await t.context.byok.getSettings(workspace.id, user.id);
t.deepEqual(
@@ -732,7 +732,7 @@ test('should be able to chat with special image model', async t => {
promptName
);
const messageId = await createCopilotMessage(app, sessionId, 'some-tag', [
`https://example.com/${promptName}.jpg`,
smallestPng,
]);
const ret3 = await chatWithImages(app, sessionId, messageId);
t.is(
@@ -17,6 +17,7 @@ import {
import { ConfigModule } from '../../base/config';
import { AuthService } from '../../core/auth';
import { QuotaModule } from '../../core/quota';
import { QuotaStateService } from '../../core/quota/state';
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
import {
ContextCategories,
@@ -101,6 +102,7 @@ type Context = {
actionBridge: ActionRuntimeBridge;
cronJobs: CopilotCronJobs;
subscription: SubscriptionService;
quotaState: QuotaStateService;
};
const buildTurn = (
@@ -199,6 +201,7 @@ test.before(async t => {
const workspaceEmbedding = module.get(CopilotWorkspaceService);
const cronJobs = module.get(CopilotCronJobs);
const subscription = module.get(SubscriptionService);
const quotaState = module.get(QuotaStateService);
t.context.module = module;
t.context.auth = auth;
@@ -225,6 +228,7 @@ test.before(async t => {
t.context.workspaceEmbedding = workspaceEmbedding;
t.context.cronJobs = cronJobs;
t.context.subscription = subscription;
t.context.quotaState = quotaState;
await module.initTestingDB();
});
@@ -2172,7 +2176,7 @@ test('model selection policy should resolve requested optional models consistent
});
test('capability policy host should gate pro model requests by subscription status', async t => {
const { subscription, module } = t.context;
const { quotaState, subscription, module } = t.context;
const capabilityPolicy = module.get(CapabilityPolicyHost);
const mockStatus = (status?: SubscriptionStatus) => {
@@ -2181,6 +2185,10 @@ test('capability policy host should gate pro model requests by subscription stat
// @ts-expect-error mock
getSubscription: async () => (status ? { status } : null),
}));
Sinon.stub(quotaState, 'reconcileUserQuotaState').resolves({
plan: status === SubscriptionStatus.Active ? 'pro' : 'free',
flags: {},
} as Awaited<ReturnType<QuotaStateService['reconcileUserQuotaState']>>);
};
// payment disabled -> allow requested if in optional; pro not blocked
@@ -3,7 +3,7 @@ import test from 'ava';
import { z } from 'zod';
import type { DocReader } from '../../core/doc';
import type { AccessController } from '../../core/permission';
import type { PermissionAccess } from '../../core/permission';
import type { Models } from '../../models';
import {
LlmRequest,
@@ -404,7 +404,7 @@ test('doc_read should return specific sync errors for unavailable docs', async t
user: () => ({
workspace: () => ({ doc: () => ({ can: async () => true }) }),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
for (const testCase of cases) {
let docReaderCalled = false;
@@ -447,7 +447,7 @@ test('document search tools should return sync error for local workspace', async
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -510,7 +510,7 @@ test('doc_semantic_search should return empty array when nothing matches', async
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -542,7 +542,7 @@ test('doc_semantic_search should pass BYOK route context into embedding matches'
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -595,7 +595,7 @@ test('blob_read should return explicit error when attachment context is missing'
}),
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const blobTool = createBlobReadTool(
buildBlobContentGetter(ac, null).bind(null, {
@@ -57,6 +57,21 @@ function getSnapshot(timestamp: number = Date.now()): DocRecord {
};
}
test('history max age converts quota seconds to milliseconds', async t => {
Sinon.restore();
const options = m.get(DocStorageOptions);
// @ts-expect-error private service boundary is asserted here
Sinon.stub(options.quota, 'getWorkspaceQuota').resolves({
name: 'Pro',
blobLimit: 1,
storageQuota: 1,
historyPeriod: 30,
memberLimit: 1,
});
t.is(await options.historyMaxAge('1'), 30_000);
});
test('should create doc history if never created before', async t => {
// @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves(null);
@@ -273,16 +273,64 @@ e2e('should update comment work', async t => {
t.truthy(result.updateComment);
});
e2e('should update comment failed by another user', async t => {
e2e('should update comment work by doc Editor', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Editor,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: workspace.id,
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(member);
const result = await app.gql({
query: updateCommentMutation,
variables: {
input: {
id: createResult.createComment.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test update' }],
},
},
},
});
t.truthy(result.updateComment);
});
e2e('should update comment failed without update permission', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Reader,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
@@ -1145,15 +1193,79 @@ e2e('should update reply work when user is reply owner', async t => {
t.truthy(result.updateReply);
});
e2e('should update reply failed when user is not reply owner', async t => {
e2e('should update reply work by doc Editor', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Editor,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: workspace.id,
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
const createReplyResult = await app.gql({
query: createReplyMutation,
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(member);
const result = await app.gql({
query: updateReplyMutation,
variables: {
input: {
id: createReplyResult.createReply.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test update' }],
},
},
},
});
t.truthy(result.updateReply);
});
e2e('should update reply failed without update permission', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Reader,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
@@ -28,37 +28,43 @@ e2e('should render doc share page with apple-itunes-app meta tag', async t => {
);
});
e2e(
e2e.serial(
'should render doc share page without apple-itunes-app meta tag when selfhosted',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error override
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
await using app = await createApp();
try {
await using app = await createApp();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
// set public to true
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
public: true,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
// set public to true
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
public: true,
});
const res = await app
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
.expect(200)
.expect('Content-Type', 'text/html; charset=utf-8');
const res = await app
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
.expect(200)
.expect('Content-Type', 'text/html; charset=utf-8');
t.notRegex(
res.text,
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
);
t.notRegex(
res.text,
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
@@ -69,6 +69,64 @@ e2e('should get recently updated docs', async t => {
t.is(recentlyUpdatedDocs.edges[2].node.title, doc1.title);
});
e2e('should filter recently updated docs by doc read permission', async t => {
const owner = await app.signup();
const member = await app.createUser();
await app.login(member);
await app.switchUser(owner);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
type: WorkspaceRole.Collaborator,
});
const privateSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: privateSnapshot.id,
title: 'private-doc',
defaultRole: DocRole.None,
});
const publicSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const publicDoc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: publicSnapshot.id,
title: 'public-doc',
defaultRole: DocRole.None,
public: true,
});
await app.switchUser(member);
const {
workspace: { recentlyUpdatedDocs },
} = await app.gql({
query: getRecentlyUpdatedDocsQuery,
variables: {
workspaceId: workspace.id,
pagination: {
first: 10,
},
},
});
t.is(recentlyUpdatedDocs.totalCount, 1);
t.deepEqual(
recentlyUpdatedDocs.edges.map(edge => edge.node.id),
[publicDoc.docId]
);
});
e2e(
'should get doc with public attribute when doc snapshot not exists',
async t => {
@@ -5,7 +5,6 @@ import {
listNotificationsQuery,
MentionNotificationBodyType,
mentionUserMutation,
notificationCountQuery,
NotificationObjectType,
NotificationType,
readAllNotificationsMutation,
@@ -13,6 +12,7 @@ import {
} from '@affine/graphql';
import { Mockers } from '../../mocks';
import { createRealtimeClient, realtimeRequest } from '../realtime';
import { app, e2e } from '../test';
async function init() {
@@ -270,10 +270,10 @@ e2e('should mark notification as read', async t => {
},
});
}
const count = await app.gql({
query: notificationCountQuery,
});
t.is(count.currentUser!.notificationCount, 0);
const socket = await createRealtimeClient(app, member);
t.teardown(() => socket.disconnect());
const count = await realtimeRequest(socket, 'notification.count.get', {});
t.is(count.count, 0);
// read again should work
for (const notification of notifications) {
@@ -0,0 +1,92 @@
import type {
RealtimeAck,
RealtimeRequestInputOf,
RealtimeRequestName,
RealtimeRequestOutputOf,
} from '@affine/realtime';
import { io, type Socket as SocketIOClient } from 'socket.io-client';
import type { Response } from 'supertest';
import type { MockedUser } from '../mocks';
import type { TestingApp } from './create-app';
const REALTIME_CLIENT_VERSION = '0.26.0';
const WS_TIMEOUT_MS = 5_000;
function cookieHeader(res: Response) {
return (res.get('Set-Cookie') ?? [])
.map(cookie => cookie.split(';')[0])
.join('; ');
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
) {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
}, timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function waitForConnect(socket: SocketIOClient) {
if (socket.connected) {
return;
}
await withTimeout(
new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
}),
WS_TIMEOUT_MS,
'realtime socket connect'
);
}
export async function createRealtimeClient(app: TestingApp, user: MockedUser) {
const login = await app.login(user);
const socket = io(app.url, {
transports: ['websocket'],
reconnection: false,
forceNew: true,
extraHeaders: {
cookie: cookieHeader(login),
},
});
await waitForConnect(socket);
return socket;
}
export async function realtimeRequest<Op extends RealtimeRequestName>(
socket: SocketIOClient,
op: Op,
input: RealtimeRequestInputOf<Op>
): Promise<RealtimeRequestOutputOf<Op>> {
const ack = await withTimeout(
new Promise<RealtimeAck<RealtimeRequestOutputOf<Op>>>(resolve => {
socket.emit(
'realtime:request',
{ op, input, clientVersion: REALTIME_CLIENT_VERSION },
(res: RealtimeAck<RealtimeRequestOutputOf<Op>>) => resolve(res)
);
}),
WS_TIMEOUT_MS,
`realtime request ${op}`
);
if ('error' in ack) {
throw new Error(`${ack.error.name}: ${ack.error.message}`);
}
return ack.data;
}
@@ -15,9 +15,18 @@ import {
R2StorageProvider,
} from '../../../base/storage/providers/r2';
import { SIGNED_URL_EXPIRED } from '../../../base/storage/providers/utils';
import { WorkspaceBlobStorage } from '../../../core/storage';
import { EntitlementService } from '../../../core/entitlement';
import {
CommentAttachmentStorage,
WorkspaceBlobStorage,
} from '../../../core/storage';
import { MULTIPART_THRESHOLD } from '../../../core/storage/constants';
import { R2UploadController } from '../../../core/storage/r2-proxy';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { app, e2e, Mockers } from '../test';
class MockR2Provider extends R2StorageProvider {
@@ -160,6 +169,8 @@ async function setBlobStorage(storage: StorageProviderConfig) {
configFactory.override({ storages: { blob: { storage } } });
const blobStorage = app.get(WorkspaceBlobStorage);
await blobStorage.onConfigInit();
const commentAttachmentStorage = app.get(CommentAttachmentStorage);
await commentAttachmentStorage.onConfigInit();
const controller = app.get(R2UploadController);
// reset cached provider in controller
(controller as any).provider = null;
@@ -245,7 +256,13 @@ async function getBlobUploadPartUrl(
}
async function setupWorkspace() {
const owner = await app.signup({ feature: 'pro_plan_v1' });
const owner = await app.signup();
await app.get(EntitlementService).upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
const workspace = await app.create(Mockers.Workspace, { owner });
return { owner, workspace };
}
@@ -435,7 +452,13 @@ e2e(
e2e(
'should still fallback to graphql when provider does not support presign',
async t => {
await setBlobStorage(defaultBlobStorage);
await setBlobStorage({
provider: 'fs',
bucket: 'test-fallback-bucket',
config: {
path: '/tmp/affine-r2-proxy-test',
},
});
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('graph');
@@ -1,6 +1,11 @@
import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
import {
Config,
ConfigFactory,
type StorageProviderConfig,
} from '../../../base';
import { CommentAttachmentStorage } from '../../../core/storage';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
@@ -21,6 +26,11 @@ e2e.afterEach.always(() => {
mock.reset();
});
async function useCommentAttachmentBlobStorage(storage: StorageProviderConfig) {
app.get(ConfigFactory).override({ storages: { blob: { storage } } });
await app.get(CommentAttachmentStorage).onConfigInit();
}
// #region comment attachment
e2e(
@@ -61,35 +71,50 @@ e2e(
}
);
e2e('should get comment attachment body', async t => {
e2e.serial('should get comment attachment body', async t => {
const defaultBlobStorage = structuredClone(
app.get(Config).storages.blob.storage
);
await useCommentAttachmentBlobStorage({
provider: 'fs',
bucket: 'test-comment-attachment',
config: {
path: '/tmp/affine-test-comment-attachment',
},
});
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test'),
owner.id
);
try {
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test'),
owner.id
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
} finally {
await useCommentAttachmentBlobStorage(defaultBlobStorage);
}
});
e2e('should get comment attachment redirect url', async t => {
@@ -1,28 +1,36 @@
import { randomUUID } from 'node:crypto';
import {
acceptInviteByInviteIdMutation,
approveWorkspaceTeamMemberMutation,
createInviteLinkMutation,
deleteBlobMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery,
inviteByEmailsMutation,
leaveWorkspaceMutation,
releaseDeletedBlobsMutation,
revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import {
WorkspaceMemberSource,
WorkspaceMemberStatus as PrismaWorkspaceMemberStatus,
} from '@prisma/client';
import { Models } from '../../../models';
import { FeatureConfigs } from '../../../models/common/feature';
import { EntitlementService } from '../../../core/entitlement';
import { WorkspacePolicyService } from '../../../core/permission';
import { Models, WorkspaceRole as ModelWorkspaceRole } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { Mockers } from '../../mocks';
import { createRealtimeClient, realtimeRequest } from '../realtime';
import { app, e2e } from '../test';
const TWO_BILLION_BYTES = 2_000_000_000;
async function createWorkspace() {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
@@ -35,6 +43,23 @@ async function createWorkspace() {
};
}
async function grantTeamPlan(workspaceId: string, quantity: number) {
await app.get(EntitlementService).upsertFromCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity,
});
}
async function revokeTeamPlan(workspaceId: string) {
await app.get(EntitlementService).revokeCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
});
}
e2e('should invite a user', async t => {
const { owner, workspace } = await createWorkspace();
const u2 = await app.create(Mockers.User);
@@ -91,19 +116,16 @@ e2e('should invite a user', async t => {
e2e('should re-check seat when accepting an email invitation', async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: 4,
});
await grantTeamPlan(workspace.id, 12);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
await Promise.all(
Array.from({ length: 10 }).map(async () => {
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
})
);
await app.login(owner);
const invite = await app.gql({
@@ -116,10 +138,10 @@ e2e('should re-check seat when accepting an email invitation', async t => {
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id,
quantity: 4,
quantity: 12,
});
await app.models.workspaceFeature.remove(workspace.id, 'team_plan_v1');
await revokeTeamPlan(workspace.id);
await app.login(member);
await t.throwsAsync(
@@ -147,24 +169,6 @@ e2e.serial(
async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
const freeStorageQuota = FeatureConfigs.free_plan_v1.configs.storageQuota;
const lifetimeStorageQuota =
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota;
FeatureConfigs.free_plan_v1.configs.storageQuota = 1;
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota = 2;
t.teardown(() => {
FeatureConfigs.free_plan_v1.configs.storageQuota = freeStorageQuota;
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota =
lifetimeStorageQuota;
});
await app.models.userFeature.switchQuota(
owner.id,
'lifetime_pro_plan_v1',
'test setup'
);
await app.login(owner);
const invite = await app.gql({
query: inviteByEmailsMutation,
@@ -174,26 +178,26 @@ e2e.serial(
},
});
await app.models.blob.upsert({
workspaceId: workspace.id,
key: 'overflow-blob',
mime: 'application/octet-stream',
size: 2,
status: 'completed',
uploadId: null,
});
await app.eventBus.emitAsync('user.subscription.canceled', {
userId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
});
const overflowBlobKeys = Array.from(
{ length: 6 },
(_, index) => `overflow-blob-${index}`
);
await Promise.all(
overflowBlobKeys.map(key =>
app.models.blob.upsert({
workspaceId: workspace.id,
key,
mime: 'application/octet-stream',
size: TWO_BILLION_BYTES,
status: 'completed',
uploadId: null,
})
)
);
t.true(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
await app.login(member);
@@ -216,26 +220,13 @@ e2e.serial(
t.is(pendingInvite.status, WorkspaceMemberStatus.Pending);
await app.login(owner);
await app.gql({
query: deleteBlobMutation,
variables: {
workspaceId: workspace.id,
key: 'overflow-blob',
permanently: false,
},
});
await app.gql({
query: releaseDeletedBlobsMutation,
variables: {
workspaceId: workspace.id,
},
});
for (const key of overflowBlobKeys) {
await app.models.blob.delete(workspace.id, key, true);
}
t.false(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
await app.login(member);
@@ -393,39 +384,31 @@ e2e('should support pagination for member', async t => {
userId: u2.id,
});
await app.login(owner);
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 2,
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
let result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 0,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 2);
t.is(result.memberCount, 3);
t.is(result.members.length, 2);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 2,
take: 2,
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 2,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 3,
take: 2,
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 3,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 0);
t.is(result.memberCount, 3);
t.is(result.members.length, 0);
});
e2e('should limit member count correctly', async t => {
@@ -441,17 +424,15 @@ e2e('should limit member count correctly', async t => {
})
);
await app.login(owner);
const result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 10,
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
const result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 0,
take: 10,
});
t.is(result.workspace.memberCount, 11);
t.is(result.workspace.members.length, 10);
t.is(result.memberCount, 11);
t.is(result.members.length, 10);
});
e2e('should get invite link info with status', async t => {
@@ -596,10 +577,7 @@ e2e(
'should invite by link and send review request notification over quota limit',
async t => {
const { owner, workspace } = await createWorkspace();
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: 3,
});
await grantTeamPlan(workspace.id, 3);
await app.login(owner);
const { createInviteLink } = await app.gql({
@@ -639,10 +617,7 @@ e2e(
name: faker.internet.displayName({ firstName: 'Lucy' }),
});
const user2 = await app.create(Mockers.User, {
email: faker.internet.email({
firstName: 'Jeanne',
lastName: 'Doe',
}),
email: `jeanne_doe.${randomUUID()}@affine.pro`,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
@@ -653,38 +628,54 @@ e2e(
userId: user2.id,
});
await app.login(owner);
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'lucy',
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
let result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'lucy',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].name, user1.name);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].name, user1.name);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'LUCY',
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'LUCY',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].name, user1.name);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].name, user1.name);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'jeanne_doe',
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'jeanne_doe',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].email, user2.email);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].email, user2.email);
const pendingEmail = `pending_search.${randomUUID()}@affine.pro`;
const pendingUser = await app.create(Mockers.User, {
email: pendingEmail,
});
await app
.get(Models)
.workspaceUser.set(
workspace.id,
pendingUser.id,
ModelWorkspaceRole.Collaborator,
{
status: PrismaWorkspaceMemberStatus.Pending,
source: WorkspaceMemberSource.Email,
}
);
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'pending_search',
});
t.is(result.memberCount, 4);
t.is(result.members.length, 1);
t.is(result.members[0].email, pendingEmail);
t.is(result.members[0].status, WorkspaceMemberStatus.Pending);
}
);
@@ -6,6 +6,7 @@ import {
revokePublicPageMutation,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { PrismaClient } from '@prisma/client';
import { QuotaService } from '../../../core/quota/service';
import { WorkspaceRole } from '../../../models';
@@ -98,7 +99,31 @@ const revokeMember = async (workspaceId: string, userId: string) => {
return revokeMember;
};
e2e('should set new invited users to AllocatingSeat', async t => {
const cancelTeamWorkspace = async (workspaceId: string) => {
const db = app.get(PrismaClient);
await db.entitlement.updateMany({
where: {
targetType: 'workspace',
targetId: workspaceId,
plan: 'team',
},
data: { status: 'revoked' },
});
await db.subscription.updateMany({
where: {
targetId: workspaceId,
plan: SubscriptionPlan.Team,
},
data: { status: 'canceled' },
});
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
};
e2e('should set new invited users to waiting-seat status', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
@@ -117,7 +142,7 @@ e2e('should set new invited users to AllocatingSeat', async t => {
const invitationInfo = await getInvitationInfo(
result.inviteMembers[0].inviteId!
);
t.is(invitationInfo.status, WorkspaceMemberStatus.AllocatingSeat);
t.is(invitationInfo.status, WorkspaceMemberStatus.NeedMoreSeat);
});
e2e('should allocate seats', async t => {
@@ -151,11 +176,11 @@ e2e('should allocate seats', async t => {
});
t.is(
members.find(m => m.user.id === u1.id)?.status,
members.find(m => m.user?.id === u1.id)?.status,
WorkspaceMemberStatus.Pending
);
t.is(
members.find(m => m.user.id === u2.id)?.status,
members.find(m => m.user?.id === u2.id)?.status,
WorkspaceMemberStatus.Accepted
);
@@ -201,11 +226,11 @@ e2e('should set all rests to NeedMoreSeat', async t => {
});
t.is(
members.find(m => m.user.id === u2.id)?.status,
members.find(m => m.user?.id === u2.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
t.is(
members.find(m => m.user.id === u3.id)?.status,
members.find(m => m.user?.id === u3.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
});
@@ -237,11 +262,7 @@ e2e(
status: WorkspaceMemberStatus.UnderReview,
});
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 20,
@@ -265,11 +286,7 @@ e2e(
async t => {
const { workspace, owner, admin } = await createTeamWorkspace();
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.false(
@@ -306,11 +323,7 @@ e2e(
await app.login(owner);
await publishDoc(workspace.id, 'published-doc');
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.true(
@@ -325,7 +338,7 @@ e2e(
);
await t.throwsAsync(publishDoc(workspace.id, 'blocked-doc'));
await t.notThrowsAsync(revokePublicDoc(workspace.id, 'published-doc'));
await t.throwsAsync(revokePublicDoc(workspace.id, 'published-doc'));
const quota = await app
.get(QuotaService)
@@ -27,6 +27,16 @@ export class MockTeamWorkspace extends Mocker<
quantity,
},
});
await this.db.entitlement.create({
data: {
targetType: 'workspace',
targetId: id,
source: 'cloud_subscription',
plan: 'team',
status: 'active',
quantity,
},
});
await this.db.workspaceFeature.create({
data: {
@@ -45,6 +45,55 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
: undefined,
},
});
const runtimeStateColumns = await this.db.$queryRaw<
Array<{ exists: boolean }>
>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_runtime_states'
AND column_name = 'known'
) AS "exists"
`;
if (runtimeStateColumns[0]?.exists) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
known = true,
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
last_reconciled_at = now(),
stale_after = NULL,
updated_at = now()
`;
} else {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
stale_at = NULL,
updated_at = now()
`;
}
// create a rootDoc snapshot
if (snapshot) {
@@ -73,6 +73,24 @@ test('should set doc user role', async t => {
t.is(role?.type, DocRole.Manager);
});
test('should batch update existing doc user roles', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.set(workspace.id, docId, user.id, DocRole.Reader);
const count = await models.docUser.batchSetUserRoles(
workspace.id,
docId,
[user.id],
DocRole.Editor
);
const role = await models.docUser.get(workspace.id, docId, user.id);
t.is(count, 1);
t.is(role?.type, DocRole.Editor);
});
test('should not allow setting doc owner through setDocUserRole', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
@@ -96,6 +114,23 @@ test('should delete doc user role', async t => {
t.is(role, null);
});
test('should delete doc grants by user id', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
await models.docUser.deleteByUserId(user.id);
t.is(await models.docUser.get(workspace.id, docId, user.id), null);
t.is(
await db.docGrant.count({
where: { principalType: 'user', principalId: user.id },
}),
0
);
});
test('should paginate doc user roles', async t => {
const workspace = await create();
const docId = 'fake-doc-id';
@@ -1,12 +1,16 @@
import { User } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
import { AvailableUserFeatureConfig } from '../../core/features/types';
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
import { Feature } from '../../models/common/feature';
import { createTestingModule, TestingModule } from '../utils';
interface Context {
module: TestingModule;
model: UserFeatureModel;
resolver: AdminFeatureManagementResolver;
u1: User;
}
@@ -16,6 +20,7 @@ test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(UserFeatureModel);
t.context.resolver = module.get(AdminFeatureManagementResolver);
t.context.module = module;
});
@@ -31,6 +36,21 @@ test.after(async t => {
await t.context.module.close();
});
test('configurable user features exclude commercial projection features', t => {
const config = new AvailableUserFeatureConfig();
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot));
});
test('admin feature resolver rejects commercial projection features', async t => {
await t.throwsAsync(
t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]),
{ message: /not configurable/ }
);
t.deepEqual(await t.context.model.list(t.context.u1.id), []);
});
test('should get null if user feature not found', async t => {
const { model, u1 } = t.context;
const userFeature = await model.get(u1.id, 'ai_early_access');
@@ -39,12 +59,14 @@ test('should get null if user feature not found', async t => {
test('should get user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userFeature = await model.get(u1.id, 'free_plan_v1');
t.is(userFeature?.name, 'free_plan_v1');
});
test('should get user quota', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userQuota = await model.getQuota(u1.id);
t.snapshot(userQuota?.configs, 'free plan');
});
@@ -52,6 +74,7 @@ test('should get user quota', async t => {
test('should list user features', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.like(await model.list(u1.id), ['free_plan_v1']);
});
@@ -68,6 +91,7 @@ test('should list user features by type', async t => {
test('should directly test user feature existence', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.true(await model.has(u1.id, 'free_plan_v1'));
t.false(await model.has(u1.id, 'ai_early_access'));
});
@@ -112,6 +136,7 @@ test('should switch user quota', async t => {
test('should not switch user quota if the new quota is the same as the current one', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
// @ts-expect-error private
@@ -135,6 +160,7 @@ test('should use pro plan as free for selfhost instance', async t => {
registered: true,
});
await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection');
const quota = await models.userFeature.getQuota(u1.id);
t.snapshot(
quota?.configs,
@@ -1,6 +1,7 @@
import { Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin';
import {
FeatureType,
UserModel,
@@ -12,6 +13,7 @@ import { createTestingModule, type TestingModule } from '../utils';
interface Context {
module: TestingModule;
model: WorkspaceFeatureModel;
resolver: AdminWorkspaceResolver;
ws: Workspace;
}
@@ -21,6 +23,7 @@ test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(WorkspaceFeatureModel);
t.context.resolver = module.get(AdminWorkspaceResolver);
t.context.module = module;
});
@@ -44,6 +47,17 @@ test('should get null if workspace feature not found', async t => {
t.is(userFeature, null);
});
test('admin workspace update changes workspace flags', async t => {
await t.context.resolver.adminUpdateWorkspace({
id: t.context.ws.id,
name: 'updated',
});
t.is(
(await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name,
'updated'
);
});
test('should directly test workspace feature existence', async t => {
const { model, ws } = t.context;
@@ -0,0 +1,594 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { PermissionProjectionChecker } from '../../core/permission/projection-checker';
import {
DocRole,
PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES,
PermissionProjectionModel,
permissionProjectionTriggerErrorCategory,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../models';
import { createModule } from '../create-module';
import { Mockers } from '../mocks';
const module = await createModule({});
const db = module.get(PrismaClient);
test.after.always(async () => {
await module.close();
});
class TestPermissionProjectionModel extends PermissionProjectionModel {
constructor(private readonly fakeDb: unknown) {
super();
}
protected override get db() {
return this.fakeDb as never;
}
}
let appliedPermissionProjectionTriggerFunctionUpdates = false;
async function applyPermissionProjectionTriggerFunctionUpdates() {
if (appliedPermissionProjectionTriggerFunctionUpdates) {
return;
}
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
for (const name of [
'affine_permission_project_new_workspace_member',
'affine_permission_project_new_workspace_invitation',
'affine_permission_project_new_doc_access_policy',
'affine_permission_project_new_doc_grant',
]) {
const sql = migration.match(
new RegExp(
`CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;`
)
)?.[0];
if (!sql) {
throw new Error(`Missing migration function ${name}`);
}
await db.$executeRawUnsafe(sql);
}
appliedPermissionProjectionTriggerFunctionUpdates = true;
}
async function hasCurrentWorkspaceInvitationColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name IN ('requested_role', 'status', 'kind')
`;
return rows.length === 3;
}
test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => {
const queryResults = [
[{ count: 1n }],
[{ count: 2n }],
[{ count: 3n }],
[{ count: 4n }],
[{ count: 5n }],
[{ count: 6n }],
[{ count: 7n }],
[{ count: 8n }],
[{ count: 9n }],
[{ count: 10n }],
[
{ category: 'legacy_doc_external_row', count: 11n },
{ category: 'doc_default_owner', count: 12n },
],
];
const model = new TestPermissionProjectionModel({
$queryRaw: async () => queryResults.shift(),
});
t.deepEqual(await model.checkLegacyProjection(), {
oldWorkspacePolicyMismatch: 1,
oldAcceptedMemberMismatch: 2,
extraProjectedMember: 3,
oldInvitationMismatch: 4,
extraProjectedInvitation: 5,
oldDocGrantMismatch: 6,
extraProjectedDocGrant: 7,
oldDocPolicyMismatch: 8,
extraProjectedDocPolicy: 9,
runtimeStateMissing: 0,
runtimeStateMismatch: 0,
ownerConflict: 10,
oldNewDecisionMismatch: 0,
invalidLegacyRows: {
legacy_doc_external_row: 11,
doc_default_owner: 12,
},
});
});
test('PermissionProjectionModel backfill runs with legacy origin in a long transaction', async t => {
const executed: unknown[] = [];
let transactionOptions: unknown;
const model = new TestPermissionProjectionModel({
$transaction: async (
callback: (tx: unknown) => Promise<void>,
options: unknown
) => {
transactionOptions = options;
await callback({
$executeRaw: async (query: unknown) => {
executed.push(query);
},
});
},
});
await model.backfillLegacyProjection();
t.is(executed.length, 11);
t.deepEqual(transactionOptions, { timeout: 10 * 60 * 1000 });
t.regex(String(executed[0]), /affine\.permission_sync_origin/);
});
test('PermissionProjectionModel exposes stable trigger metric categories', t => {
t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [
'owner_conflict',
'invalid_legacy_role',
'foreign_key_missing',
'projection_recursion_guard_missing',
'unknown',
]);
});
test('permission projection migration uses non-recursive origin guard', t => {
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
const guardBody = migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/
)?.[0];
t.truthy(guardBody);
t.true(
guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN')
);
t.false(
guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()')
);
t.truthy(
migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/
)
);
});
test('permission projection trigger maps legacy workspace permission rows', async t => {
const workspace = await module.create(Mockers.Workspace);
const [admin, pending] = await module.create(Mockers.User, 2);
await db.workspaceUserRole.createMany({
data: [
{
workspaceId: workspace.id,
userId: admin.id,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
},
{
workspaceId: workspace.id,
userId: pending.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Pending,
},
],
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: admin.id,
state: 'active',
},
});
const invitation = await db.workspaceInvitation.findUniqueOrThrow({
where: {
workspaceId_inviteeUserId: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
},
},
});
t.is(member.role, 'admin');
t.is(invitation.requestedRole, 'member');
t.is(invitation.status, 'pending');
});
test('permission projection trigger maps legacy doc policy rows', async t => {
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'public-doc',
public: true,
defaultRole: DocRole.Reader,
},
});
const policy = await db.docAccessPolicy.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'public-doc',
},
},
});
t.is(policy.visibility, 'public');
t.is(policy.publicRole, 'external');
t.is(policy.memberDefaultRole, 'reader');
});
async function hasDocGrantLegacyProjectionColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'doc_grants'
AND column_name IN (
'legacy_workspace_id',
'legacy_doc_id',
'legacy_user_id'
)
`;
return rows.length === 3;
}
test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => {
if (!(await hasDocGrantLegacyProjectionColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates doc_grants legacy columns'
);
return;
}
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceDocUserRole.createMany({
data: [
{
workspaceId: workspace.id,
docId: 'valid-grant',
userId: user.id,
type: DocRole.Reader,
},
{
workspaceId: workspace.id,
docId: 'dirty-external',
userId: user.id,
type: DocRole.External,
},
{
workspaceId: workspace.id,
docId: 'dirty-none',
userId: user.id,
type: DocRole.None,
},
],
});
const grants = await db.docGrant.findMany({
where: {
workspaceId: workspace.id,
principalId: user.id,
},
orderBy: {
docId: 'asc',
},
});
t.deepEqual(
grants.map(grant => [grant.docId, grant.role]),
[['valid-grant', 'reader']]
);
});
test('permission projection trigger clears legacy row for non-active new workspace member states', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const member = await db.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId: user.id,
role: 'member',
state: 'active',
},
});
t.truthy(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
);
await db.workspaceMember.update({
where: { id: member.id },
data: { state: 'suspended' },
});
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => {
if (!(await hasCurrentWorkspaceInvitationColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates workspace invitation projection columns'
);
return;
}
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const [invitation] = await db.$queryRaw<{ id: string }[]>`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
requested_role,
status,
kind
)
VALUES (
${workspace.id},
${user.id},
'member',
'pending',
'email'
)
RETURNING id
`;
t.is(
(
await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
).status,
'Pending'
);
await db.$executeRaw`
UPDATE workspace_invitations
SET status = 'declined'
WHERE id = ${invitation.id}
`;
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'metadata-doc',
public: true,
defaultRole: DocRole.Reader,
mode: 1,
blocked: true,
title: 'Title',
summary: 'Summary',
publishedAt: new Date('2026-01-01T00:00:00Z'),
},
});
await db.docAccessPolicy.delete({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
const doc = await db.workspaceDoc.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
t.is(doc.public, false);
t.is(doc.defaultRole, DocRole.Manager);
t.is(doc.publishedAt, null);
t.is(doc.mode, 1);
t.is(doc.blocked, true);
t.is(doc.title, 'Title');
t.is(doc.summary, 'Summary');
});
test('permission projection trigger ignores group doc grants on legacy projection', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'user',
principalId: user.id,
role: 'reader',
},
});
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
role: 'manager',
},
});
await db.docGrant.delete({
where: {
workspaceId_docId_principalType_principalId: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
},
},
});
const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({
where: {
workspaceId_docId_userId: {
workspaceId: workspace.id,
docId: 'group-doc',
userId: user.id,
},
},
});
t.is(legacyGrant.type, DocRole.Reader);
});
test('PermissionProjectionModel parses trigger error metric category', t => {
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:owner_conflict:duplicate owner')
),
'owner_conflict'
);
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:unexpected:nope')
),
'unknown'
);
t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null);
});
test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => {
const checker = new PermissionProjectionChecker(
{
workspace: {
findMany: async () => [],
},
$queryRaw: async () => [
{
category: 'active_member_doc',
workspaceId: 'w1',
docId: 'doc1',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'explicit_doc_grant',
workspaceId: 'w1',
docId: 'doc2',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'workspace_invitation',
workspaceId: 'w1',
docId: null,
userId: 'u2',
workspaceActions: ['Workspace.Read'],
docActions: null,
},
],
} as never,
{
permissionProjection: {
checkLegacyProjection: async () => ({}),
},
} as never,
{
load: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: 'legacy' },
docs: input.docs
? [{ docId: input.docs[0].docId, marker: 'legacy' }]
: [],
}),
loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: input.docs ? 'legacy' : 'projection' },
docs: input.docs
? [
{
docId: input.docs[0].docId,
marker:
input.docs[0].docId === 'doc1' ? 'legacy' : 'projection',
},
]
: [],
}),
} as never,
{
evaluate: (input: unknown) => input,
} as never
);
t.deepEqual(await checker.checkLegacyProjection(), {
oldNewDecisionMismatch: 2,
});
});
@@ -151,6 +151,22 @@ test('should not get inactive workspace role', async t => {
t.is(role, null);
});
test('should not activate a missing workspace invitation', async t => {
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await t.throwsAsync(
models.workspaceUser.setStatus(
workspace.id,
user.id,
WorkspaceMemberStatus.Accepted
),
{ message: 'Cannot activate a missing workspace invitation.' }
);
t.is(await models.workspaceUser.get(workspace.id, user.id), null);
});
test('should update user role', async t => {
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
@@ -215,6 +231,114 @@ test('should delete workspace user role', async t => {
t.is(role, null);
});
test('should delete legacy-only external workspace user role', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
t.truthy(await models.workspaceUser.get(workspace.id, u1.id));
await models.workspaceUser.delete(workspace.id, u1.id);
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
});
test('should convert existing workspace user role to legacy-only external role', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.get(workspace.id, u1.id);
t.is(role?.type, WorkspaceRole.External);
t.is(
await db.workspaceMember.count({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
}),
0
);
});
test('should backfill legacy permission id for new workspace member writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: u1.id,
},
},
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
});
t.is(member.legacyPermissionId, legacyRole.id);
});
test('should backfill legacy permission id for new workspace invitation writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Pending,
}
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: u1.id,
},
},
});
const invitation = await db.workspaceInvitation.findFirstOrThrow({
where: {
workspaceId: workspace.id,
inviteeUserId: u1.id,
},
});
t.is(invitation.legacyPermissionId, legacyRole.id);
});
test('should get user workspace roles with filter', async t => {
const ws1 = await module.create(Mockers.Workspace);
const ws2 = await module.create(Mockers.Workspace);
@@ -0,0 +1,204 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CryptoHelper, EventBus } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { WorkspacePolicyService } from '../../core/permission';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceService } from '../../core/workspaces';
import { Models } from '../../models';
import { LicenseService } from '../../plugins/license/service';
import { PaymentEventHandlers } from '../../plugins/payment/event';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from '../../plugins/payment/types';
type Context = Record<string, never>;
const test = ava as TestFn<Context>;
test('workspace subscription activation only sends upgrade notification', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
let reconciled = false;
const handler = new PaymentEventHandlers(
{
isTeamWorkspace: async () => true,
sendTeamWorkspaceUpgradedEmail: async () => {},
} as unknown as WorkspaceService,
{
reconcileWorkspaceQuotaState: async () => {
reconciled = true;
},
} as unknown as WorkspacePolicyService,
{
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 7 }),
} as unknown as QuotaStateService,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus
);
await handler.onWorkspaceSubscriptionUpdated({
workspaceId: 'ws',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
quantity: 999,
});
t.deepEqual(events, []);
t.false(reconciled);
});
test('workspace entitlement change allocates seats from effective quota state', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const handler = new PaymentEventHandlers(
{} as unknown as WorkspaceService,
{} as unknown as WorkspacePolicyService,
{
reconcileWorkspaceQuotaState: async () => ({
plan: 'team',
seatLimit: 7,
}),
} as unknown as QuotaStateService,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus
);
await handler.onEntitlementChanged({
targetType: 'workspace',
targetId: 'ws',
});
t.deepEqual(events, [
{
name: 'workspace.members.allocateSeats',
payload: { workspaceId: 'ws', quantity: 7 },
},
]);
});
test('onetime selfhost license seat allocation ignores projected license quantity', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const service = new LicenseService(
{
installedLicense: {
findUnique: async () => ({
key: 'license-key',
workspaceId: 'ws',
quantity: 999,
recurring: SubscriptionRecurring.Yearly,
variant: SubscriptionVariant.Onetime,
}),
},
} as unknown as PrismaClient,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus,
{} as unknown as Models,
{} as unknown as CryptoHelper,
{} as unknown as WorkspacePolicyService,
{} as unknown as EntitlementService,
{
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 4 }),
} as unknown as QuotaStateService
);
await service.updateTeamSeats({
workspaceId: 'ws',
} as Events['workspace.members.updated']);
t.deepEqual(events, [
{
name: 'workspace.members.allocateSeats',
payload: { workspaceId: 'ws', quantity: 4 },
},
]);
});
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const affineProRequests: string[] = [];
const upserts: unknown[] = [];
const entitlements: unknown[] = [];
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
const service = new LicenseService(
{
installedLicense: {
findUnique: async () => null,
upsert: async (input: unknown) => {
upserts.push(input);
return {
workspaceId: 'ws',
key: 'license-key',
quantity: 3,
recurring: SubscriptionRecurring.Monthly,
variant: null,
};
},
},
} as unknown as PrismaClient,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus,
{} as unknown as Models,
{} as unknown as CryptoHelper,
{} as unknown as WorkspacePolicyService,
{
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
entitlements.push(input);
},
} as unknown as EntitlementService,
{} as unknown as QuotaStateService
);
(
service as unknown as {
fetchAffinePro: (path: string) => Promise<{
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
quantity: number;
endAt: number;
res: Response;
}>;
}
).fetchAffinePro = async (path: string) => {
affineProRequests.push(path);
return {
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
quantity: 3,
endAt: expiresAt,
res: new Response(null, {
headers: {
'x-next-validate-key': 'next-validate-key',
},
}),
};
};
const license = await service.activateTeamLicense('ws', 'license-key');
t.like(license, {
workspaceId: 'ws',
key: 'license-key',
quantity: 3,
recurring: SubscriptionRecurring.Monthly,
});
t.is(entitlements.length, 1);
t.is(upserts.length, 1);
t.deepEqual(affineProRequests, ['/api/team/licenses/license-key/activate']);
t.deepEqual(events, [
{
name: 'workspace.subscription.activated',
payload: {
workspaceId: 'ws',
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
quantity: 3,
},
},
]);
});
@@ -86,7 +86,10 @@ test('should cleanup expired pending blobs', async t => {
],
});
const abortSpy = Sinon.spy(t.context.storage, 'abortMultipartUpload');
const abortSpy = Sinon.stub(
t.context.storage,
'abortMultipartUpload'
).resolves();
const deleteSpy = Sinon.spy(t.context.storage, 'delete');
t.teardown(() => {
abortSpy.restore();
@@ -9,7 +9,7 @@ import type { TestingApp } from './utils';
type TestContext = {
app: TestingApp;
};
const test = ava as TestFn<TestContext>;
const test = ava.serial as TestFn<TestContext>;
let safeFetchStub: Sinon.SinonStub | undefined;
let safeFetchHandler:
@@ -3,7 +3,8 @@ import { createHash } from 'node:crypto';
import test from 'ava';
import Sinon from 'sinon';
import { Config, StorageProviderFactory } from '../../base';
import { Config, ConfigFactory, StorageProviderFactory } from '../../base';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
import { BlobModel, WorkspaceFeatureModel } from '../../models';
import {
@@ -35,6 +36,18 @@ let model: WorkspaceFeatureModel;
test.before(async () => {
app = await createTestingApp();
model = app.get(WorkspaceFeatureModel);
app.get(ConfigFactory).override({
storages: {
blob: {
storage: {
provider: 'fs',
bucket: 'test',
config: { path: '/tmp/affine-test-storage' },
},
},
},
});
await app.get(WorkspaceBlobStorage).onConfigInit();
});
test.beforeEach(async () => {
@@ -45,6 +58,26 @@ test.after.always(async () => {
await app.close();
});
async function withRestrictedWorkspaceQuota(workspaceId: string) {
const quotaState = app.get(QuotaStateService);
const blobModel = app.get(BlobModel);
const base = await quotaState.reconcileWorkspaceQuotaState(workspaceId);
return Sinon.stub(quotaState, 'reconcileWorkspaceQuotaState').callsFake(
async id => {
if (id !== workspaceId) {
return base;
}
return {
...base,
blobLimit: BigInt(RESTRICTED_QUOTA.blobLimit),
storageQuota: BigInt(RESTRICTED_QUOTA.storageQuota),
usedStorageQuota: BigInt(await blobModel.totalSize(workspaceId)),
};
}
);
}
test('should set blobs', async t => {
await app.signupV1('u1@affine.pro');
@@ -233,7 +266,8 @@ test('should reject blob exceeded limit', async t => {
await app.signupV1('u1@affine.pro');
const workspace1 = await createWorkspace(app);
await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
const quotaStub = await withRestrictedWorkspaceQuota(workspace1.id);
t.teardown(() => quotaStub.restore());
const buffer1 = Buffer.from(
Array.from({ length: RESTRICTED_QUOTA.blobLimit + 1 }, () => 0)
@@ -247,7 +281,8 @@ test('should reject blob exceeded storage quota', async t => {
await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app);
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
const quotaStub = await withRestrictedWorkspaceQuota(workspace.id);
t.teardown(() => quotaStub.restore());
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
@@ -7,7 +7,9 @@ import Sinon from 'sinon';
import supertest from 'supertest';
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
import { ConfigFactory } from '../../base';
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { PermissionReadModel } from '../../core/permission/config';
import { WorkspaceBlobStorage } from '../../core/storage';
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
import {
@@ -152,6 +154,31 @@ test('should be able to get private workspace with public pages', async t => {
t.is(res.text, 'blob');
});
test('should be able to get private workspace with public pages using new permission model', async t => {
const { app, storage } = t.context;
const config = app.get(ConfigFactory);
config.override({
permission: {
readModel: PermissionReadModel.Projection,
},
});
try {
storage.get.resolves(blob());
const res = await app.GET('/api/workspaces/private/blobs/test');
t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');
} finally {
config.override({
permission: {
readModel: PermissionReadModel.Legacy,
},
});
}
});
test('should not be able to get private workspace with no public pages', async t => {
const { app } = t.context;
+1 -1
View File
@@ -10,5 +10,5 @@ import { CacheInterceptor } from './interceptor';
})
export class CacheModule {}
export { Cache, SessionCache };
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
export { isValidCacheTtl } from './provider';
+4
View File
@@ -7,6 +7,10 @@ export interface CacheSetOptions {
ttl?: number;
}
export function isValidCacheTtl(ttl: unknown): ttl is number {
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
}
export class CacheProvider {
constructor(private readonly redis: Redis) {}
@@ -1,6 +1,7 @@
export {
Cache,
CacheInterceptor,
isValidCacheTtl,
MakeCache,
PreventCache,
SessionCache,
@@ -62,6 +62,7 @@ export type KnownMetricScopes =
| 'queue'
| 'storage'
| 'process'
| 'permission'
| 'workspace';
const metricCreators: MetricCreators = {
@@ -9,7 +9,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { ActionForbidden } from '../../base';
import { ActionForbidden, EventBus } from '../../base';
import { Models } from '../../models';
import { CurrentUser } from '../auth/session';
import { UserType } from '../user';
@@ -26,7 +26,10 @@ class GenerateAccessTokenInput {
@Resolver(() => AccessToken)
export class AccessTokenResolver {
constructor(private readonly models: Models) {}
constructor(
private readonly models: Models,
private readonly event: EventBus
) {}
@Query(() => [RevealedAccessToken], {
deprecationReason: 'use currentUser.revealedAccessTokens',
@@ -42,11 +45,13 @@ export class AccessTokenResolver {
@CurrentUser() user: CurrentUser,
@Args('input') input: GenerateAccessTokenInput
): Promise<RevealedAccessToken> {
return await this.models.accessToken.create({
const token = await this.models.accessToken.create({
userId: user.id,
name: input.name,
expiresAt: input.expiresAt,
});
this.event.emit('user.access_token.created', { userId: user.id });
return token;
}
@Mutation(() => Boolean)
@@ -55,6 +60,7 @@ export class AccessTokenResolver {
@Args('id') id: string
): Promise<boolean> {
await this.models.accessToken.revoke(id, user.id);
this.event.emit('user.access_token.revoked', { userId: user.id });
return true;
}
}
@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { z } from 'zod';
import { decodeWithJson, encodeWithJson } from '../../base/graphql';
import { AccessController } from '../permission';
import { PermissionAccess } from '../permission';
import {
realtimeCommentRoom,
RealtimePublisher,
@@ -20,7 +20,7 @@ export function commentRoom(workspaceId: string, docId: string) {
export class CommentRealtimeProvider implements OnModuleInit {
constructor(
private readonly service: CommentService,
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly registry: RealtimeRegistry
) {}
@@ -25,7 +25,7 @@ import {
import { Comment, DocMode, Models, Reply } from '../../models';
import { CurrentUser } from '../auth/session';
import { ServerFeature, ServerService } from '../config';
import { AccessController, DocAction } from '../permission';
import { DocAction, PermissionAccess } from '../permission';
import { RealtimePublisher } from '../realtime';
import { CommentAttachmentStorage } from '../storage';
import { UserType } from '../user';
@@ -54,7 +54,7 @@ export interface CommentCursor {
export class CommentResolver {
constructor(
private readonly service: CommentService,
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly commentAttachmentStorage: CommentAttachmentStorage,
private readonly queue: JobQueue,
private readonly models: Models,
@@ -469,11 +469,7 @@ export class CommentResolver {
private async assertPermission(
me: UserType,
item: {
workspaceId: string;
docId: string;
userId?: string;
},
item: { workspaceId: string; docId: string; userId?: string },
action: DocAction
) {
// the owner of the comment/reply can update, delete, resolve it
@@ -173,7 +173,7 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
description: 'Workspace features available for admin configuration',
})
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
return ['unlimited_workspace', 'team_plan_v1'];
return [];
}
}
@@ -11,7 +11,7 @@ import { Models } from '../../models';
import { htmlSanitize } from '../../native';
import { Public } from '../auth';
import { DocReader } from '../doc';
import { WorkspacePolicyService } from '../permission';
import { PermissionService } from '../permission';
interface RenderOptions {
title: string;
@@ -61,7 +61,7 @@ export class DocRendererController {
private readonly doc: DocReader,
private readonly models: Models,
private readonly config: Config,
private readonly policy: WorkspacePolicyService
private readonly permission: PermissionService
) {
this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static'));
this.mobileAssets = this.readHtmlAssets(
@@ -99,10 +99,11 @@ export class DocRendererController {
req.accepts().some(t => markdownType.has(t.toLowerCase()))
) {
try {
const canReadMarkdown = await this.policy.canReadSharedDoc(
const canReadMarkdown = await this.permission.canDoc({
workspaceId,
sub
);
docId: sub,
action: 'Doc.Read',
});
if (!canReadMarkdown) {
res.status(404).end();
return;
@@ -162,7 +163,7 @@ export class DocRendererController {
workspaceId: string,
docId: string
): Promise<RenderOptions | null> {
if (await this.policy.canPreviewDoc(workspaceId, docId)) {
if (await this.permission.canPreviewDoc({ workspaceId, docId })) {
return this.doc.getDocContent(workspaceId, docId);
}
@@ -172,8 +173,9 @@ export class DocRendererController {
private async getWorkspaceContent(
workspaceId: string
): Promise<RenderOptions | null> {
const canPreviewWorkspace =
await this.policy.canPreviewWorkspace(workspaceId);
const canPreviewWorkspace = await this.permission.canPreviewWorkspace({
workspaceId,
});
if (!canPreviewWorkspace) return null;
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
@@ -73,7 +73,7 @@ export class DocStorageOptions implements IDocStorageOptions {
historyMaxAge = async (spaceId: string) => {
const quota = await this.quota.getWorkspaceQuota(spaceId);
return quota.historyPeriod;
return quota.historyPeriod * 1000;
};
historyMinInterval = (_spaceId: string) => {
@@ -0,0 +1,135 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { Models } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import {
EntitlementModule,
EntitlementProjectionChecker,
EntitlementService,
} from '../index';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
entitlement: EntitlementService;
checker: EntitlementProjectionChecker;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({ imports: [EntitlementModule] });
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.entitlement = module.get(EntitlementService);
t.context.checker = module.get(EntitlementProjectionChecker);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('checker distinguishes valid projection from dirty legacy features', async t => {
const cleanUser = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: cleanUser.id,
plan: 'pro',
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
const dirtyUser = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.userFeature.add(
dirtyUser.id,
'pro_plan_v1',
'dirty legacy feature'
);
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.dirtyLegacyUserFeatures, 1);
t.is(report.missingUserFeatureProjection, 0);
});
test('checker reports missing legacy projection and stale state', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: 'pro',
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
await t.context.db.subscription.delete({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
});
await t.context.db.effectiveUserQuotaState.update({
where: { userId: user.id },
data: {
staleAfter: new Date('2020-01-01T00:00:00Z'),
},
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.cloudSubscriptionProjectionMissing, 1);
t.is(report.staleEffectiveUserState, 1);
});
test('checker reports legal legacy facts missing entitlements', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.subscription.create({
data: {
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
},
});
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.installedLicense.create({
data: {
key: 'legacy-verifiable-key',
workspaceId: workspace.id,
quantity: 5,
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
validatedAt: new Date(),
license: Buffer.from('raw-license'),
},
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.cloudSubscriptionEntitlementMissing, 1);
t.is(report.selfhostLicenseEntitlementMissing, 1);
});
@@ -0,0 +1,480 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { Models } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { EntitlementModule, EntitlementService } from '../index';
import { LegacyEntitlementProjectionService } from '../projection';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
entitlement: EntitlementService;
projection: LegacyEntitlementProjectionService;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({ imports: [EntitlementModule] });
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.entitlement = module.get(EntitlementService);
t.context.projection = module.get(LegacyEntitlementProjectionService);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('projects user entitlement to legacy user features and subscriptions', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
t.true(await t.context.models.userFeature.has(user.id, 'pro_plan_v1'));
t.true(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
t.like(
await t.context.db.subscription.findUnique({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
}),
{
recurring: SubscriptionRecurring.Yearly,
status: 'active',
}
);
await t.context.entitlement.revokeCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
});
t.false(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
});
test('projects workspace entitlement and readonly state to legacy workspace features', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.entitlement.upsertFromCloudSubscription({
targetId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
quantity: 8,
});
const teamFeature = await t.context.models.workspaceFeature.get(
workspace.id,
'team_plan_v1'
);
t.is(teamFeature?.configs.memberLimit, 8);
await t.context.db.effectiveWorkspaceQuotaState.upsert({
where: {
workspaceId: workspace.id,
},
create: {
workspaceId: workspace.id,
plan: 'free',
ownerUserId: owner.id,
usesOwnerQuota: true,
seatLimit: 3,
memberCount: 4,
overcapacityMemberCount: 1,
blobLimit: BigInt(10),
storageQuota: BigInt(10),
usedStorageQuota: BigInt(1),
historyPeriodSeconds: 7,
readonly: true,
readonlyReasons: ['member_overflow'],
known: true,
stale: false,
},
update: {
plan: 'free',
ownerUserId: owner.id,
usesOwnerQuota: true,
seatLimit: 3,
memberCount: 4,
overcapacityMemberCount: 1,
blobLimit: BigInt(10),
storageQuota: BigInt(10),
usedStorageQuota: BigInt(1),
historyPeriodSeconds: 7,
readonly: true,
readonlyReasons: ['member_overflow'],
known: true,
stale: false,
},
});
await t.context.projection.onWorkspaceQuotaStateChanged({
workspaceId: workspace.id,
});
t.true(
await t.context.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
);
});
test('installed license scanner never trusts quantity without raw license', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.installedLicense.create({
data: {
key: 'legacy-key',
workspaceId: workspace.id,
quantity: 100,
recurring: SubscriptionRecurring.Yearly,
validateKey: '',
validatedAt: new Date(),
},
});
await t.context.projection.scanInstalledLicenses();
const entitlement = await t.context.db.entitlement.findFirst({
where: {
source: 'selfhost_license',
subjectId: 'legacy-key',
},
});
t.is(entitlement?.status, 'needs_reupload');
t.is(entitlement?.quantity, null);
});
test.serial(
'selfhosted legacy projection ignores unknown entitlements',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.entitlement.create({
data: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'ai',
status: 'active',
subjectId: `forged-ai:${user.id}`,
},
});
await t.context.projection.onEntitlementChanged({
targetType: 'user',
targetId: user.id,
});
t.false(
await t.context.models.userFeature.has(user.id, 'unlimited_copilot')
);
t.is(
await t.context.db.subscription.count({ where: { targetId: user.id } }),
0
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
test('backfill marks selfhost team subscriptions as needing license revalidation', async t => {
await t.context.db.subscription.create({
data: {
targetId: 'license-key-target',
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
},
});
await t.context.projection.backfillEntitlementsAndQuotaStates();
t.like(
await t.context.db.entitlement.findFirstOrThrow({
where: {
source: 'selfhost_license',
subjectId: 'license-key-target',
},
}),
{
targetType: 'instance',
targetId: 'license-key-target',
plan: 'selfhost_team',
status: 'needs_reupload',
}
);
});
test('backfill removes dangling legacy subscriptions and entitlements', async t => {
await t.context.db.subscription.createMany({
data: [
{
targetId: randomUUID(),
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
},
{
targetId: randomUUID(),
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
},
],
});
await t.context.db.entitlement.createMany({
data: [
{
targetType: 'user',
targetId: randomUUID(),
source: 'cloud_subscription',
plan: 'pro',
status: 'active',
subjectId: randomUUID(),
},
{
targetType: 'workspace',
targetId: randomUUID(),
source: 'cloud_subscription',
plan: 'team',
status: 'active',
subjectId: randomUUID(),
},
],
});
await t.context.projection.backfillEntitlementsAndQuotaStates();
t.is(await t.context.db.subscription.count(), 0);
t.is(await t.context.db.entitlement.count(), 0);
});
test('key based selfhost entitlements without raw payload need reupload', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.entitlement.upsertFromSelfhostLicense({
workspaceId: workspace.id,
licenseKey: 'remote-key',
recurring: SubscriptionRecurring.Yearly,
quantity: 5,
validateKey: 'validate-key',
expiresAt: new Date(Date.now() + 3600_000),
});
await t.context.projection.scanInstalledLicenses();
t.like(
await t.context.db.entitlement.findFirstOrThrow({
where: { source: 'selfhost_license', subjectId: 'remote-key' },
}),
{ status: 'needs_reupload', quantity: null }
);
});
test('revoked selfhost entitlement removes installed license projection', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.entitlement.create({
data: {
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'active',
subjectId: 'revoked-key',
quantity: 5,
signedPayload: Buffer.from('signed-license-payload'),
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
},
expiresAt: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
});
await t.context.db.installedLicense.create({
data: {
key: 'revoked-key',
workspaceId: workspace.id,
quantity: 5,
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
validatedAt: new Date(),
license: Buffer.from('signed-license-payload'),
},
});
await t.context.entitlement.revokeBySubject(
'selfhost_license',
'revoked-key'
);
t.falsy(
await t.context.db.installedLicense.findUnique({
where: { workspaceId: workspace.id },
})
);
});
test('installed license projection uses explicit entitlement status priority', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.entitlement.createMany({
data: [
{
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'expired',
subjectId: 'expired-key',
quantity: 5,
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'expired-validate-key',
},
expiresAt: new Date(Date.now() - 3600_000),
validatedAt: new Date(),
},
{
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'grace',
subjectId: 'grace-key',
quantity: 6,
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'grace-validate-key',
},
expiresAt: new Date(Date.now() - 1800_000),
graceUntil: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
],
});
await t.context.projection.onEntitlementChanged({
targetType: 'workspace',
targetId: workspace.id,
});
const installedLicense =
await t.context.db.installedLicense.findUniqueOrThrow({
where: { workspaceId: workspace.id },
});
t.is(installedLicense.key, 'grace-key');
t.is(installedLicense.quantity, 6);
t.is(installedLicense.validateKey, 'grace-validate-key');
});
test.serial(
'selfhosted projection does not trust non-null signed payload',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.entitlement.create({
data: {
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'active',
subjectId: 'forged-key',
quantity: 100,
signedPayload: Buffer.from('not-a-valid-license'),
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
},
expiresAt: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
});
await t.context.projection.onEntitlementChanged({
targetType: 'workspace',
targetId: workspace.id,
});
t.falsy(
await t.context.models.workspaceFeature.get(
workspace.id,
'team_plan_v1'
)
);
t.falsy(
await t.context.db.installedLicense.findUnique({
where: { workspaceId: workspace.id },
})
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
@@ -0,0 +1,508 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { Models } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { EntitlementModule } from '../index';
import { EntitlementService } from '../service';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
service: EntitlementService;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({ imports: [EntitlementModule] });
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.service = module.get(EntitlementService);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('upserts admin grant entitlement as commercial source of truth', async t => {
const owner = await t.context.models.user.create({
email: 'admin-grant-owner@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
const entitlement = await t.context.service.upsertAdminGrant({
targetType: 'workspace',
targetId: workspace.id,
plan: 'team',
quantity: 6,
});
const resolved = await t.context.service.resolveWorkspaceEntitlement(
workspace.id
);
t.is(entitlement.source, 'admin_grant');
t.is(entitlement.plan, 'team');
t.is(entitlement.quantity, 6);
t.is(resolved.plan, 'team');
t.is(resolved.quota.seatLimit, 6);
});
test('admin grant replaces and revokes previous admin grant', async t => {
const user = await t.context.models.user.create({
email: 'admin-grant-replace@affine.pro',
});
await t.context.service.upsertAdminGrant({
targetType: 'user',
targetId: user.id,
plan: 'lifetime_pro',
});
await t.context.service.upsertAdminGrant({
targetType: 'user',
targetId: user.id,
plan: 'pro',
});
const [resolved, entitlements] = await Promise.all([
t.context.service.resolveUserEntitlement(user.id),
t.context.db.entitlement.findMany({
where: { source: 'admin_grant', targetId: user.id },
}),
]);
t.is(resolved.plan, 'pro');
t.is(
entitlements.filter(entitlement => entitlement.status === 'active').length,
1
);
t.false(
entitlements.some(
entitlement =>
entitlement.plan === 'lifetime_pro' && entitlement.status === 'active'
)
);
await t.context.service.revokeAdminGrant('user', user.id);
t.is((await t.context.service.resolveUserEntitlement(user.id)).plan, 'free');
});
test('admin grant rejects self-hosted commercial entitlement without writing', async t => {
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific entitlement semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
const owner = await t.context.models.user.create({
email: 'admin-grant-selfhost@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
try {
await t.throwsAsync(
t.context.service.upsertAdminGrant({
targetType: 'workspace',
targetId: workspace.id,
plan: 'team',
quantity: 6,
}),
{ message: /signed license/ }
);
t.is(
await t.context.db.entitlement.count({
where: { source: 'admin_grant', targetId: workspace.id },
}),
0
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = originalDeploymentType;
}
});
test('admin grant rejects incompatible target plan without writing', async t => {
const user = await t.context.models.user.create({
email: 'admin-grant-invalid@affine.pro',
});
await t.context.service.upsertAdminGrant({
targetType: 'user',
targetId: user.id,
plan: 'pro',
});
await t.throwsAsync(
t.context.service.upsertAdminGrant({
targetType: 'user',
targetId: user.id,
plan: 'team',
quantity: 6,
}),
{ message: /not configurable/ }
);
const active = await t.context.db.entitlement.findMany({
where: { source: 'admin_grant', targetId: user.id, status: 'active' },
});
t.is(active.length, 1);
t.is(active[0].plan, 'pro');
});
test('upserts cloud subscription entitlements without writing legacy features', async t => {
const proUser = await t.context.models.user.create({
email: 'user-pro@affine.pro',
});
const aiUser = await t.context.models.user.create({
email: 'user-ai@affine.pro',
});
const owner = await t.context.models.user.create({
email: 'workspace-owner@affine.pro',
});
const teamWorkspace = await t.context.models.workspace.create(owner.id);
const cases = [
{
targetId: proUser.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
expected: { targetType: 'user', plan: 'pro', status: 'active' },
},
{
targetId: aiUser.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'trialing',
expected: { targetType: 'user', plan: 'ai', status: 'active' },
},
{
targetId: teamWorkspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: 'past_due',
quantity: 7,
expected: { targetType: 'workspace', plan: 'team', status: 'grace' },
},
];
for (const item of cases) {
const entitlement = await t.context.service.upsertFromCloudSubscription({
...item,
subscriptionId: `${item.targetId}:${item.plan}`,
start: new Date('2026-05-14T00:00:00Z'),
});
t.like(entitlement, item.expected, item.targetId);
}
t.is(await t.context.db.entitlement.count(), cases.length);
});
test('revokes cloud subscription entitlement by subject', async t => {
const user = await t.context.models.user.create({
email: 'revoke-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
subscriptionId: 'sub_1',
});
await t.context.service.revokeCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
subscriptionId: 'sub_1',
});
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(updated?.status, 'revoked');
});
test('revokes onetime or revenuecat entitlements using fallback subject', async t => {
const user = await t.context.models.user.create({
email: 'fallback-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
});
await t.context.service.revokeCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
subscriptionId: 1,
});
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(updated?.status, 'revoked');
});
test('resolves higher priority commercial entitlement over ai capability', async t => {
const user = await t.context.models.user.create({
email: 'priority-user@affine.pro',
});
await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
});
await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
const resolved = await t.context.service.resolveUserEntitlement(user.id);
t.is(resolved.plan, 'pro');
t.is(resolved.quota.storageQuota, 100 * 1024 * 1024 * 1024);
});
test('ignores expired active entitlements during best entitlement selection', async t => {
const user = await t.context.models.user.create({
email: 'expired-user@affine.pro',
});
const cases = [
{
status: 'active',
subjectId: 'expired-subscription',
expiresAt: new Date('2020-01-01T00:00:00Z'),
},
{
status: 'grace',
subjectId: 'open-ended-grace',
},
];
for (const item of cases) {
await t.context.db.entitlement.create({
data: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'pro',
...item,
},
});
}
t.falsy(await t.context.service.getBestEntitlement('user', user.id));
const resolved = await t.context.service.resolveUserEntitlement(user.id);
t.is(resolved.plan, 'free');
});
test('selfhosted resolution ignores unsigned DB entitlements', async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific trust boundary
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
const user = await t.context.models.user.create({
email: 'forged-user@affine.pro',
});
const owner = await t.context.models.user.create({
email: 'forged-workspace-owner@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
const cases = [
{
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'ai',
quantity: null,
},
{
targetType: 'workspace',
targetId: workspace.id,
source: 'cloud_subscription',
plan: 'team',
quantity: 100,
},
{
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
quantity: 100,
},
] as const;
for (const item of cases) {
await t.context.db.entitlement.create({
data: {
...item,
status: 'active',
subjectId: `${item.source}:${item.plan}:${item.targetId}`,
quantity: item.quantity ?? undefined,
},
});
}
t.falsy(await t.context.service.getBestEntitlement('user', user.id));
t.falsy(
await t.context.service.getBestEntitlement('workspace', workspace.id)
);
const userResolved = await t.context.service.resolveUserEntitlement(
user.id
);
const workspaceResolved =
await t.context.service.resolveWorkspaceEntitlement(workspace.id);
t.is(userResolved.plan, 'selfhost_free');
t.is(workspaceResolved.plan, 'selfhost_free');
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
});
test('cloud resolution lazily imports legacy subscriptions written after backfill', async t => {
const user = await t.context.models.user.create({
email: 'legacy-subscription-user@affine.pro',
});
await t.context.db.subscription.create({
data: {
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity: 1,
start: new Date(),
},
});
const userResolved = await t.context.service.resolveUserEntitlement(user.id);
const userEntitlement = await t.context.db.entitlement.findFirst({
where: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'pro',
},
});
t.is(userResolved.plan, 'pro');
t.is(userEntitlement?.status, 'active');
const owner = await t.context.models.user.create({
email: 'legacy-subscription-owner@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.subscription.create({
data: {
targetId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity: 7,
start: new Date(),
},
});
const workspaceResolved = await t.context.service.resolveWorkspaceEntitlement(
workspace.id
);
t.is(workspaceResolved.plan, 'team');
t.is(workspaceResolved.quantity, 7);
t.is(workspaceResolved.quota.seatLimit, 7);
await t.context.db.subscription.delete({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const revokedResolved = await t.context.service.resolveUserEntitlement(
user.id
);
const revokedEntitlement = await t.context.db.entitlement.findFirst({
where: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'pro',
},
});
t.is(revokedResolved.plan, 'free');
t.is(revokedEntitlement?.status, 'revoked');
});
test('cloud resolution revokes projected entitlements after legacy subscription deletion', async t => {
const user = await t.context.models.user.create({
email: 'legacy-delete-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
});
await t.context.db.subscription.findUniqueOrThrow({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
await t.context.db.subscription.delete({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const resolved = await t.context.service.resolveUserEntitlement(user.id);
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(resolved.plan, 'free');
t.is(updated?.status, 'revoked');
});
test('cloud resolution keeps projected string-subscription entitlements while legacy row exists', async t => {
const user = await t.context.models.user.create({
email: 'string-subscription-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
subscriptionId: 'sub_legacy_string',
});
await t.context.db.subscription.findUniqueOrThrow({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const resolved = await t.context.service.resolveUserEntitlement(user.id);
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(resolved.plan, 'pro');
t.is(updated?.status, 'active');
});
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { LegacyEntitlementProjectionService } from './projection';
import { EntitlementProjectionChecker } from './projection-checker';
import { EntitlementService } from './service';
@Module({
providers: [
EntitlementService,
LegacyEntitlementProjectionService,
EntitlementProjectionChecker,
],
exports: [
EntitlementService,
LegacyEntitlementProjectionService,
EntitlementProjectionChecker,
],
})
export class EntitlementModule {}
export { EntitlementService };
export { EntitlementProjectionChecker };
export { LegacyEntitlementProjectionService };
@@ -0,0 +1,290 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class EntitlementProjectionChecker {
constructor(private readonly db: PrismaClient) {}
async checkEntitlementProjection() {
const now = new Date();
const [
missingEffectiveUserState,
missingEffectiveWorkspaceState,
staleEffectiveUserState,
staleEffectiveWorkspaceState,
cloudSubscriptionProjectionMissing,
selfhostLicenseProjectionMissing,
cloudSubscriptionEntitlementMissing,
selfhostLicenseEntitlementMissing,
dirtyLegacyUserFeatures,
dirtyLegacyWorkspaceFeatures,
missingUserFeatureProjection,
missingWorkspaceFeatureProjection,
] = await Promise.all([
this.db.user.count({
where: { quotaState: null },
}),
this.db.workspace.count({
where: { quotaState: null },
}),
this.db.effectiveUserQuotaState.count({
where: {
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
},
}),
this.db.effectiveWorkspaceQuotaState.count({
where: {
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
},
}),
this.cloudSubscriptionProjectionMissing(),
this.selfhostLicenseProjectionMissing(),
this.cloudSubscriptionEntitlementMissing(),
this.selfhostLicenseEntitlementMissing(),
this.dirtyLegacyUserFeatures(),
this.dirtyLegacyWorkspaceFeatures(),
this.missingUserFeatureProjection(),
this.missingWorkspaceFeatureProjection(),
]);
return {
missingEffectiveUserState,
missingEffectiveWorkspaceState,
staleEffectiveUserState,
staleEffectiveWorkspaceState,
cloudSubscriptionProjectionMissing,
selfhostLicenseProjectionMissing,
cloudSubscriptionEntitlementMissing,
selfhostLicenseEntitlementMissing,
dirtyLegacyUserFeatures,
dirtyLegacyWorkspaceFeatures,
missingUserFeatureProjection,
missingWorkspaceFeatureProjection,
};
}
private async cloudSubscriptionProjectionMissing() {
const legacyKeys = new Set(
(
await this.db.subscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
},
select: { targetId: true, plan: true },
})
).map(subscription => `${subscription.targetId}:${subscription.plan}`)
);
const entitlements = await this.validEntitlements({
source: 'cloud_subscription',
});
return entitlements.filter(
entitlement =>
entitlement.targetId &&
!legacyKeys.has(
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
).length;
}
private async selfhostLicenseProjectionMissing() {
const licenseKeys = new Set(
(
await this.db.installedLicense.findMany({
select: { key: true },
})
).map(license => license.key)
);
const entitlements = await this.validEntitlements({
source: 'selfhost_license',
});
return entitlements.filter(
entitlement =>
entitlement.subjectId && !licenseKeys.has(entitlement.subjectId)
).length;
}
private async cloudSubscriptionEntitlementMissing() {
const activeSubscriptions = await this.db.subscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
},
select: { targetId: true, plan: true },
});
const valid = new Set(
(
await this.validEntitlements({
source: 'cloud_subscription',
})
).map(
entitlement =>
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
);
return activeSubscriptions.filter(
subscription =>
!valid.has(`${subscription.targetId}:${subscription.plan}`)
).length;
}
private async selfhostLicenseEntitlementMissing() {
const licenses = await this.db.installedLicense.findMany({
where: {
license: { not: null },
},
select: { key: true },
});
const validKeys = new Set(
(
await this.validEntitlements({
source: 'selfhost_license',
})
).flatMap(entitlement => entitlement.subjectId ?? [])
);
return licenses.filter(license => !validKeys.has(license.key)).length;
}
private async dirtyLegacyUserFeatures() {
const rows = await this.db.userFeature.findMany({
where: {
activated: true,
name: {
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
},
},
select: {
userId: true,
name: true,
},
});
const valid = new Set(
(
await this.validEntitlements({
targetType: 'user',
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
})
).map(entitlement => `${entitlement.targetId}:${entitlement.plan}`)
);
return rows.filter(row => {
const plan =
row.name === 'lifetime_pro_plan_v1'
? 'lifetime_pro'
: row.name === 'pro_plan_v1'
? 'pro'
: 'ai';
return !valid.has(`${row.userId}:${plan}`);
}).length;
}
private async dirtyLegacyWorkspaceFeatures() {
const rows = await this.db.workspaceFeature.findMany({
where: {
activated: true,
name: 'team_plan_v1',
},
select: { workspaceId: true },
});
const validWorkspaceIds = new Set(
(
await this.validEntitlements({
targetType: 'workspace',
plan: { in: ['team', 'selfhost_team'] },
})
).flatMap(entitlement => entitlement.targetId ?? [])
);
return rows.filter(row => !validWorkspaceIds.has(row.workspaceId)).length;
}
private async missingUserFeatureProjection() {
const entitlements = await this.validEntitlements({
targetType: 'user',
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
});
const features = new Set(
(
await this.db.userFeature.findMany({
where: {
activated: true,
name: {
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
},
},
select: { userId: true, name: true },
})
).map(feature => `${feature.userId}:${feature.name}`)
);
return entitlements.filter(entitlement => {
if (!entitlement.targetId) {
return false;
}
const feature =
entitlement.plan === 'lifetime_pro'
? 'lifetime_pro_plan_v1'
: entitlement.plan === 'pro'
? 'pro_plan_v1'
: 'unlimited_copilot';
return !features.has(`${entitlement.targetId}:${feature}`);
}).length;
}
private async missingWorkspaceFeatureProjection() {
const entitlements = await this.validEntitlements({
targetType: 'workspace',
plan: { in: ['team', 'selfhost_team'] },
});
const featureWorkspaceIds = new Set(
(
await this.db.workspaceFeature.findMany({
where: {
activated: true,
name: 'team_plan_v1',
},
select: { workspaceId: true },
})
).map(feature => feature.workspaceId)
);
return entitlements.filter(
entitlement =>
entitlement.targetId && !featureWorkspaceIds.has(entitlement.targetId)
).length;
}
private validEntitlements(where: Record<string, unknown>) {
const now = new Date();
return this.db.entitlement.findMany({
where: {
...where,
...(where.source === 'selfhost_license'
? { signedPayload: { not: null } }
: {}),
OR: [
{
status: 'active',
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
{
status: 'grace',
graceUntil: { gt: now },
},
],
},
select: {
targetId: true,
subjectId: true,
plan: true,
},
});
}
private subscriptionPlan(plan: string) {
return plan === 'lifetime_pro' ? 'pro' : plan;
}
}
@@ -0,0 +1,538 @@
import { Injectable } from '@nestjs/common';
import { Entitlement, PrismaClient } from '@prisma/client';
import { OnEvent } from '../../base';
import { Models } from '../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../plugins/payment/types';
import { EntitlementService } from './service';
type Metadata = {
provider?: string | null;
recurring?: string | null;
variant?: string | null;
subscriptionId?: string | number | null;
stripeSubscriptionId?: string | null;
validateKey?: string | null;
legacyProjected?: boolean;
};
@Injectable()
export class LegacyEntitlementProjectionService {
constructor(
private readonly db: PrismaClient,
private readonly models: Models,
private readonly entitlement: EntitlementService
) {}
@OnEvent('entitlement.changed')
async onEntitlementChanged({
targetType,
targetId,
}: Events['entitlement.changed']) {
if (targetType === 'user') {
await this.#projectCloudSubscriptions('user', targetId);
await this.#projectUserFeatures(targetId);
} else if (targetType === 'workspace') {
await this.#projectCloudSubscriptions('workspace', targetId);
await Promise.all([
this.#projectWorkspaceFeatures(targetId),
this.#projectInstalledLicense(targetId),
]);
}
}
@OnEvent('workspace.quota_state.changed')
async onWorkspaceQuotaStateChanged({
workspaceId,
}: Events['workspace.quota_state.changed']) {
await this.#projectReadonlyFeature(workspaceId);
}
async scanInstalledLicenses() {
const licenses = await this.db.installedLicense.findMany();
await Promise.all(
licenses.map(async license =>
license.license
? await this.entitlement.upsertFromSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
license: Buffer.from(license.license),
})
: license.validateKey
? await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
validateKey: license.validateKey,
variant: license.variant,
})
: await this.entitlement.markSelfhostLicenseNeedsReupload({
workspaceId: license.workspaceId,
licenseKey: license.key,
reason: 'Installed license has no raw payload to verify.',
})
)
);
}
async backfillEntitlementsAndQuotaStates() {
await this.#cleanupDanglingLegacyEntitlements();
const [subscriptions, users, workspaces] = await Promise.all([
this.db.subscription.findMany(),
this.db.user.findMany({ select: { id: true } }),
this.db.workspace.findMany({ select: { id: true } }),
]);
for (const subscription of subscriptions) {
if (!(await this.#subscriptionTargetExists(subscription))) {
continue;
}
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
await this.entitlement.markSelfhostLicenseNeedsReupload({
licenseKey: subscription.targetId,
reason:
'Historical self-hosted team subscription needs license activation or revalidation.',
});
continue;
}
await this.entitlement.upsertFromCloudSubscription(subscription);
}
await this.scanInstalledLicenses();
await Promise.all([
...users.map(user =>
this.db.effectiveUserQuotaState.upsert({
where: { userId: user.id },
update: { stale: true },
create: {
userId: user.id,
plan: 'free',
blobLimit: BigInt(0),
storageQuota: BigInt(0),
usedStorageQuota: BigInt(0),
historyPeriodSeconds: 0,
known: false,
stale: true,
},
})
),
...workspaces.map(workspace =>
this.db.effectiveWorkspaceQuotaState.upsert({
where: { workspaceId: workspace.id },
update: { stale: true },
create: {
workspaceId: workspace.id,
plan: 'free',
usesOwnerQuota: true,
seatLimit: 0,
memberCount: 0,
overcapacityMemberCount: 0,
blobLimit: BigInt(0),
storageQuota: BigInt(0),
usedStorageQuota: BigInt(0),
historyPeriodSeconds: 0,
known: false,
stale: true,
},
})
),
]);
}
async #cleanupDanglingLegacyEntitlements() {
await this.db.$executeRaw`
DELETE FROM entitlements entitlement
WHERE (
entitlement.target_type = 'user'
AND NOT EXISTS (
SELECT 1
FROM users
WHERE users.id = entitlement.target_id
)
)
OR (
entitlement.target_type = 'workspace'
AND NOT EXISTS (
SELECT 1
FROM workspaces
WHERE workspaces.id = entitlement.target_id
)
)
`;
await this.db.$executeRaw`
DELETE FROM subscriptions subscription
WHERE (
subscription.plan IN (${SubscriptionPlan.Pro}, ${SubscriptionPlan.AI})
AND NOT EXISTS (
SELECT 1
FROM users
WHERE users.id = subscription.target_id
)
)
OR (
subscription.plan = ${SubscriptionPlan.Team}
AND NOT EXISTS (
SELECT 1
FROM workspaces
WHERE workspaces.id = subscription.target_id
)
)
`;
}
async #subscriptionTargetExists(subscription: {
targetId: string;
plan: string;
}) {
if (
subscription.plan === SubscriptionPlan.Pro ||
subscription.plan === SubscriptionPlan.AI
) {
return !!(await this.db.user.findUnique({
where: { id: subscription.targetId },
select: { id: true },
}));
}
if (subscription.plan === SubscriptionPlan.Team) {
return !!(await this.db.workspace.findUnique({
where: { id: subscription.targetId },
select: { id: true },
}));
}
return true;
}
async #projectUserFeatures(userId: string) {
const entitlements = await this.#activeEntitlements('user', userId);
const quotaEntitlement = entitlements.find(entitlement =>
['lifetime_pro', 'pro'].includes(entitlement.plan)
);
if (quotaEntitlement?.plan === 'lifetime_pro') {
await this.models.userFeature.switchQuota(
userId,
'lifetime_pro_plan_v1',
'legacy entitlement projection'
);
} else if (quotaEntitlement?.plan === 'pro') {
await this.models.userFeature.switchQuota(
userId,
'pro_plan_v1',
'legacy entitlement projection'
);
} else if (
await this.hasActiveUserFeature(userId, [
'pro_plan_v1',
'lifetime_pro_plan_v1',
])
) {
await this.models.userFeature.switchQuota(
userId,
'free_plan_v1',
'legacy entitlement projection'
);
}
if (entitlements.some(entitlement => entitlement.plan === 'ai')) {
await this.models.userFeature.add(
userId,
'unlimited_copilot',
'legacy entitlement projection'
);
} else {
await this.models.userFeature.remove(userId, 'unlimited_copilot');
}
}
async #projectWorkspaceFeatures(workspaceId: string) {
const [entitlement, resolved] = await Promise.all([
this.entitlement.getBestEntitlement('workspace', workspaceId),
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
]);
if (
entitlement &&
['team', 'selfhost_team'].includes(resolved.plan) &&
resolved.valid &&
resolved.quota.seatLimit
) {
await this.models.workspaceFeature.add(
workspaceId,
'team_plan_v1',
'legacy entitlement projection',
{
memberLimit: resolved.quota.seatLimit,
}
);
} else {
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
}
}
async #projectCloudSubscriptions(
targetType: 'user' | 'workspace',
targetId: string
) {
if (env.selfhosted) return;
const entitlements = await this.db.entitlement.findMany({
where: {
targetType,
targetId,
source: 'cloud_subscription',
},
orderBy: { updatedAt: 'asc' },
});
for (const entitlement of this.#projectableCloudEntitlements(
entitlements
)) {
const metadata = entitlement.metadata as Metadata;
await this.db.subscription.upsert({
where: {
targetId_plan: {
targetId,
plan: this.#subscriptionPlan(entitlement.plan),
},
},
update: {
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
quantity: entitlement.quantity ?? 1,
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
provider: this.#provider(metadata.provider),
status: this.#subscriptionStatus(entitlement.status),
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
trialEnd: entitlement.graceUntil,
},
create: {
targetId,
plan: this.#subscriptionPlan(entitlement.plan),
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
quantity: entitlement.quantity ?? 1,
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
provider: this.#provider(metadata.provider),
status: this.#subscriptionStatus(entitlement.status),
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
trialEnd: entitlement.graceUntil,
},
});
if (!metadata.legacyProjected) {
await this.db.entitlement.update({
where: { id: entitlement.id },
data: {
metadata: {
...metadata,
legacyProjected: true,
},
},
});
}
}
}
*#projectableCloudEntitlements(entitlements: Entitlement[]) {
const byPlan = new Map<string, Entitlement>();
for (const entitlement of entitlements) {
const plan = this.#subscriptionPlan(entitlement.plan);
const current = byPlan.get(plan);
if (
!current ||
this.#subscriptionProjectionPriority(entitlement) >
this.#subscriptionProjectionPriority(current)
) {
byPlan.set(plan, entitlement);
}
}
yield* byPlan.values();
}
#subscriptionProjectionPriority(entitlement: {
status: string;
updatedAt: Date;
}) {
const statusPriority =
entitlement.status === 'active' || entitlement.status === 'grace'
? 2
: entitlement.status === 'expired'
? 1
: 0;
return (
statusPriority * 10_000_000_000_000 + entitlement.updatedAt.getTime()
);
}
async #projectInstalledLicense(workspaceId: string) {
const [entitlements, resolved] = await Promise.all([
this.db.entitlement.findMany({
where: {
targetType: 'workspace',
targetId: workspaceId,
source: 'selfhost_license',
},
orderBy: [{ signedPayload: 'desc' }, { updatedAt: 'desc' }],
}),
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
]);
const entitlement = entitlements.sort(
(left, right) =>
this.#installedLicenseStatusPriority(right.status) -
this.#installedLicenseStatusPriority(left.status) ||
Number(!!right.signedPayload) - Number(!!left.signedPayload) ||
right.updatedAt.getTime() - left.updatedAt.getTime()
)[0];
if (!entitlement) {
return;
}
if (
resolved.plan !== 'selfhost_team' ||
!['active', 'grace', 'expired'].includes(resolved.status)
) {
await this.db.installedLicense.deleteMany({
where: { workspaceId },
});
return;
}
const metadata = entitlement.metadata as Metadata;
const expiredAt = resolved.expiresAt
? new Date(resolved.expiresAt)
: entitlement.expiresAt;
await this.db.installedLicense.upsert({
where: { workspaceId },
update: {
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
quantity: resolved.quantity ?? 1,
recurring:
resolved.recurring ??
metadata.recurring ??
SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
validateKey: metadata.validateKey ?? '',
validatedAt: entitlement.validatedAt ?? new Date(),
expiredAt,
license: entitlement.signedPayload
? Buffer.from(entitlement.signedPayload)
: null,
},
create: {
workspaceId,
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
quantity: resolved.quantity ?? 1,
recurring:
resolved.recurring ??
metadata.recurring ??
SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
validateKey: metadata.validateKey ?? '',
validatedAt: entitlement.validatedAt ?? new Date(),
expiredAt,
license: entitlement.signedPayload
? Buffer.from(entitlement.signedPayload)
: null,
},
});
}
#installedLicenseStatusPriority(status: string) {
if (status === 'active' || status === 'grace') {
return 3;
}
if (status === 'expired') {
return 2;
}
if (status === 'needs_reupload') {
return 1;
}
return 0;
}
async #projectReadonlyFeature(workspaceId: string) {
const state = await this.db.effectiveWorkspaceQuotaState.findUnique({
where: {
workspaceId,
},
});
if (state?.readonly) {
await this.models.workspaceFeature.add(
workspaceId,
'quota_exceeded_readonly_workspace_v1',
`legacy quota state projection: ${state.readonlyReasons.join(',')}`
);
} else {
await this.models.workspaceFeature.remove(
workspaceId,
'quota_exceeded_readonly_workspace_v1'
);
}
}
async #activeEntitlements(
targetType: 'user' | 'workspace',
targetId: string
) {
return this.entitlement.getActiveEntitlements(targetType, targetId);
}
private async hasActiveUserFeature(userId: string, names: string[]) {
const count = await this.db.userFeature.count({
where: {
userId,
name: { in: names },
activated: true,
},
});
return count > 0;
}
#subscriptionPlan(plan: string) {
if (plan === 'lifetime_pro') {
return SubscriptionPlan.Pro;
}
if (plan === 'selfhost_team') {
return SubscriptionPlan.SelfHostedTeam;
}
return plan;
}
#subscriptionStatus(status: string) {
if (status === 'active') {
return SubscriptionStatus.Active;
}
if (status === 'grace') {
return SubscriptionStatus.PastDue;
}
return SubscriptionStatus.Canceled;
}
#provider(provider: string | null | undefined) {
return provider === 'revenuecat' ? 'revenuecat' : 'stripe';
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { EntitlementModule } from '../entitlement';
import {
AdminFeatureManagementResolver,
UserFeatureResolver,
@@ -7,6 +8,7 @@ import {
import { EarlyAccessType, FeatureService } from './service';
@Module({
imports: [EntitlementModule],
providers: [
UserFeatureResolver,
AdminFeatureManagementResolver,
@@ -1,5 +1,6 @@
import {
Args,
Int,
Mutation,
Parent,
registerEnumType,
@@ -8,13 +9,10 @@ import {
} from '@nestjs/graphql';
import { difference } from 'lodash-es';
import {
Feature,
Models,
type UserFeatureName,
type WorkspaceFeatureName,
} from '../../models';
import { BadRequest, EventBus } from '../../base';
import { Feature, Models, type UserFeatureName } from '../../models';
import { Admin } from '../common';
import { EntitlementService } from '../entitlement';
import { UserType } from '../user/types';
import { AvailableUserFeatureConfig } from './types';
@@ -42,7 +40,11 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig {
@Admin()
@Resolver(() => Boolean)
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
constructor(private readonly models: Models) {
constructor(
private readonly models: Models,
private readonly entitlement: EntitlementService,
private readonly event: EventBus
) {
super();
}
@@ -55,44 +57,58 @@ export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
features: UserFeatureName[]
) {
const configurableUserFeatures = this.configurableUserFeatures();
const unsupported = features.filter(
feature => !configurableUserFeatures.has(feature)
);
if (unsupported.length) {
throw new BadRequest(
`User feature ${unsupported.join(', ')} is not configurable`
);
}
const removed = difference(Array.from(configurableUserFeatures), features);
await Promise.all(
features.map(async feature => {
if (configurableUserFeatures.has(feature)) {
return this.models.userFeature.add(id, feature, 'admin panel');
} else {
return;
}
})
features.map(feature =>
this.models.userFeature.add(id, feature, 'admin panel')
)
);
await Promise.all(
removed.map(feature => this.models.userFeature.remove(id, feature))
);
const user = await this.models.user.get(id);
if (user) {
this.event.emit('user.updated', user);
}
return features;
}
@Mutation(() => Boolean)
async addWorkspaceFeature(
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
async grantCommercialEntitlement(
@Args('targetType', { type: () => String })
targetType: 'user' | 'workspace',
@Args('targetId', { type: () => String }) targetId: string,
@Args('plan', { type: () => String }) plan: string,
@Args('quantity', { type: () => Int, nullable: true }) quantity?: number
) {
await this.models.workspaceFeature.add(
workspaceId,
feature,
'by administrator'
);
await this.entitlement.upsertAdminGrant({
targetType,
targetId,
plan,
quantity,
});
return true;
}
@Mutation(() => Boolean)
async removeWorkspaceFeature(
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
async revokeCommercialEntitlement(
@Args('targetType', { type: () => String })
targetType: 'user' | 'workspace',
@Args('targetId', { type: () => String }) targetId: string
) {
await this.models.workspaceFeature.remove(workspaceId, feature);
await this.entitlement.revokeAdminGrant(targetType, targetId);
return true;
}
}
@@ -5,24 +5,14 @@ import { Feature, UserFeatureName } from '../../models';
@Injectable()
export class AvailableUserFeatureConfig {
availableUserFeatures(): Set<UserFeatureName> {
return new Set([
Feature.Admin,
Feature.UnlimitedCopilot,
Feature.EarlyAccess,
Feature.AIEarlyAccess,
]);
return new Set([Feature.Admin, Feature.EarlyAccess, Feature.AIEarlyAccess]);
}
configurableUserFeatures(): Set<UserFeatureName> {
return new Set(
env.selfhosted
? [Feature.Admin, Feature.UnlimitedCopilot]
: [
Feature.EarlyAccess,
Feature.AIEarlyAccess,
Feature.Admin,
Feature.UnlimitedCopilot,
]
? [Feature.Admin]
: [Feature.EarlyAccess, Feature.AIEarlyAccess, Feature.Admin]
);
}
}
@@ -14,7 +14,7 @@ import {
import { paginate, PaginationInput } from '../../base/graphql';
import { MentionNotificationCreateSchema } from '../../models';
import { CurrentUser } from '../auth/session';
import { AccessController } from '../permission';
import { PermissionAccess } from '../permission';
import { UserType } from '../user';
import { NotificationService } from './service';
import {
@@ -28,7 +28,7 @@ import {
export class UserNotificationResolver {
constructor(
private readonly service: NotificationService,
private readonly ac: AccessController
private readonly ac: PermissionAccess
) {}
@ResolveField(() => PaginatedNotificationObjectType, {
@@ -229,6 +229,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': false,
'Doc.Comments.Read': false,
'Doc.Comments.Resolve': false,
'Doc.Comments.Update': false,
'Doc.Copy': false,
'Doc.Delete': false,
'Doc.Duplicate': false,
@@ -251,6 +252,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Comments.Update': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': false,
@@ -273,6 +275,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Comments.Update': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': true,
@@ -295,6 +298,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Comments.Update': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': true,
@@ -317,6 +321,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Comments.Update': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -339,6 +344,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Comments.Update': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -361,6 +367,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Comments.Update': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -412,6 +419,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': 'Editor',
'Doc.Comments.Read': 'External',
'Doc.Comments.Resolve': 'Editor',
'Doc.Comments.Update': 'Editor',
'Doc.Copy': 'External',
'Doc.Delete': 'Editor',
'Doc.Duplicate': 'Reader',
@@ -10,14 +10,13 @@ import {
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { DocAccessController } from '../doc';
import { PermissionModule } from '../index';
import { PermissionAccess, PermissionModule } from '../index';
import { WorkspacePolicyService } from '../policy';
import { DocRole, mapDocRoleToPermissions } from '../types';
let module: TestingModule;
let models: Models;
let ac: DocAccessController;
let ac: PermissionAccess;
let policy: WorkspacePolicyService;
let user: User;
let ws: Workspace;
@@ -26,7 +25,7 @@ let underReviewUserId: string;
test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models);
ac = module.get(DocAccessController);
ac = module.get(PermissionAccess);
policy = module.get(WorkspacePolicyService);
});
@@ -40,6 +39,21 @@ test.after.always(async () => {
await module.close();
});
function doc(resource: {
workspaceId: string;
docId: string;
userId: string;
allowLocal?: boolean;
}) {
const checker = ac
.user(resource.userId)
.doc(resource.workspaceId, resource.docId);
if (resource.allowLocal) {
checker.allowLocal();
}
return checker;
}
const roleCases: Array<{
title: string;
setup?: () => Promise<void>;
@@ -90,7 +104,7 @@ const roleCases: Array<{
expectedRole: DocRole.Owner,
},
{
title: 'should fallback to [External] if workspace is public',
title: 'should not grant private doc role if workspace is public',
setup: async () => {
await models.workspace.update(ws.id, {
public: true,
@@ -101,7 +115,7 @@ const roleCases: Array<{
docId: 'doc1',
userId: 'random-user-id',
}),
expectedRole: DocRole.External,
expectedRole: null,
},
{
title: 'should return null even if workspace has other public doc',
@@ -131,9 +145,13 @@ const roleCases: Array<{
title: 'should return null if doc role is [None]',
setup: async () => {
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
const u2 = await models.user.create({
email: `${randomUUID()}@affine.pro`,
});
underReviewUserId = u2.id;
await models.workspaceUser.set(
ws.id,
user.id,
underReviewUserId,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
@@ -143,7 +161,7 @@ const roleCases: Array<{
resource: () => ({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
userId: underReviewUserId,
}),
expectedRole: null,
},
@@ -151,14 +169,6 @@ const roleCases: Array<{
title: 'should return [External] if doc role is [None] but doc is public',
setup: async () => {
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
await models.workspaceUser.set(
ws.id,
user.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.doc.publish(ws.id, 'doc1');
},
resource: () => ({
@@ -174,18 +184,18 @@ for (const roleCase of roleCases) {
test(roleCase.title, async t => {
await roleCase.setup?.();
const resource = roleCase.resource();
const role = await ac.getRole(resource);
const role = (await doc(resource).permissions()).role;
t.is(role, roleCase.expectedRole);
});
}
test('should return mapped permissions', async t => {
const { permissions } = await ac.role({
const { permissions } = await doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
});
}).permissions();
t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner));
});
@@ -195,11 +205,11 @@ test('should deny publish permission when workspace sharing is disabled', async
enableSharing: false,
});
const { permissions } = await ac.role({
const { permissions } = await doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
});
}).permissions();
t.false(permissions['Doc.Publish']);
t.true(permissions['Doc.Read']);
@@ -211,24 +221,18 @@ test('should deny publish assert when workspace sharing is disabled', async t =>
});
await t.throwsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
},
'Doc.Publish'
)
doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
}).assert('Doc.Publish')
);
await t.notThrowsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
},
'Doc.Read'
)
doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
}).assert('Doc.Read')
);
});
@@ -239,34 +243,27 @@ test('should deny external read assert when sharing is disabled even if doc is p
});
await t.throwsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: 'random-user-id',
},
'Doc.Read'
)
doc({
workspaceId: ws.id,
docId: 'doc1',
userId: 'random-user-id',
}).assert('Doc.Read')
);
});
test('should assert action', async t => {
await t.notThrowsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
},
'Doc.Update'
)
doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
}).assert('Doc.Update')
);
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
await t.throwsAsync(
ac.assert(
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
doc({ workspaceId: ws.id, docId: 'doc1', userId: u2.id }).assert(
'Doc.Update'
)
);
@@ -278,8 +275,7 @@ test('should assert action', async t => {
await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager);
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
doc({ workspaceId: ws.id, docId: 'doc1', userId: u2.id }).assert(
'Doc.Delete'
)
);
@@ -301,11 +297,11 @@ test('should apply readonly doc restrictions while keeping cleanup actions', asy
}
await policy.reconcileWorkspaceQuotaState(ws.id);
const { permissions } = await ac.role({
const { permissions } = await doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
});
}).permissions();
t.false(permissions['Doc.Update']);
t.false(permissions['Doc.Publish']);
@@ -1,20 +1,84 @@
import { randomUUID } from 'node:crypto';
import { Prisma, PrismaClient } from '@prisma/client';
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Models } from '../../../models';
import { AccessControllerBuilder } from '../builder';
import { PermissionDiagnosticService } from '../diagnostic';
import { DocRole, PermissionModule, WorkspaceRole } from '../index';
import { PermissionSqlPredicateBuilder } from '../sql-predicate';
import type { DocAction } from '../types';
const module = await createModule({
imports: [PermissionModule],
});
const builder = module.get(AccessControllerBuilder);
const models = module.get(Models);
const db = module.get(PrismaClient);
const diagnostic = module.get(PermissionDiagnosticService);
const sqlPredicate = module.get(PermissionSqlPredicateBuilder);
test.after.always(async () => {
await module.close();
});
async function sqlReadableDocIds(input: {
workspaceId: string;
userId?: string;
action?: DocAction;
docIds: string[];
}) {
const values = Prisma.join(
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
);
const predicate = sqlPredicate.docReadableByNewTablesSql({
workspaceId: input.workspaceId,
userId: input.userId,
action: input.action ?? 'Doc.Read',
docIdColumn: Prisma.raw('c.doc_id'),
});
const rows = await db.$queryRaw<{ docId: string }[]>`
WITH candidates(doc_id, ord) AS (VALUES ${values})
SELECT c.doc_id AS "docId"
FROM candidates c
WHERE ${predicate}
ORDER BY c.ord ASC
`;
return rows.map(row => row.docId);
}
async function resetProjection(workspaceId: string) {
await db.$executeRaw`DELETE FROM doc_grants WHERE workspace_id = ${workspaceId}`;
await db.$executeRaw`DELETE FROM doc_access_policies WHERE workspace_id = ${workspaceId}`;
await db.$executeRaw`DELETE FROM workspace_members WHERE workspace_id = ${workspaceId}`;
await db.$executeRaw`
INSERT INTO workspace_access_policies (
workspace_id,
visibility,
sharing_enabled,
url_preview_enabled,
member_default_doc_role,
updated_at
)
VALUES (${workspaceId}, 'private', true, false, 'none', now())
ON CONFLICT (workspace_id)
DO UPDATE SET
visibility = EXCLUDED.visibility,
sharing_enabled = EXCLUDED.sharing_enabled,
url_preview_enabled = EXCLUDED.url_preview_enabled,
member_default_doc_role = EXCLUDED.member_default_doc_role,
updated_at = now()
`;
await models.workspaceRuntimeState.upsert(workspaceId, {
readonly: false,
readonlyReasons: [],
});
}
test('should filter docs by Doc.Read', async t => {
const owner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
@@ -79,11 +143,329 @@ test('should filter docs by Doc.Read', async t => {
t.is(docs3.length, 0);
});
test('SQL doc read predicate matches Rust for projection default and public candidates', async t => {
const owner = await module.create(Mockers.User);
const member = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await resetProjection(workspace.id);
await db.$executeRaw`
UPDATE workspace_access_policies
SET member_default_doc_role = 'reader'
WHERE workspace_id = ${workspace.id}
`;
await db.$executeRaw`
INSERT INTO workspace_members (
workspace_id,
user_id,
role,
state,
source,
updated_at
)
VALUES (${workspace.id}, ${member.id}, 'member', 'active', 'legacy', now())
`;
await db.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
updated_at
)
VALUES
(${workspace.id}, 'member-default-none', 'private', NULL, 'none', now()),
(${workspace.id}, 'public-doc', 'public', 'external', NULL, now())
`;
const docIds = ['missing-policy', 'member-default-none', 'public-doc'];
const sqlReadable = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: member.id,
docIds,
});
const shadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: member.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sqlReadable,
});
t.deepEqual(sqlReadable, ['missing-policy', 'public-doc']);
t.true(shadow.matched);
});
test('SQL doc read predicate matches Rust for non-member grant and sharing disabled', async t => {
const owner = await module.create(Mockers.User);
const nonMember = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await resetProjection(workspace.id);
await db.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
updated_at
)
VALUES
(${workspace.id}, 'public-doc', 'public', 'external', NULL, now()),
(${workspace.id}, 'private-doc', 'private', NULL, NULL, now()),
(${workspace.id}, 'explicit-grant', 'private', NULL, NULL, now()),
(${workspace.id}, 'explicit-owner-grant', 'private', NULL, NULL, now())
`;
await db.$executeRaw`
INSERT INTO doc_grants (
workspace_id,
doc_id,
principal_type,
principal_id,
role,
updated_at
)
VALUES
(
${workspace.id},
'explicit-grant',
'user',
${nonMember.id},
'reader',
now()
),
(
${workspace.id},
'explicit-owner-grant',
'user',
${nonMember.id},
'owner',
now()
)
`;
const docIds = [
'public-doc',
'private-doc',
'explicit-grant',
'explicit-owner-grant',
];
const sharingEnabledReadable = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: nonMember.id,
docIds,
});
const sharingEnabledShadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: nonMember.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sharingEnabledReadable,
});
const sharingEnabledUpdate = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: nonMember.id,
action: 'Doc.Update',
docIds,
});
await db.$executeRaw`
UPDATE workspace_access_policies
SET sharing_enabled = false
WHERE workspace_id = ${workspace.id}
`;
const sharingDisabledReadable = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: nonMember.id,
docIds,
});
const sharingDisabledShadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: nonMember.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sharingDisabledReadable,
});
t.deepEqual(sharingEnabledReadable, [
'public-doc',
'explicit-grant',
'explicit-owner-grant',
]);
t.true(sharingEnabledShadow.matched);
t.deepEqual(sharingEnabledUpdate, ['explicit-owner-grant']);
t.deepEqual(sharingDisabledReadable, []);
t.true(sharingDisabledShadow.matched);
});
test('SQL doc predicate suppresses member default when explicit grant exists', async t => {
const owner = await module.create(Mockers.User);
const member = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await resetProjection(workspace.id);
await db.$executeRaw`
UPDATE workspace_access_policies
SET member_default_doc_role = 'manager'
WHERE workspace_id = ${workspace.id}
`;
await db.$executeRaw`
INSERT INTO workspace_members (
workspace_id,
user_id,
role,
state,
source,
updated_at
)
VALUES (${workspace.id}, ${member.id}, 'member', 'active', 'legacy', now())
`;
await db.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
updated_at
)
VALUES
(${workspace.id}, 'default-manager', 'private', NULL, NULL, now()),
(${workspace.id}, 'explicit-reader', 'private', NULL, NULL, now())
`;
await db.$executeRaw`
INSERT INTO doc_grants (
workspace_id,
doc_id,
principal_type,
principal_id,
role,
updated_at
)
VALUES (
${workspace.id},
'explicit-reader',
'user',
${member.id},
'reader',
now()
)
`;
const docIds = ['default-manager', 'explicit-reader'];
const sqlUpdateAllowed = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: member.id,
action: 'Doc.Update',
docIds,
});
t.deepEqual(sqlUpdateAllowed, ['default-manager']);
});
test('legacy SQL doc predicate matches external row and explicit grant cap semantics', async t => {
const workspaceId = randomUUID();
const memberId = randomUUID();
const externalId = randomUUID();
async function fixtureLegacyDocIds(input: {
userId: string;
action: DocAction;
docIds: string[];
}) {
const values = Prisma.join(
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
);
const predicate = sqlPredicate.docReadableByLegacyTablesSql({
workspaceId,
userId: input.userId,
action: input.action,
docIdColumn: Prisma.raw('c.doc_id'),
});
// Current triggers reject newly inserted legacy External workspace rows;
// CTEs let the same predicate run in Postgres against historical shapes.
const rows = await db.$queryRaw<{ docId: string }[]>`
WITH
workspaces(id, enable_sharing) AS (
VALUES (${workspaceId}, true)
),
workspace_pages(workspace_id, page_id, public, "defaultRole") AS (
VALUES
(${workspaceId}, 'default-manager', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'explicit-reader', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'external-owner', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'dirty-external', false, ${DocRole.Manager}::smallint)
),
workspace_user_permissions(
id,
workspace_id,
user_id,
status,
type
) AS (
VALUES
(${randomUUID()}, ${workspaceId}, ${memberId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.Collaborator}::smallint),
(${randomUUID()}, ${workspaceId}, ${externalId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.External}::smallint)
),
workspace_page_user_permissions(
workspace_id,
page_id,
user_id,
type
) AS (
VALUES
(${workspaceId}, 'explicit-reader', ${memberId}, ${DocRole.Reader}::smallint),
(${workspaceId}, 'external-owner', ${externalId}, ${DocRole.Owner}::smallint),
(${workspaceId}, 'dirty-external', ${externalId}, ${DocRole.External}::smallint)
),
candidates(doc_id, ord) AS (VALUES ${values})
SELECT c.doc_id AS "docId"
FROM candidates c
WHERE ${predicate}
ORDER BY c.ord ASC
`;
return rows.map(row => row.docId);
}
const memberUpdateAllowed = await fixtureLegacyDocIds({
userId: memberId,
action: 'Doc.Update',
docIds: ['default-manager', 'explicit-reader'],
});
const externalUpdateAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.Update',
docIds: ['external-owner', 'dirty-external'],
});
const externalManageAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.Users.Manage',
docIds: ['external-owner', 'dirty-external'],
});
const externalTransferAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.TransferOwner',
docIds: ['external-owner', 'dirty-external'],
});
t.deepEqual(memberUpdateAllowed, ['default-manager']);
t.deepEqual(externalUpdateAllowed, ['external-owner']);
t.deepEqual(externalManageAllowed, []);
t.deepEqual(externalTransferAllowed, []);
});
test('should filter docs by Doc.Publish', async t => {
const owner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await models.workspace.update(workspace.id, { enableSharing: true });
await models.workspaceRuntimeState.upsert(workspace.id, {
readonly: false,
readonlyReasons: [],
});
const docs1 = await builder
.user(owner.id)
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
@@ -7,11 +8,6 @@ import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import {
DocActionDenied,
OwnerCanNotLeaveWorkspace,
SpaceAccessDenied,
} from '../../../base';
import {
Models,
User,
@@ -19,25 +15,59 @@ import {
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { QuotaService } from '../../quota/service';
import { QuotaServiceModule } from '../../quota/service.module';
import { QuotaStateService } from '../../quota/state';
import { PermissionModule } from '../index';
import { WorkspacePolicyService } from '../policy';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
policy: WorkspacePolicyService;
}
const test = ava as TestFn<Context>;
const READONLY_FEATURE = 'quota_exceeded_readonly_workspace_v1' as const;
type WorkspaceQuotaSnapshot = Awaited<
ReturnType<QuotaService['getWorkspaceQuotaWithUsage']>
ReturnType<QuotaStateService['reconcileWorkspaceQuotaState']>
> & {
ownerQuota?: string;
readonlyReasons: string[];
};
const readonlyWorkspaceState = (
workspaceId: string,
readonlyReasons: string[],
overrides: Partial<WorkspaceQuotaSnapshot> = {}
) =>
({
workspaceId,
plan: 'free',
sourceEntitlementId: null,
ownerUserId: owner.id,
usesOwnerQuota: true,
seatLimit: 3,
memberCount: 1,
overcapacityMemberCount: readonlyReasons.includes('member_overflow')
? 1
: 0,
blobLimit: BigInt(1),
storageQuota: BigInt(1),
usedStorageQuota: readonlyReasons.includes('storage_overflow')
? BigInt(2)
: BigInt(0),
historyPeriodSeconds: 1,
readonly: readonlyReasons.length > 0,
readonlyReasons,
flags: {},
known: true,
stale: false,
lastReconciledAt: new Date(),
staleAfter: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}) satisfies WorkspaceQuotaSnapshot;
async function addAcceptedMembers(
models: Models,
workspaceId: string,
@@ -64,6 +94,7 @@ let workspace: Workspace;
test.before(async t => {
const module = await createTestingModule({ imports: [PermissionModule] });
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.policy = module.get(WorkspacePolicyService);
});
@@ -81,21 +112,23 @@ test.after.always(async t => {
await t.context.module.close();
});
test('should reuse quota service exported by quota service module', async t => {
test('should reuse quota state service exported by quota service module', async t => {
const module = await createTestingModule(
{ imports: [PermissionModule, QuotaServiceModule] },
false
);
try {
const quota = module.select(QuotaServiceModule).get(QuotaService, {
strict: true,
});
const quotaState = module
.select(QuotaServiceModule)
.get(QuotaStateService, {
strict: true,
});
const policy = module.select(PermissionModule).get(WorkspacePolicyService, {
strict: true,
});
t.is(Reflect.get(policy, 'quota'), quota);
t.is(Reflect.get(policy, 'quotaState'), quotaState);
} finally {
await module.close();
}
@@ -108,12 +141,9 @@ test('should keep owned workspace writable when quota is within limit', async t
t.false(state.isReadonly);
t.deepEqual(state.readonlyReasons, []);
t.false(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
});
test('should enter readonly mode when fallback owner member quota overflows', async t => {
test('should report readonly state when fallback owner member quota overflows', async t => {
await addAcceptedMembers(t.context.models, workspace.id, 10);
const state = await t.context.policy.reconcileWorkspaceQuotaState(
@@ -124,91 +154,16 @@ test('should enter readonly mode when fallback owner member quota overflows', as
t.true(state.canRecoverByRemovingMembers);
t.false(state.canRecoverByDeletingBlobs);
t.deepEqual(state.readonlyReasons, ['member_overflow']);
t.true(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
await t.throwsAsync(t.context.policy.assertCanInviteMembers(workspace.id), {
instanceOf: SpaceAccessDenied,
});
});
test('should deny blob uploads when user no longer has write access', async t => {
const external = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspace.id,
external.id,
WorkspaceRole.External,
{ status: WorkspaceMemberStatus.Accepted }
);
await t.throwsAsync(
t.context.policy.assertCanUploadBlob(external.id, workspace.id),
{ instanceOf: SpaceAccessDenied }
);
});
test('should deny publish through policy when workspace sharing is disabled', async t => {
await t.context.models.workspace.update(workspace.id, {
enableSharing: false,
});
await t.throwsAsync(
t.context.policy.assertCanPublishDoc(owner.id, workspace.id, 'doc1'),
{ instanceOf: DocActionDenied }
);
await t.notThrowsAsync(
t.context.policy.assertCanUnpublishDoc(owner.id, workspace.id, 'doc1')
);
});
test('should allow managers to revoke invite links in readonly workspace', async t => {
await addAcceptedMembers(t.context.models, workspace.id, 10);
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
await t.notThrowsAsync(
t.context.policy.assertCanManageInviteLink(owner.id, workspace.id)
);
});
test('should apply leave workspace policy by role', async t => {
const collaborator = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspace.id,
collaborator.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.Accepted }
);
await t.throwsAsync(
t.context.policy.assertCanLeaveWorkspace(owner.id, workspace.id),
{ instanceOf: OwnerCanNotLeaveWorkspace }
);
await t.notThrowsAsync(
t.context.policy.assertCanLeaveWorkspace(collaborator.id, workspace.id)
);
});
test('should enter readonly mode when fallback owner storage quota overflows', async t => {
const quota = Sinon.stub(
Reflect.get(t.context.policy, 'quota') as QuotaService,
'getWorkspaceQuotaWithUsage'
const quotaState = Sinon.stub(
Reflect.get(t.context.policy, 'quotaState') as QuotaStateService,
'reconcileWorkspaceQuotaState'
);
quotaState.callsFake(async workspaceId =>
readonlyWorkspaceState(workspaceId, ['storage_overflow'])
);
quota.resolves({
name: 'Free',
blobLimit: 1,
storageQuota: 1,
usedStorageQuota: 2,
historyPeriod: 1,
memberLimit: 3,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 2,
ownerQuota: owner.id,
} satisfies WorkspaceQuotaSnapshot);
const state = await t.context.policy.reconcileWorkspaceQuotaState(
workspace.id
@@ -218,57 +173,26 @@ test('should enter readonly mode when fallback owner storage quota overflows', a
t.false(state.canRecoverByRemovingMembers);
t.true(state.canRecoverByDeletingBlobs);
t.deepEqual(state.readonlyReasons, ['storage_overflow']);
t.true(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
});
test('should leave readonly mode after workspace usage recovers', async t => {
const quota = Sinon.stub(
Reflect.get(t.context.policy, 'quota') as QuotaService,
'getWorkspaceQuotaWithUsage'
test('should report recovered state after workspace usage recovers', async t => {
const quotaState = Sinon.stub(
Reflect.get(t.context.policy, 'quotaState') as QuotaStateService,
'reconcileWorkspaceQuotaState'
);
quota.onFirstCall().resolves({
name: 'Free',
blobLimit: 1,
storageQuota: 1,
usedStorageQuota: 2,
historyPeriod: 1,
memberLimit: 3,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 2,
ownerQuota: owner.id,
} satisfies WorkspaceQuotaSnapshot);
quota.onSecondCall().resolves({
name: 'Free',
blobLimit: 1,
storageQuota: 1,
usedStorageQuota: 0,
historyPeriod: 1,
memberLimit: 3,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 0,
ownerQuota: owner.id,
} satisfies WorkspaceQuotaSnapshot);
quota.onThirdCall().resolves({
name: 'Free',
blobLimit: 1,
storageQuota: 1,
usedStorageQuota: 0,
historyPeriod: 1,
memberLimit: 3,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 0,
ownerQuota: owner.id,
} satisfies WorkspaceQuotaSnapshot);
quotaState
.onFirstCall()
.callsFake(async workspaceId =>
readonlyWorkspaceState(workspaceId, ['storage_overflow'])
);
quotaState
.onSecondCall()
.callsFake(async workspaceId => readonlyWorkspaceState(workspaceId, []));
quotaState
.onThirdCall()
.callsFake(async workspaceId => readonlyWorkspaceState(workspaceId, []));
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
t.true(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
const recovered = await t.context.policy.reconcileWorkspaceQuotaState(
workspace.id
@@ -276,10 +200,6 @@ test('should leave readonly mode after workspace usage recovers', async t => {
t.false(recovered.isReadonly);
t.deepEqual(recovered.readonlyReasons, []);
t.false(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
await t.notThrowsAsync(t.context.policy.assertCanInviteMembers(workspace.id));
});
test('should roll back team cancellation cleanup when cleanup fails', async t => {
@@ -289,11 +209,58 @@ test('should roll back team cancellation cleanup when cleanup fails', async t =>
const admin = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspace.id,
pending.id,
WorkspaceRole.Collaborator
);
await t.context.db.$transaction(async db => {
await db.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
const pendingPermission = await db.workspaceUserRole.create({
data: {
workspaceId: workspace.id,
userId: pending.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Pending,
},
});
const [invitationShape] = await db.$queryRaw<Array<{ current: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name = 'requested_role'
) AS "current"
`;
if (invitationShape?.current) {
await db.workspaceInvitation.create({
data: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
requestedRole: 'member',
status: 'pending',
kind: 'email',
legacyPermissionId: pendingPermission.id,
},
});
} else {
await db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
role,
state,
source,
updated_at
)
VALUES (
${workspace.id},
${pending.id},
${'member'},
${'pending'},
${'email'},
now()
)
`;
}
});
await t.context.models.workspaceUser.set(
workspace.id,
admin.id,
File diff suppressed because it is too large Load Diff
@@ -10,14 +10,13 @@ import {
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { PermissionModule } from '../index';
import { PermissionAccess, PermissionModule } from '../index';
import { WorkspacePolicyService } from '../policy';
import { mapWorkspaceRoleToPermissions } from '../types';
import { WorkspaceAccessController } from '../workspace';
let module: TestingModule;
let models: Models;
let ac: WorkspaceAccessController;
let ac: PermissionAccess;
let policy: WorkspacePolicyService;
let user: User;
let ws: Workspace;
@@ -26,7 +25,7 @@ let underReviewUserId: string;
test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models);
ac = module.get(WorkspaceAccessController);
ac = module.get(PermissionAccess);
policy = module.get(WorkspacePolicyService);
});
@@ -138,10 +137,34 @@ const roleCases: Array<{
},
];
async function getRole(resource: {
workspaceId: string;
userId: string;
allowLocal?: boolean;
}) {
const checker = ac.user(resource.userId).workspace(resource.workspaceId);
if (resource.allowLocal) {
checker.allowLocal();
}
return (await checker.permissions()).role;
}
function workspace(resource: {
workspaceId: string;
userId: string;
allowLocal?: boolean;
}) {
const checker = ac.user(resource.userId).workspace(resource.workspaceId);
if (resource.allowLocal) {
checker.allowLocal();
}
return checker;
}
for (const roleCase of roleCases) {
test(roleCase.title, async t => {
await roleCase.setup?.();
const role = await ac.getRole(roleCase.resource());
const role = await getRole(roleCase.resource());
t.is(role, roleCase.expectedRole);
});
@@ -150,10 +173,10 @@ for (const roleCase of roleCases) {
test('should return mapped null permission even workspace has public docs', async t => {
await models.doc.publish(ws.id, 'doc1');
const { permissions } = await ac.role({
const { permissions } = await workspace({
workspaceId: ws.id,
userId: 'random-user-id',
});
}).permissions();
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(null));
});
@@ -162,13 +185,10 @@ test('should deny external read assert even workspace has public docs', async t
await models.doc.publish(ws.id, 'doc1');
await t.throwsAsync(
ac.assert(
{
workspaceId: ws.id,
userId: 'random-user-id',
},
'Workspace.Read'
)
workspace({
workspaceId: ws.id,
userId: 'random-user-id',
}).assert('Workspace.Read')
);
});
@@ -177,13 +197,10 @@ test('should deny external read assert when sharing disabled even if workspace h
await models.workspace.update(ws.id, { enableSharing: false });
await t.throwsAsync(
ac.assert(
{
workspaceId: ws.id,
userId: 'random-user-id',
},
'Workspace.Read'
)
workspace({
workspaceId: ws.id,
userId: 'random-user-id',
}).assert('Workspace.Read')
);
});
@@ -193,31 +210,27 @@ test('should reject external doc roles when sharing disabled', async t => {
enableSharing: false,
});
const [docRole] = await ac.docRoles(
{
workspaceId: ws.id,
userId: 'random-user-id',
},
['doc1']
);
const docRole = await ac
.user('random-user-id')
.doc(ws.id, 'doc1')
.permissions();
t.is(docRole.role, null);
t.false(docRole.permissions['Doc.Read']);
});
test('should return mapped permissions', async t => {
const { permissions } = await ac.role({
const { permissions } = await workspace({
workspaceId: ws.id,
userId: user.id,
});
}).permissions();
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(WorkspaceRole.Owner));
});
test('should assert action', async t => {
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, userId: user.id },
workspace({ workspaceId: ws.id, userId: user.id }).assert(
'Workspace.TransferOwner'
)
);
@@ -225,7 +238,7 @@ test('should assert action', async t => {
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await t.throwsAsync(
ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync')
workspace({ workspaceId: ws.id, userId: u2.id }).assert('Workspace.Sync')
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Admin, {
@@ -233,8 +246,7 @@ test('should assert action', async t => {
});
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, userId: u2.id },
workspace({ workspaceId: ws.id, userId: u2.id }).assert(
'Workspace.Settings.Update'
)
);
@@ -256,10 +268,10 @@ test('should apply readonly workspace restrictions while keeping cleanup actions
}
await policy.reconcileWorkspaceQuotaState(ws.id);
const { permissions } = await ac.role({
const { permissions } = await workspace({
workspaceId: ws.id,
userId: user.id,
});
}).permissions();
t.false(permissions['Workspace.CreateDoc']);
t.false(permissions['Workspace.Settings.Update']);
@@ -1,26 +1,47 @@
import { Injectable } from '@nestjs/common';
import { DocID } from '../utils/doc';
import { getAccessController } from './controller';
import { Resource } from './resource';
import { DocAction, WorkspaceAction } from './types';
import { WorkspaceAccessController } from './workspace';
import { PermissionService } from './service';
import {
DOC_ACTIONS,
DocAction,
DocRole,
WORKSPACE_ACTIONS,
WorkspaceAction,
WorkspaceRole,
} from './types';
function assertPerm(permission?: PermissionService) {
if (!permission) {
throw new Error('PermissionService is required for permission checks.');
}
return permission;
}
@Injectable()
export class AccessControllerBuilder {
constructor(private readonly permission?: PermissionService) {}
user(userId: string) {
return new UserAccessControllerBuilder(userId);
return new UserAccessControllerBuilder(userId, this.permission);
}
}
export class UserAccessControllerBuilder {
constructor(private readonly userId: string) {}
constructor(
private readonly userId: string,
private readonly permission?: PermissionService
) {}
workspace(workspaceId: string) {
return new WorkspaceAccessControllerBuilder({
userId: this.userId,
workspaceId,
});
return new WorkspaceAccessControllerBuilder(
{
userId: this.userId,
workspaceId,
},
this.permission
);
}
doc(
@@ -45,16 +66,22 @@ export class UserAccessControllerBuilder {
docId = docIdOrWorkspaceId.docId;
}
return new DocAccessControllerBuilder({
userId: this.userId,
workspaceId,
docId,
});
return new DocAccessControllerBuilder(
{
userId: this.userId,
workspaceId,
docId,
},
this.permission
);
}
}
class WorkspaceAccessControllerBuilder {
constructor(public readonly data: Resource<'ws'>) {}
constructor(
public readonly data: Resource<'ws'>,
private readonly permission?: PermissionService
) {}
allowLocal() {
this.data.allowLocal = true;
@@ -62,10 +89,13 @@ class WorkspaceAccessControllerBuilder {
}
doc(docId: string) {
return new DocAccessControllerBuilder({
...this.data,
docId,
});
return new DocAccessControllerBuilder(
{
...this.data,
docId,
},
this.permission
);
}
/**
@@ -79,35 +109,61 @@ class WorkspaceAccessControllerBuilder {
action: DocAction
): Promise<T[]> {
const docIds = items.map(item => item.docId);
const checker = getAccessController('ws') as WorkspaceAccessController;
const docRoles = await checker.docRoles(this.data, docIds);
const docRoles = await assertPerm(this.permission).batchDocPermissions({
userId: this.data.userId,
workspaceId: this.data.workspaceId,
docs: docIds.map(docId => ({
docId,
actions: [action],
})),
allowLocal: this.data.allowLocal,
});
const docRolesMap = new Map(
docRoles.map((role, index) => [docIds[index], role])
);
return items.filter(item => {
return docRolesMap.get(item.docId)?.permissions[action];
return docRolesMap
.get(item.docId)
?.decisions.some(
decision => decision.action === action && decision.allowed
);
});
}
async assert(action: WorkspaceAction) {
const checker = getAccessController('ws');
await checker.assert(this.data, action);
await assertPerm(this.permission).assertWorkspace({
...this.data,
action,
});
}
async can(action: WorkspaceAction) {
const checker = getAccessController('ws');
return await checker.can(this.data, action);
return await assertPerm(this.permission).canWorkspace({
...this.data,
action,
});
}
async permissions() {
const checker = getAccessController('ws');
return await checker.role(this.data);
const result = await assertPerm(this.permission).workspacePermissions({
...this.data,
actions: [...WORKSPACE_ACTIONS],
});
return {
role: result.legacyApiRole as WorkspaceRole | null,
permissions: Object.fromEntries(
result.decisions.map(decision => [decision.action, decision.allowed])
) as Record<WorkspaceAction, boolean>,
};
}
}
class DocAccessControllerBuilder {
constructor(public readonly data: Resource<'doc'>) {}
constructor(
public readonly data: Resource<'doc'>,
private readonly permission?: PermissionService
) {}
allowLocal() {
this.data.allowLocal = true;
@@ -115,17 +171,29 @@ class DocAccessControllerBuilder {
}
async assert(action: DocAction) {
const checker = getAccessController('doc');
await checker.assert(this.data, action);
await assertPerm(this.permission).assertDoc({
...this.data,
action,
});
}
async can(action: DocAction) {
const checker = getAccessController('doc');
return await checker.can(this.data, action);
return await assertPerm(this.permission).canDoc({
...this.data,
action,
});
}
async permissions() {
const checker = getAccessController('doc');
return await checker.role(this.data);
const result = await assertPerm(this.permission).docPermissions({
...this.data,
actions: [...DOC_ACTIONS],
});
return {
role: result.legacyApiRole as DocRole | null,
permissions: Object.fromEntries(
result.decisions.map(decision => [decision.action, decision.allowed])
) as Record<DocAction, boolean>,
};
}
}
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { defineModuleConfig } from '../../base';
export enum PermissionReadModel {
Legacy = 'legacy',
Projection = 'projection',
}
declare global {
interface AppConfigSchema {
permission: {
readModel: PermissionReadModel;
fallbackLegacyLoader: boolean;
};
}
}
defineModuleConfig('permission', {
readModel: {
desc: 'Permission data source for Rust evaluation',
default: PermissionReadModel.Projection,
shape: z.nativeEnum(PermissionReadModel),
env: ['AFFINE_PERMISSION_READ_MODEL', 'string'],
},
fallbackLegacyLoader: {
desc: 'Fallback from projection loader to legacy loader when projection input loading fails',
default: false,
env: ['AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER', 'boolean'],
},
});
@@ -0,0 +1,463 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ClsService } from 'nestjs-cls';
import { DocRole, Models } from '../../models';
import type { PermissionEvaluationInputV1 } from '../../native';
import {
toNativeDocRole,
toNativeExplicitDocGrantRole,
toNativeMemberState,
toNativeWorkspaceRole,
} from './context';
import type { DocAction, WorkspaceAction } from './types';
type PermissionRequestCache = {
workspaceMember: Map<
string,
Awaited<ReturnType<Models['workspaceUser']['get']>>
>;
workspacePolicy: Map<string, Awaited<ReturnType<Models['workspace']['get']>>>;
workspaceRuntime: Map<
string,
Awaited<ReturnType<Models['workspaceRuntimeState']['get']>>
>;
workspaceQuotaRuntime: Map<string, NewWorkspaceRuntimeState>;
docPolicies: Map<
string,
Awaited<ReturnType<Models['doc']['findDefaultRoles']>>
>;
docGrants: Map<string, Awaited<ReturnType<Models['docUser']['findMany']>>>;
};
type NewWorkspaceMemberRow = {
role: 'owner' | 'admin' | 'member';
state: 'active' | 'suspended' | 'left';
};
type NewWorkspacePolicyRow = {
visibility: 'private' | 'public';
sharingEnabled: boolean;
urlPreviewEnabled: boolean;
memberDefaultDocRole: 'none' | 'reader' | 'commenter' | 'editor' | 'manager';
};
type NewDocPolicyRow = {
docId: string;
visibility: 'private' | 'public';
publicRole: 'external' | null;
memberDefaultRole:
| 'none'
| 'reader'
| 'commenter'
| 'editor'
| 'manager'
| null;
urlPreviewEnabled: boolean;
};
type NewDocGrantRow = {
docId: string;
role: 'owner' | 'manager' | 'editor' | 'commenter' | 'reader';
};
type NewWorkspaceRuntimeState = {
known: boolean;
stale: boolean;
readonly: boolean;
readonlyReasons: string[];
staleAfter: Date | null;
};
const CACHE_KEY = 'permission.context.cache';
function createPermissionRequestCache(): PermissionRequestCache {
return {
workspaceMember: new Map(),
workspacePolicy: new Map(),
workspaceRuntime: new Map(),
workspaceQuotaRuntime: new Map(),
docPolicies: new Map(),
docGrants: new Map(),
};
}
export type PermissionWorkspaceAction = WorkspaceAction | 'Workspace.Preview';
export type PermissionDocAction = DocAction | 'Doc.Preview';
function cacheKey(parts: readonly unknown[]) {
return parts.join('\0');
}
@Injectable()
export class PermissionContextLoader {
constructor(
private readonly models: Models,
private readonly db: PrismaClient,
private readonly cls?: ClsService
) {}
async load(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
workspaceActions?: PermissionWorkspaceAction[];
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
}): Promise<PermissionEvaluationInputV1> {
const docs = input.docs ?? [];
const [member, workspace, runtime, docPolicies, docGrants] =
await Promise.all([
input.userId
? this.workspaceMember(input.workspaceId, input.userId)
: Promise.resolve(null),
this.workspacePolicy(input.workspaceId),
this.workspaceRuntime(input.workspaceId),
this.docPolicies(
input.workspaceId,
docs.map(doc => doc.docId)
),
input.userId
? this.docGrants(
input.workspaceId,
docs.map(doc => doc.docId),
input.userId
)
: Promise.resolve([]),
]);
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
const workspaceSharingEnabled = workspace?.enableSharing ?? true;
return {
version: 1,
legacyCompatMode: true,
subject: {
userId: input.userId,
groupIds: [],
allowLocal: input.allowLocal,
},
runtime: {
known: runtime.known,
stale: runtime.stale,
readonly: runtime.readonly,
readonlyReason: runtime.readonlyReasons[0],
sharingEnabled: workspaceSharingEnabled,
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
},
workspace: {
role: toNativeWorkspaceRole(member?.type),
memberState: toNativeMemberState(member?.status),
public: workspace?.public ?? false,
sharingEnabled: workspaceSharingEnabled,
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
local: !workspace,
},
workspaceActions: input.workspaceActions,
docs: docs.map((doc, index) => {
const policy = docPolicies[index];
const grant = docGrantMap.get(doc.docId);
return {
docId: doc.docId,
actions: doc.actions,
explicitUserRole: toNativeExplicitDocGrantRole(grant?.type),
groupGrants: [],
groupGrantsEnabled: false,
memberDefaultRole: toNativeDocRole(
policy?.workspace ?? DocRole.Manager
),
publicRole: policy?.external === null ? undefined : 'external',
visibility: policy?.external === null ? 'private' : 'public',
sharingEnabled: workspaceSharingEnabled,
previewEnabled: policy?.external !== null,
};
}),
};
}
async loadFromNewTables(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
workspaceActions?: PermissionWorkspaceAction[];
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
}): Promise<PermissionEvaluationInputV1> {
const docs = input.docs ?? [];
const docIds = docs.map(doc => doc.docId);
const [member, workspacePolicy, runtime, docPolicies, docGrants] =
await Promise.all([
input.userId
? this.newWorkspaceMember(input.workspaceId, input.userId)
: Promise.resolve(null),
this.newWorkspacePolicy(input.workspaceId),
this.newWorkspaceRuntime(input.workspaceId),
this.newDocPolicies(input.workspaceId, docIds),
input.userId
? this.newDocGrants(input.workspaceId, docIds, input.userId)
: Promise.resolve([]),
]);
const docPolicyMap = new Map(
docPolicies.map(policy => [policy.docId, policy])
);
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
const local =
!workspacePolicy &&
!!input.allowLocal &&
!(await this.workspaceExists(input.workspaceId));
const sharingEnabled = workspacePolicy?.sharingEnabled ?? true;
const urlPreviewEnabled = workspacePolicy?.urlPreviewEnabled ?? false;
return {
version: 1,
legacyCompatMode: true,
subject: {
userId: input.userId,
groupIds: [],
allowLocal: input.allowLocal,
},
runtime: {
known: runtime.known,
stale: runtime.stale,
readonly: runtime.readonly,
readonlyReason: runtime.readonlyReasons[0],
sharingEnabled,
urlPreviewEnabled,
},
workspace: {
role: member?.role,
memberState: member?.state === 'active' ? 'active' : undefined,
public: workspacePolicy?.visibility === 'public',
sharingEnabled,
urlPreviewEnabled,
local,
},
workspaceActions: input.workspaceActions,
docs: docs.map(doc => {
const policy = docPolicyMap.get(doc.docId);
const grant = docGrantMap.get(doc.docId);
const visibility = policy?.visibility ?? 'private';
const publicRole = policy?.publicRole ?? undefined;
return {
docId: doc.docId,
actions: doc.actions,
explicitUserRole: grant?.role,
groupGrants: [],
groupGrantsEnabled: false,
memberDefaultRole:
policy?.memberDefaultRole ??
workspacePolicy?.memberDefaultDocRole ??
'manager',
publicRole: publicRole === 'external' ? 'external' : undefined,
visibility,
sharingEnabled,
previewEnabled:
visibility === 'public' ||
policy?.urlPreviewEnabled ||
urlPreviewEnabled,
};
}),
};
}
private get cache(): PermissionRequestCache {
if (!this.cls) {
return createPermissionRequestCache();
}
if (typeof this.cls.isActive === 'function' && !this.cls.isActive()) {
return createPermissionRequestCache();
}
const existing = this.cls.get(CACHE_KEY) as
| PermissionRequestCache
| undefined;
if (existing) {
return existing;
}
const created = createPermissionRequestCache();
this.cls.set(CACHE_KEY, created);
return created;
}
private memo<T>(
map: Map<string, Promise<T> | T>,
key: string,
load: () => Promise<T>
) {
const cached = map.get(key);
if (cached) {
return Promise.resolve(cached);
}
const promise = load();
map.set(key, promise);
return promise;
}
private workspaceMember(workspaceId: string, userId: string) {
return this.memo(
this.cache.workspaceMember,
cacheKey([workspaceId, userId]),
() => this.models.workspaceUser.get(workspaceId, userId)
);
}
private workspacePolicy(workspaceId: string) {
return this.memo(this.cache.workspacePolicy, workspaceId, () =>
this.models.workspace.get(workspaceId)
);
}
private async workspaceRuntime(workspaceId: string) {
return this.memo(this.cache.workspaceRuntime, workspaceId, () =>
this.models.workspaceRuntimeState.get(workspaceId).then(async state => {
if (state.known || !state.stale) {
return state;
}
const quotaState = await this.newWorkspaceRuntime(workspaceId);
if (!quotaState.known) {
return state;
}
return {
workspaceId,
known: quotaState.known,
stale: quotaState.stale,
readonly: quotaState.readonly,
readonlyReasons: quotaState.readonlyReasons,
updatedAt: null,
lastReconciledAt: null,
staleAfter: quotaState.staleAfter,
};
})
);
}
invalidateWorkspaceQuotaRuntime(workspaceId: string) {
this.cache.workspaceQuotaRuntime.delete(workspaceId);
}
private newWorkspaceRuntime(workspaceId: string) {
return this.memo(
this.cache.workspaceQuotaRuntime,
workspaceId,
async () => {
const rows = await this.db.$queryRaw<NewWorkspaceRuntimeState[]>`
SELECT
known,
stale,
readonly,
readonly_reasons AS "readonlyReasons",
stale_after AS "staleAfter"
FROM effective_workspace_quota_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
const state = rows[0];
if (!state) {
return {
known: false,
stale: true,
readonly: false,
readonlyReasons: [],
staleAfter: null,
};
}
return {
...state,
stale:
state.stale ||
(state.staleAfter !== null && state.staleAfter <= new Date()),
};
}
);
}
private docPolicies(workspaceId: string, docIds: string[]) {
const uniqueDocIds = [...new Set(docIds)];
return this.memo(
this.cache.docPolicies,
cacheKey([workspaceId, ...uniqueDocIds]),
() => this.models.doc.findDefaultRoles(workspaceId, uniqueDocIds)
);
}
private docGrants(workspaceId: string, docIds: string[], userId: string) {
const uniqueDocIds = [...new Set(docIds)];
return this.memo(
this.cache.docGrants,
cacheKey([workspaceId, userId, ...uniqueDocIds]),
() => this.models.docUser.findMany(workspaceId, uniqueDocIds, userId)
);
}
private async newWorkspaceMember(workspaceId: string, userId: string) {
const rows = await this.db.$queryRaw<NewWorkspaceMemberRow[]>`
SELECT role, state
FROM workspace_members
WHERE workspace_id = ${workspaceId}
AND user_id = ${userId}
AND state = 'active'
LIMIT 1
`;
return rows[0] ?? null;
}
private async newWorkspacePolicy(workspaceId: string) {
const rows = await this.db.$queryRaw<NewWorkspacePolicyRow[]>`
SELECT
visibility,
sharing_enabled AS "sharingEnabled",
url_preview_enabled AS "urlPreviewEnabled",
member_default_doc_role AS "memberDefaultDocRole"
FROM workspace_access_policies
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
return rows[0] ?? null;
}
async workspaceExists(workspaceId: string) {
const workspace = await this.db.workspace.findUnique({
where: { id: workspaceId },
select: { id: true },
});
return !!workspace;
}
private async newDocPolicies(workspaceId: string, docIds: string[]) {
if (docIds.length === 0) {
return [];
}
return await this.db.$queryRaw<NewDocPolicyRow[]>`
SELECT
doc_id AS "docId",
visibility,
public_role AS "publicRole",
member_default_role AS "memberDefaultRole",
url_preview_enabled AS "urlPreviewEnabled"
FROM doc_access_policies
WHERE workspace_id = ${workspaceId}
AND doc_id = ANY(${[...new Set(docIds)]})
`;
}
private async newDocGrants(
workspaceId: string,
docIds: string[],
userId: string
) {
if (docIds.length === 0) {
return [];
}
return await this.db.$queryRaw<NewDocGrantRow[]>`
SELECT doc_id AS "docId", role
FROM doc_grants
WHERE workspace_id = ${workspaceId}
AND principal_type = 'user'
AND principal_id = ${userId}
AND doc_id = ANY(${[...new Set(docIds)]})
`;
}
}
@@ -0,0 +1,125 @@
import { WorkspaceMemberStatus } from '@prisma/client';
import type {
PermissionDocRole,
PermissionEvaluationInputV1,
PermissionEvaluationOutputV1,
PermissionWorkspaceRole,
} from '../../native';
import { DocRole, WorkspaceRole } from './types';
export type PermissionRuntimeState = NonNullable<
PermissionEvaluationInputV1['runtime']
>;
export type PermissionWorkspaceContext = NonNullable<
PermissionEvaluationInputV1['workspace']
>;
export type PermissionDocContext = NonNullable<
NonNullable<PermissionEvaluationInputV1['docs']>[number]
>;
export type PermissionLegacyRoleBoundary = {
resourceOwnerRole: PermissionDocRole | PermissionWorkspaceRole | null;
effectiveRole: PermissionDocRole | PermissionWorkspaceRole | null;
legacyApiRole: DocRole | WorkspaceRole | null;
};
const WORKSPACE_ROLE_TO_NATIVE = new Map<
WorkspaceRole,
PermissionWorkspaceRole
>([
[WorkspaceRole.External, 'external'],
[WorkspaceRole.Collaborator, 'member'],
[WorkspaceRole.Admin, 'admin'],
[WorkspaceRole.Owner, 'owner'],
]);
const DOC_ROLE_TO_NATIVE = new Map<DocRole, PermissionDocRole>([
[DocRole.None, 'none'],
[DocRole.External, 'external'],
[DocRole.Reader, 'reader'],
[DocRole.Commenter, 'commenter'],
[DocRole.Editor, 'editor'],
[DocRole.Manager, 'manager'],
[DocRole.Owner, 'owner'],
]);
const NATIVE_WORKSPACE_ROLE_TO_LEGACY = new Map<
PermissionWorkspaceRole,
WorkspaceRole
>([
['external', WorkspaceRole.External],
['member', WorkspaceRole.Collaborator],
['admin', WorkspaceRole.Admin],
['owner', WorkspaceRole.Owner],
]);
const NATIVE_DOC_ROLE_TO_LEGACY = new Map<PermissionDocRole, DocRole>([
['none', DocRole.None],
['external', DocRole.External],
['reader', DocRole.Reader],
['commenter', DocRole.Commenter],
['editor', DocRole.Editor],
['manager', DocRole.Manager],
['owner', DocRole.Owner],
]);
export function toNativeWorkspaceRole(role: WorkspaceRole | null | undefined) {
return role == null ? undefined : WORKSPACE_ROLE_TO_NATIVE.get(role);
}
export function toNativeDocRole(role: DocRole | null | undefined) {
return role == null ? undefined : DOC_ROLE_TO_NATIVE.get(role);
}
export function toNativeExplicitDocGrantRole(role: DocRole | null | undefined) {
if (role === DocRole.None || role === DocRole.External) {
return undefined;
}
return toNativeDocRole(role);
}
export function toNativeMemberState(status?: WorkspaceMemberStatus | null) {
switch (status) {
case WorkspaceMemberStatus.Accepted:
return 'active';
case WorkspaceMemberStatus.UnderReview:
return 'waiting_review';
case WorkspaceMemberStatus.AllocatingSeat:
case WorkspaceMemberStatus.NeedMoreSeat:
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
return 'waiting_seat';
case WorkspaceMemberStatus.Pending:
return 'pending';
default:
return undefined;
}
}
export function workspaceLegacyBoundary(
workspace: PermissionEvaluationOutputV1['workspace']
): PermissionLegacyRoleBoundary {
const effectiveRole = workspace.effectiveRole ?? null;
return {
resourceOwnerRole: workspace.resourceOwnerRole ?? null,
effectiveRole,
legacyApiRole: effectiveRole
? (NATIVE_WORKSPACE_ROLE_TO_LEGACY.get(effectiveRole) ?? null)
: null,
};
}
export function docLegacyBoundary(
doc: PermissionEvaluationOutputV1['docs'][number]
): PermissionLegacyRoleBoundary {
const effectiveRole = doc.effectiveRole ?? null;
return {
resourceOwnerRole: doc.resourceOwnerRole ?? null,
effectiveRole,
legacyApiRole: effectiveRole
? (NATIVE_DOC_ROLE_TO_LEGACY.get(effectiveRole) ?? null)
: null,
};
}
@@ -1,53 +0,0 @@
import { Logger, OnModuleInit } from '@nestjs/common';
import type {
Resource,
ResourceAction,
ResourceRole,
ResourceType,
} from './resource';
const ACTION_CHECKER_PROVIDERS = new Map<ResourceType, AccessController<any>>();
function registerAccessController<Type extends ResourceType>(
type: Type,
provider: AccessController<Type>
) {
ACTION_CHECKER_PROVIDERS.set(type, provider);
}
export function getAccessController<Type extends ResourceType>(
type: Type
): AccessController<Type> {
const provider = ACTION_CHECKER_PROVIDERS.get(type);
if (!provider) {
throw new Error(`No action checker provider for type ${type}`);
}
return provider;
}
export abstract class AccessController<
Type extends ResourceType,
> implements OnModuleInit {
protected abstract readonly type: Type;
protected logger = new Logger(AccessController.name);
onModuleInit() {
registerAccessController(this.type, this);
}
abstract assert(
resource: Resource<Type>,
action: ResourceAction<Type>
): Promise<void>;
abstract can(
resource: Resource<Type>,
action: ResourceAction<Type>
): Promise<boolean>;
abstract role(resource: Resource<Type>): Promise<{
role: ResourceRole<Type> | null;
permissions: Record<ResourceAction<Type>, boolean>;
}>;
}
@@ -0,0 +1,326 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { metrics } from '../../base';
import type { PermissionEvaluationOutputV1 } from '../../native';
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
import {
PermissionContextLoader,
type PermissionDocAction,
type PermissionWorkspaceAction,
} from './context-loader';
import { PermissionService } from './service';
import { PermissionSqlPredicateBuilder } from './sql-predicate';
export const PERMISSION_SHADOW_MISMATCH_CATEGORIES = [
'legacy_compat_delta',
'projection',
'rust_rule',
'loader',
'sql_predicate',
'legacy_api_role_mapping',
'preview_read_mapping',
'runtime_state',
'projection_or_loader',
] as const;
type PermissionShadowMismatchCategory =
(typeof PERMISSION_SHADOW_MISMATCH_CATEGORIES)[number];
@Injectable()
export class PermissionDiagnosticService {
constructor(
private readonly loader: PermissionContextLoader,
private readonly permission: PermissionService,
@Optional()
@Inject(PermissionSqlPredicateBuilder)
private readonly sqlPredicate = new PermissionSqlPredicateBuilder()
) {}
async shadowDocPermissions(input: {
userId?: string;
workspaceId: string;
docs: Array<{ docId: string; actions: PermissionDocAction[] }>;
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const [legacyOutput, newOutput] = await Promise.all([
this.loader.load(input).then(input => this.permission.evaluate(input)),
this.loader
.loadFromNewTables(input)
.then(input => this.permission.evaluate(input)),
]);
const legacy = legacyOutput.docs.map(doc => ({
docId: doc.docId,
...docLegacyBoundary(doc),
decisions: doc.decisions,
}));
const current = newOutput.docs.map(doc => ({
docId: doc.docId,
...docLegacyBoundary(doc),
decisions: doc.decisions,
}));
const matched = JSON.stringify(legacy) === JSON.stringify(current);
const mismatchType = matched
? null
: (input.expectedDeltaCategory ??
this.classifyDocShadowMismatch(legacy, current));
this.recordShadowMismatch('doc', mismatchType);
return {
matched,
legacy,
current,
mismatchType,
};
}
async shadowWorkspacePermissions(input: {
userId?: string;
workspaceId: string;
actions: PermissionWorkspaceAction[];
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const legacyInput = {
userId: input.userId,
workspaceId: input.workspaceId,
workspaceActions: input.actions,
allowLocal: input.allowLocal,
};
const [legacyOutput, newOutput] = await Promise.all([
this.loader
.load(legacyInput)
.then(input => this.permission.evaluate(input)),
this.loader
.loadFromNewTables(legacyInput)
.then(input => this.permission.evaluate(input)),
]);
const legacy = {
...workspaceLegacyBoundary(legacyOutput.workspace),
decisions: legacyOutput.workspace.decisions,
};
const current = {
...workspaceLegacyBoundary(newOutput.workspace),
decisions: newOutput.workspace.decisions,
};
const matched = JSON.stringify(legacy) === JSON.stringify(current);
const mismatchType = matched
? null
: (input.expectedDeltaCategory ??
this.classifyShadowMismatch(legacyOutput, newOutput));
this.recordShadowMismatch('workspace', mismatchType);
return {
matched,
legacy,
current,
mismatchType,
};
}
async shadowSqlDocRead(input: {
userId: string;
workspaceId: string;
docs: Array<{ docId: string }>;
sqlReadableDocIds: string[];
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const rustOutput = this.permission.evaluate(
await this.loader.loadFromNewTables({
userId: input.userId,
workspaceId: input.workspaceId,
docs: input.docs.map(doc => ({
docId: doc.docId,
actions: ['Doc.Read'],
})),
allowLocal: input.allowLocal,
})
);
const rustReadable = new Set(
rustOutput.docs
.filter(doc => doc.decisions[0]?.allowed)
.map(doc => doc.docId)
);
const sqlReadable = new Set(input.sqlReadableDocIds);
const missingInSql = [...rustReadable].filter(id => !sqlReadable.has(id));
const extraInSql = [...sqlReadable].filter(id => !rustReadable.has(id));
const mismatchType =
missingInSql.length || extraInSql.length
? (input.expectedDeltaCategory ?? 'sql_predicate')
: null;
this.recordShadowMismatch('sql_predicate', mismatchType);
return {
matched: mismatchType === null,
predicate: this.sqlPredicate.docReadableByNewTables({
workspaceId: input.workspaceId,
userId: input.userId,
action: 'Doc.Read',
}),
rustReadableDocIds: [...rustReadable],
sqlReadableDocIds: [...sqlReadable],
missingInSql,
extraInSql,
mismatchType,
};
}
async shadowPreviewDoc(input: {
userId?: string;
workspaceId: string;
docId: string;
allowLocal?: boolean;
}) {
const result = await this.shadowDocPermissions({
...input,
docs: [{ docId: input.docId, actions: ['Doc.Preview', 'Doc.Read'] }],
});
const legacy = result.legacy[0];
const current = result.current[0];
const legacyPreviewAllowed = legacy?.decisions.find(
decision => decision.action === 'Doc.Preview'
)?.allowed;
const legacyReadAllowed = legacy?.decisions.find(
decision => decision.action === 'Doc.Read'
)?.allowed;
const previewAllowed = current?.decisions.find(
decision => decision.action === 'Doc.Preview'
)?.allowed;
const readAllowed = current?.decisions.find(
decision => decision.action === 'Doc.Read'
)?.allowed;
const mismatchType =
legacyPreviewAllowed !== previewAllowed ||
(previewAllowed && readAllowed && !legacyReadAllowed)
? 'preview_read_mapping'
: result.mismatchType;
this.recordShadowMismatch('preview', mismatchType);
return {
...result,
matched: result.matched && mismatchType === null,
mismatchType,
};
}
async shadowPreviewWorkspace(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
}) {
const result = await this.shadowWorkspacePermissions({
...input,
actions: ['Workspace.Preview', 'Workspace.Read'],
});
const legacyPreviewAllowed = result.legacy.decisions.find(
decision => decision.action === 'Workspace.Preview'
)?.allowed;
const legacyReadAllowed = result.legacy.decisions.find(
decision => decision.action === 'Workspace.Read'
)?.allowed;
const previewAllowed = result.current.decisions.find(
decision => decision.action === 'Workspace.Preview'
)?.allowed;
const readAllowed = result.current.decisions.find(
decision => decision.action === 'Workspace.Read'
)?.allowed;
const mismatchType =
legacyPreviewAllowed !== previewAllowed ||
(previewAllowed && readAllowed && !legacyReadAllowed)
? 'preview_read_mapping'
: result.mismatchType;
this.recordShadowMismatch('preview', mismatchType);
return {
...result,
matched: result.matched && mismatchType === null,
mismatchType,
};
}
private classifyShadowMismatch(
legacyOutput: PermissionEvaluationOutputV1,
newOutput: PermissionEvaluationOutputV1
) {
if (JSON.stringify(legacyOutput) === JSON.stringify(newOutput)) {
return null;
}
const legacyRestrictions =
JSON.stringify(legacyOutput).includes('runtime_');
const newRestrictions = JSON.stringify(newOutput).includes('runtime_');
if (legacyRestrictions || newRestrictions) {
return 'runtime_state';
}
if (legacyOutput.docs.length !== newOutput.docs.length) {
return 'loader';
}
if (JSON.stringify(legacyOutput.docs) !== JSON.stringify(newOutput.docs)) {
return 'rust_rule';
}
return 'projection';
}
private classifyDocShadowMismatch(
legacy: Array<
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
>,
current: Array<
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
>
) {
if (JSON.stringify(legacy) === JSON.stringify(current)) {
return null;
}
const legacyApi = legacy.map(doc => ({
effectiveRole: doc.effectiveRole,
legacyApiRole: doc.legacyApiRole,
resourceOwnerRole: doc.resourceOwnerRole,
}));
const currentApi = current.map(doc => ({
effectiveRole: doc.effectiveRole,
legacyApiRole: doc.legacyApiRole,
resourceOwnerRole: doc.resourceOwnerRole,
}));
if (JSON.stringify(legacyApi) !== JSON.stringify(currentApi)) {
return 'legacy_api_role_mapping';
}
if (
JSON.stringify(legacy).includes('runtime_') ||
JSON.stringify(current).includes('runtime_')
) {
return 'runtime_state';
}
if (legacy.length !== current.length) {
return 'loader';
}
const legacyDecisions = legacy.map(doc => doc.decisions);
const currentDecisions = current.map(doc => doc.decisions);
if (JSON.stringify(legacyDecisions) !== JSON.stringify(currentDecisions)) {
return 'rust_rule';
}
return 'projection';
}
private recordShadowMismatch(
scope: string,
category: PermissionShadowMismatchCategory | null
) {
if (!category) {
return;
}
metrics.permission
.counter('shadow_mismatches', {
description: 'Permission shadow-read mismatch count',
})
.add(1, { scope, category });
}
}
@@ -1,75 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DocActionDenied } from '../../base';
import { AccessController, getAccessController } from './controller';
import { WorkspacePolicyService } from './policy';
import type { Resource } from './resource';
import {
DocAction,
docActionRequiredRole,
DocRole,
mapDocRoleToPermissions,
} from './types';
import { WorkspaceAccessController } from './workspace';
@Injectable()
export class DocAccessController extends AccessController<'doc'> {
protected readonly type = 'doc';
constructor(private readonly policy: WorkspacePolicyService) {
super();
}
async role(resource: Resource<'doc'>) {
const role = await this.getRole(resource);
const permissions = await this.policy.applyDocPermissions(
resource.workspaceId,
mapDocRoleToPermissions(role)
);
const sharingAllowed = await this.policy.canPublishDoc(
resource.workspaceId
);
if (!sharingAllowed) {
permissions['Doc.Publish'] = false;
}
return { role, permissions };
}
async can(resource: Resource<'doc'>, action: DocAction) {
const { permissions, role } = await this.role(resource);
const allow = permissions[action] || false;
if (!allow) {
this.logger.debug('Doc access check failed', {
action,
resource,
role,
requiredRole: docActionRequiredRole(action),
});
}
return allow;
}
async assert(resource: Resource<'doc'>, action: DocAction) {
const allow = await this.can(resource, action);
if (!allow) {
throw new DocActionDenied({
docId: resource.docId,
spaceId: resource.workspaceId,
action,
});
}
}
async getRole(payload: Resource<'doc'>): Promise<DocRole | null> {
const workspaceController = getAccessController(
'ws'
) as WorkspaceAccessController;
const docRoles = await workspaceController.getDocRoles(payload, [
payload.docId,
]);
return docRoles[0];
}
}
@@ -1,27 +1,54 @@
import './config';
import { Module } from '@nestjs/common';
import { QuotaServiceModule } from '../quota/service.module';
import { AccessControllerBuilder } from './builder';
import { DocAccessController } from './doc';
import { PermissionContextLoader } from './context-loader';
import { PermissionDiagnosticService } from './diagnostic';
import { EventsListener } from './event';
import { WorkspacePolicyService } from './policy';
import { WorkspaceAccessController } from './workspace';
import { PermissionProjectionChecker } from './projection-checker';
import { PermissionService } from './service';
import { PermissionSqlPredicateBuilder } from './sql-predicate';
@Module({
imports: [QuotaServiceModule],
providers: [
WorkspaceAccessController,
DocAccessController,
AccessControllerBuilder,
EventsListener,
WorkspacePolicyService,
PermissionProjectionChecker,
PermissionSqlPredicateBuilder,
PermissionContextLoader,
PermissionDiagnosticService,
PermissionService,
],
exports: [
AccessControllerBuilder,
WorkspacePolicyService,
PermissionProjectionChecker,
PermissionSqlPredicateBuilder,
PermissionDiagnosticService,
PermissionService,
],
exports: [AccessControllerBuilder, WorkspacePolicyService],
})
export class PermissionModule {}
export { AccessControllerBuilder as AccessController } from './builder';
export { AccessControllerBuilder as PermissionAccess } from './builder';
export { PermissionContextLoader } from './context-loader';
export {
PERMISSION_SHADOW_MISMATCH_CATEGORIES,
PermissionDiagnosticService,
} from './diagnostic';
export {
type DotToUnderline,
mapPermissionsToGraphqlPermissions,
} from './permission-map';
export { WorkspacePolicyService } from './policy';
export { PermissionProjectionChecker } from './projection-checker';
export { PermissionService } from './service';
export { PermissionSqlPredicateBuilder } from './sql-predicate';
export {
DOC_ACTIONS,
type DocAction,
@@ -0,0 +1,15 @@
export type DotToUnderline<T extends string> =
T extends `${infer Prefix}.${infer Suffix}`
? `${Prefix}_${DotToUnderline<Suffix>}`
: T;
export function mapPermissionsToGraphqlPermissions<A extends string>(
permission: Record<A, boolean>
): Record<DotToUnderline<A>, boolean> {
return Object.fromEntries(
Object.entries(permission).map(([key, value]) => [
key.replaceAll('.', '_'),
value,
])
) as Record<DotToUnderline<A>, boolean>;
}
@@ -1,29 +1,15 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import {
DocActionDenied,
OnEvent,
OwnerCanNotLeaveWorkspace,
SpaceAccessDenied,
} from '../../base';
import { OnEvent } from '../../base';
import { Models, WorkspaceRole } from '../../models';
import { QuotaService } from '../quota/service';
import { getAccessController } from './controller';
import type { Resource } from './resource';
import {
type DocAction,
type DocActionPermissions,
mapWorkspaceRoleToPermissions,
type WorkspaceAction,
type WorkspaceActionPermissions,
} from './types';
import { QuotaStateService } from '../quota/state';
export type WorkspaceReadonlyReason = 'member_overflow' | 'storage_overflow';
type WorkspaceQuotaSnapshot = Awaited<
ReturnType<QuotaService['getWorkspaceQuotaWithUsage']>
ReturnType<QuotaStateService['reconcileWorkspaceQuotaState']>
> & {
ownerQuota?: string;
readonlyReasons: WorkspaceReadonlyReason[];
};
export type WorkspaceState = {
@@ -35,35 +21,6 @@ export type WorkspaceState = {
usesFallbackOwnerQuota: boolean;
};
const READONLY_WORKSPACE_ACTIONS: WorkspaceAction[] = [
'Workspace.CreateDoc',
'Workspace.Settings.Update',
'Workspace.Properties.Create',
'Workspace.Properties.Update',
'Workspace.Properties.Delete',
'Workspace.Blobs.Write',
];
const READONLY_DOC_ACTIONS: DocAction[] = [
'Doc.Update',
'Doc.Duplicate',
'Doc.Publish',
'Doc.Comments.Create',
'Doc.Comments.Update',
'Doc.Comments.Resolve',
];
const READONLY_WORKSPACE_FEATURE =
'quota_exceeded_readonly_workspace_v1' as const;
type WorkspaceRoleChecker = {
getRole(resource: Resource<'ws'>): Promise<WorkspaceRole | null>;
docRoles(
resource: Resource<'ws'>,
docIds: string[]
): Promise<Array<{ role: unknown; permissions: Record<DocAction, boolean> }>>;
};
declare global {
interface Events {
'workspace.blobs.updated': {
@@ -76,39 +33,23 @@ declare global {
export class WorkspacePolicyService {
constructor(
private readonly models: Models,
private readonly quota: QuotaService
private readonly quotaState: QuotaStateService
) {}
async getWorkspaceState(workspaceId: string): Promise<WorkspaceState> {
const [isTeamWorkspace, isUnlimitedWorkspace, quota] = await Promise.all([
this.models.workspace.isTeamWorkspace(workspaceId),
this.models.workspaceFeature.has(workspaceId, 'unlimited_workspace'),
this.quota.getWorkspaceQuotaWithUsage(workspaceId),
]);
const quota =
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
const quotaSnapshot = quota as WorkspaceQuotaSnapshot;
const readonlyReasons: WorkspaceReadonlyReason[] = [];
const usesFallbackOwnerQuota =
!!quotaSnapshot.ownerQuota && !isUnlimitedWorkspace;
if (usesFallbackOwnerQuota && quotaSnapshot.overcapacityMemberCount > 0) {
readonlyReasons.push('member_overflow');
}
if (
usesFallbackOwnerQuota &&
quotaSnapshot.usedStorageQuota > quotaSnapshot.storageQuota
) {
readonlyReasons.push('storage_overflow');
}
const readonlyReasons = quotaSnapshot.readonlyReasons;
return {
isTeamWorkspace,
isTeamWorkspace: ['team', 'selfhost_team'].includes(quotaSnapshot.plan),
isReadonly: readonlyReasons.length > 0,
readonlyReasons,
canRecoverByRemovingMembers: readonlyReasons.includes('member_overflow'),
canRecoverByDeletingBlobs: readonlyReasons.includes('storage_overflow'),
usesFallbackOwnerQuota,
usesFallbackOwnerQuota: quotaSnapshot.usesOwnerQuota,
};
}
@@ -126,286 +67,19 @@ export class WorkspacePolicyService {
}
async reconcileWorkspaceQuotaState(workspaceId: string) {
const [state, isReadonlyFeatureEnabled] = await Promise.all([
this.getWorkspaceState(workspaceId),
this.models.workspaceFeature.has(workspaceId, READONLY_WORKSPACE_FEATURE),
]);
if (state.isReadonly && !isReadonlyFeatureEnabled) {
await this.models.workspaceFeature.add(
workspaceId,
READONLY_WORKSPACE_FEATURE,
`workspace recovery mode: ${state.readonlyReasons.join(',')}`
);
} else if (!state.isReadonly && isReadonlyFeatureEnabled) {
await this.models.workspaceFeature.remove(
workspaceId,
READONLY_WORKSPACE_FEATURE
);
}
return state;
return await this.getWorkspaceState(workspaceId);
}
async isWorkspaceReadonly(workspaceId: string) {
const hasReadonlyFeature = await this.models.workspaceFeature.has(
workspaceId,
READONLY_WORKSPACE_FEATURE
);
if (!hasReadonlyFeature) {
return false;
}
const state = await this.getWorkspaceState(workspaceId);
if (!state.isReadonly) {
await this.models.workspaceFeature.remove(
workspaceId,
READONLY_WORKSPACE_FEATURE
);
return false;
}
return true;
}
async isSharingEnabled(workspaceId: string) {
return await this.models.workspace.allowSharing(workspaceId);
}
async canReadWorkspaceByPublicFlag(workspaceId: string) {
const workspace = await this.models.workspace.get(workspaceId);
return !!workspace?.public && (workspace.enableSharing ?? true);
}
async canReadWorkspaceBySharedDocs(workspaceId: string) {
const [sharingEnabled, hasPublicDocs] = await Promise.all([
this.isSharingEnabled(workspaceId),
this.models.doc.hasPublic(workspaceId),
]);
return sharingEnabled && hasPublicDocs;
}
async canReadSharedDoc(workspaceId: string, docId: string) {
const [sharingEnabled, isPublicDoc] = await Promise.all([
this.isSharingEnabled(workspaceId),
this.models.doc.isPublic(workspaceId, docId),
]);
return sharingEnabled && isPublicDoc;
}
async canPreviewDoc(workspaceId: string, docId: string) {
const [sharingEnabled, canReadSharedDoc, allowUrlPreview] =
await Promise.all([
this.isSharingEnabled(workspaceId),
this.canReadSharedDoc(workspaceId, docId),
this.models.workspace.allowUrlPreview(workspaceId),
]);
return sharingEnabled && (canReadSharedDoc || allowUrlPreview);
}
async canPreviewWorkspace(workspaceId: string) {
const [sharingEnabled, allowUrlPreview] = await Promise.all([
this.isSharingEnabled(workspaceId),
this.models.workspace.allowUrlPreview(workspaceId),
]);
return sharingEnabled && allowUrlPreview;
}
async canPublishDoc(workspaceId: string) {
return await this.isSharingEnabled(workspaceId);
}
async applyWorkspacePermissions(
workspaceId: string,
permissions: WorkspaceActionPermissions
) {
if (!(await this.isWorkspaceReadonly(workspaceId))) {
return permissions;
}
const next = { ...permissions };
READONLY_WORKSPACE_ACTIONS.forEach(action => {
next[action] = false;
});
return next;
}
async applyDocPermissions(
workspaceId: string,
permissions: DocActionPermissions
) {
if (!(await this.isWorkspaceReadonly(workspaceId))) {
return permissions;
}
const next = { ...permissions };
READONLY_DOC_ACTIONS.forEach(action => {
next[action] = false;
});
return next;
}
async assertWorkspaceActionAllowed(
workspaceId: string,
action: WorkspaceAction
) {
if (
READONLY_WORKSPACE_ACTIONS.includes(action) &&
(await this.isWorkspaceReadonly(workspaceId))
) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
}
async assertDocActionAllowed(
workspaceId: string,
docId: string,
action: DocAction
) {
if (
READONLY_DOC_ACTIONS.includes(action) &&
(await this.isWorkspaceReadonly(workspaceId))
) {
throw new DocActionDenied({
action,
docId,
spaceId: workspaceId,
});
}
}
async assertWorkspaceRoleAction(
userId: string,
workspaceId: string,
action: WorkspaceAction
) {
const checker = getAccessController(
'ws'
) as unknown as WorkspaceRoleChecker;
const role = await checker.getRole({ userId, workspaceId });
const permissions = mapWorkspaceRoleToPermissions(role);
if (!permissions[action]) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
}
async assertDocRoleAction(
userId: string,
workspaceId: string,
docId: string,
action: DocAction
) {
const checker = getAccessController(
'ws'
) as unknown as WorkspaceRoleChecker;
const [role] = await checker.docRoles({ userId, workspaceId }, [docId]);
if (!role?.permissions[action]) {
throw new DocActionDenied({
action,
docId,
spaceId: workspaceId,
});
}
}
async assertCanUploadBlob(userId: string, workspaceId: string) {
await this.assertWorkspaceRoleAction(
userId,
workspaceId,
'Workspace.Blobs.Write'
);
await this.assertWorkspaceActionAllowed(
workspaceId,
'Workspace.Blobs.Write'
);
}
async assertCanDeleteBlob(userId: string, workspaceId: string) {
await this.assertWorkspaceRoleAction(
userId,
workspaceId,
'Workspace.Blobs.Write'
);
}
async assertCanInviteMembers(workspaceId: string) {
if (await this.isWorkspaceReadonly(workspaceId)) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
}
async assertCanRevokeMember(
userId: string,
workspaceId: string,
role: WorkspaceRole
) {
await this.assertWorkspaceRoleAction(
userId,
workspaceId,
role === WorkspaceRole.Admin
? 'Workspace.Administrators.Manage'
: 'Workspace.Users.Manage'
);
}
@Transactional()
async handleTeamPlanCanceled(workspaceId: string) {
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId);
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
await this.cleanupTeamPlanCanceled(workspaceId);
return await this.reconcileWorkspaceQuotaState(workspaceId);
}
async assertCanUnpublishDoc(
userId: string,
workspaceId: string,
docId: string
) {
await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish');
}
async assertCanPublishDoc(
userId: string,
workspaceId: string,
docId: string
) {
await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish');
await this.assertDocActionAllowed(workspaceId, docId, 'Doc.Publish');
if (!(await this.canPublishDoc(workspaceId))) {
throw new DocActionDenied({
action: 'Doc.Publish',
docId,
spaceId: workspaceId,
});
}
}
async assertCanManageInviteLink(userId: string, workspaceId: string) {
await this.assertWorkspaceRoleAction(
userId,
workspaceId,
'Workspace.Users.Manage'
);
}
async assertCanLeaveWorkspace(userId: string, workspaceId: string) {
const role = await this.models.workspaceUser.getActive(workspaceId, userId);
if (!role) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
if (role.type === WorkspaceRole.Owner) {
throw new OwnerCanNotLeaveWorkspace();
}
@Transactional()
private async cleanupTeamPlanCanceled(workspaceId: string) {
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId);
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
}
@OnEvent('workspace.members.updated')
@@ -0,0 +1,166 @@
import { Injectable, Optional } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Models } from '../../models';
import {
PermissionContextLoader,
type PermissionDocAction,
type PermissionWorkspaceAction,
} from './context-loader';
import { PermissionService } from './service';
type ProjectionDecisionSample = {
category: string;
workspaceId: string;
docId: string | null;
userId: string | null;
workspaceActions: string[] | null;
docActions: string[] | null;
};
@Injectable()
export class PermissionProjectionChecker {
constructor(
private readonly db: PrismaClient,
private readonly models: Models,
@Optional()
private readonly loader?: PermissionContextLoader,
@Optional()
private readonly permission?: PermissionService
) {}
async checkLegacyProjection() {
const report =
await this.models.permissionProjection.checkLegacyProjection();
return {
...report,
oldNewDecisionMismatch: await this.checkOldNewLoaderDecisionMismatch(),
};
}
private async checkOldNewLoaderDecisionMismatch() {
const { loader, permission } = this;
if (!loader || !permission) {
return 0;
}
const samples = await this.db.$queryRaw<ProjectionDecisionSample[]>`
(
SELECT
'active_member_doc' AS category,
old_member.workspace_id AS "workspaceId",
old_doc.page_id AS "docId",
old_member.user_id AS "userId",
NULL::text[] AS "workspaceActions",
ARRAY['Doc.Read', 'Doc.Preview']::text[] AS "docActions"
FROM workspace_user_permissions old_member
INNER JOIN workspace_pages old_doc
ON old_doc.workspace_id = old_member.workspace_id
WHERE old_member.status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(old_member.type) IS NOT NULL
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
ORDER BY md5(old_member.workspace_id || ':' || old_doc.page_id || ':' || old_member.user_id)
LIMIT 80
)
UNION ALL
(
SELECT
'workspace_invitation' AS category,
old_member.workspace_id AS "workspaceId",
NULL::text AS "docId",
old_member.user_id AS "userId",
ARRAY['Workspace.Read']::text[] AS "workspaceActions",
NULL::text[] AS "docActions"
FROM workspace_user_permissions old_member
WHERE old_member.status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(old_member.status) IS NOT NULL
AND affine_permission_legacy_workspace_role(old_member.type) IS NOT NULL
ORDER BY md5(old_member.workspace_id || ':' || old_member.user_id)
LIMIT 40
)
UNION ALL
(
SELECT
'public_doc_anonymous' AS category,
old_doc.workspace_id AS "workspaceId",
old_doc.page_id AS "docId",
NULL::text AS "userId",
NULL::text[] AS "workspaceActions",
ARRAY['Doc.Read', 'Doc.Preview']::text[] AS "docActions"
FROM workspace_pages old_doc
WHERE old_doc.public
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
ORDER BY md5(old_doc.workspace_id || ':' || old_doc.page_id)
LIMIT 40
)
UNION ALL
(
SELECT
'workspace_url_preview_private_doc' AS category,
old_doc.workspace_id AS "workspaceId",
old_doc.page_id AS "docId",
NULL::text AS "userId",
NULL::text[] AS "workspaceActions",
ARRAY['Doc.Preview', 'Doc.Read']::text[] AS "docActions"
FROM workspace_pages old_doc
INNER JOIN workspaces old_workspace
ON old_workspace.id = old_doc.workspace_id
WHERE old_workspace.enable_sharing
AND old_workspace.enable_url_preview
AND NOT old_doc.public
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
ORDER BY md5(old_doc.workspace_id || ':' || old_doc.page_id)
LIMIT 40
)
UNION ALL
(
SELECT
'explicit_doc_grant' AS category,
old_grant.workspace_id AS "workspaceId",
old_grant.page_id AS "docId",
old_grant.user_id AS "userId",
NULL::text[] AS "workspaceActions",
ARRAY['Doc.Read', 'Doc.Update', 'Doc.Users.Manage', 'Doc.TransferOwner']::text[] AS "docActions"
FROM workspace_page_user_permissions old_grant
WHERE affine_permission_legacy_doc_role(old_grant.type) IS NOT NULL
ORDER BY md5(old_grant.workspace_id || ':' || old_grant.page_id || ':' || old_grant.user_id)
LIMIT 80
)
`;
let mismatches = 0;
for (const sample of samples) {
const input = {
userId: sample.userId ?? undefined,
workspaceId: sample.workspaceId,
workspaceActions: sample.workspaceActions as
| PermissionWorkspaceAction[]
| undefined,
docs:
sample.docId && sample.docActions
? [
{
docId: sample.docId,
actions: sample.docActions as PermissionDocAction[],
},
]
: undefined,
};
const [legacy, projection] = await Promise.all([
loader.load(input).then(input => permission.evaluate(input)),
loader
.loadFromNewTables(input)
.then(input => permission.evaluate(input)),
]);
if (
JSON.stringify(legacy.workspace) !==
JSON.stringify(projection.workspace) ||
JSON.stringify(legacy.docs) !== JSON.stringify(projection.docs)
) {
mismatches += 1;
}
}
return mismatches;
}
}
@@ -0,0 +1,317 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
Config,
DocActionDenied,
InternalServerError,
metrics,
SpaceAccessDenied,
} from '../../base';
import {
evaluatePermissionV1,
type PermissionEvaluationInputV1,
type PermissionEvaluationOutputV1,
} from '../../native';
import { PermissionReadModel } from './config';
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
import {
PermissionContextLoader,
type PermissionDocAction,
type PermissionWorkspaceAction,
} from './context-loader';
import { WorkspacePolicyService } from './policy';
import { PermissionSqlPredicateBuilder } from './sql-predicate';
import type { DocAction } from './types';
const RUNTIME_RESTRICTED_WORKSPACE_ACTIONS = new Set<PermissionWorkspaceAction>(
[
'Workspace.Sync',
'Workspace.CreateDoc',
'Workspace.Delete',
'Workspace.TransferOwner',
'Workspace.Users.Manage',
'Workspace.Administrators.Manage',
'Workspace.Settings.Update',
'Workspace.Properties.Create',
'Workspace.Properties.Update',
'Workspace.Properties.Delete',
'Workspace.Blobs.Write',
'Workspace.Payment.Manage',
]
);
const RUNTIME_RESTRICTED_DOC_ACTIONS = new Set<PermissionDocAction>([
'Doc.Duplicate',
'Doc.Trash',
'Doc.Restore',
'Doc.Delete',
'Doc.Update',
'Doc.Publish',
'Doc.TransferOwner',
'Doc.Properties.Update',
'Doc.Users.Manage',
'Doc.Comments.Create',
'Doc.Comments.Update',
'Doc.Comments.Delete',
'Doc.Comments.Resolve',
]);
@Injectable()
export class PermissionService {
constructor(
private readonly loader: PermissionContextLoader,
@Optional()
@Inject(PermissionSqlPredicateBuilder)
private readonly sqlPredicate = new PermissionSqlPredicateBuilder(),
@Optional()
private readonly workspacePolicy?: WorkspacePolicyService,
@Optional()
private readonly config?: Config
) {}
readModel() {
return this.config?.permission.readModel ?? PermissionReadModel.Projection;
}
docReadableSqlPredicate(input: {
userId: string;
workspaceId: string;
action: DocAction;
docIdColumn?: Prisma.Sql;
}) {
if (this.readModel() === PermissionReadModel.Projection) {
return this.sqlPredicate.docReadableByNewTablesSql(input);
}
return this.sqlPredicate.docReadableByLegacyTablesSql(input);
}
fallbackDocReadableSqlPredicate(input: {
userId: string;
workspaceId: string;
action: DocAction;
docIdColumn?: Prisma.Sql;
}) {
if (
this.readModel() === PermissionReadModel.Projection &&
(this.config?.permission.fallbackLegacyLoader ?? false)
) {
return this.sqlPredicate.docReadableByLegacyTablesSql(input);
}
return null;
}
evaluate(input: PermissionEvaluationInputV1) {
try {
return evaluatePermissionV1(input);
} catch (error) {
throw new InternalServerError(
error instanceof Error ? error.message : undefined
);
}
}
async workspacePermissions(input: {
userId?: string;
workspaceId: string;
actions: PermissionWorkspaceAction[];
allowLocal?: boolean;
}) {
const output = await this.evaluateLoaded({
userId: input.userId,
workspaceId: input.workspaceId,
workspaceActions: input.actions,
allowLocal: input.allowLocal,
});
return {
...workspaceLegacyBoundary(output.workspace),
decisions: output.workspace.decisions,
};
}
async canWorkspace(input: {
userId?: string;
workspaceId: string;
action: PermissionWorkspaceAction;
allowLocal?: boolean;
}) {
const output = await this.workspacePermissions({
...input,
actions: [input.action],
});
return output.decisions[0]?.allowed ?? false;
}
async assertWorkspace(input: {
userId?: string;
workspaceId: string;
action: PermissionWorkspaceAction;
allowLocal?: boolean;
}) {
if (!(await this.canWorkspace(input))) {
throw new SpaceAccessDenied({ spaceId: input.workspaceId });
}
}
async docPermissions(input: {
userId?: string;
workspaceId: string;
docId: string;
actions: PermissionDocAction[];
allowLocal?: boolean;
}) {
const output = await this.evaluateLoaded({
userId: input.userId,
workspaceId: input.workspaceId,
docs: [{ docId: input.docId, actions: input.actions }],
allowLocal: input.allowLocal,
});
const doc = output.docs[0];
return {
...docLegacyBoundary(doc),
decisions: doc.decisions,
};
}
async canDoc(input: {
userId?: string;
workspaceId: string;
docId: string;
action: PermissionDocAction;
allowLocal?: boolean;
}) {
const output = await this.docPermissions({
...input,
actions: [input.action],
});
return output.decisions[0]?.allowed ?? false;
}
async assertDoc(input: {
userId?: string;
workspaceId: string;
docId: string;
action: PermissionDocAction;
allowLocal?: boolean;
}) {
if (!(await this.canDoc(input))) {
throw new DocActionDenied({
action: input.action,
docId: input.docId,
spaceId: input.workspaceId,
});
}
}
async filterReadableDocs<T extends { docId: string }>(input: {
userId?: string;
workspaceId: string;
docs: T[];
allowLocal?: boolean;
}) {
const decisions = await this.batchDocPermissions({
...input,
docs: input.docs.map(doc => ({
docId: doc.docId,
actions: ['Doc.Read'],
})),
});
const readableDocIds = new Set(
decisions.filter(doc => doc.decisions[0]?.allowed).map(doc => doc.docId)
);
return input.docs.filter(doc => readableDocIds.has(doc.docId));
}
async batchDocPermissions(input: {
userId?: string;
workspaceId: string;
docs: Array<{ docId: string; actions: PermissionDocAction[] }>;
allowLocal?: boolean;
}) {
const output = await this.evaluateLoaded(input);
return output.docs.map(doc => ({
docId: doc.docId,
...docLegacyBoundary(doc),
decisions: doc.decisions,
}));
}
async canPreviewWorkspace(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
}) {
return await this.canWorkspace({
...input,
action: 'Workspace.Preview',
});
}
async canPreviewDoc(input: {
userId?: string;
workspaceId: string;
docId: string;
allowLocal?: boolean;
}) {
return await this.canDoc({
...input,
action: 'Doc.Preview',
});
}
private async evaluateLoaded(
input: Parameters<PermissionContextLoader['load']>[0]
) {
if (this.readModel() === PermissionReadModel.Projection) {
try {
if (
this.needsFreshRuntimeState(input) &&
(await this.loader.workspaceExists(input.workspaceId))
) {
await this.workspacePolicy?.getWorkspaceState(input.workspaceId);
this.loader.invalidateWorkspaceQuotaRuntime(input.workspaceId);
}
return this.evaluate(await this.loader.loadFromNewTables(input));
} catch (error) {
if (
input.allowLocal &&
error instanceof Error &&
error.message === 'Workspace owner not found'
) {
const loaded = await this.loader.loadFromNewTables(input);
if (loaded.workspace?.local) {
return this.evaluate(loaded);
}
}
if (!(this.config?.permission.fallbackLegacyLoader ?? false)) {
throw error;
}
metrics.permission
.counter('projection_loader_fallbacks', {
description: 'Permission projection loader fallback count',
})
.add(1);
}
}
return this.evaluate(await this.loader.load(input));
}
private needsFreshRuntimeState(
input: Parameters<PermissionContextLoader['load']>[0]
) {
return (
input.workspaceActions?.some(action =>
RUNTIME_RESTRICTED_WORKSPACE_ACTIONS.has(action)
) ||
input.docs?.some(doc =>
doc.actions.some(action => RUNTIME_RESTRICTED_DOC_ACTIONS.has(action))
) ||
false
);
}
}
export type PermissionServiceEvaluationOutput = PermissionEvaluationOutputV1;
@@ -0,0 +1,306 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { permissionActionRoleMatrixV1 } from '../../native';
import { type DocAction, DocRole, WorkspaceRole } from './types';
export type PermissionSqlPredicate = {
sql: string;
params: unknown[];
};
type RawDocIdColumn = 'doc_id' | 'docs.id';
@Injectable()
export class PermissionSqlPredicateBuilder {
private readonly matrix = permissionActionRoleMatrixV1() as {
doc?: { roles?: Record<string, string[]> };
workspace?: { roles?: Record<string, string[]> };
};
private readonly legacyDocRoleValues = new Map<string, DocRole>([
['external', DocRole.External],
['reader', DocRole.Reader],
['commenter', DocRole.Commenter],
['editor', DocRole.Editor],
['manager', DocRole.Manager],
['owner', DocRole.Owner],
]);
private readonly legacyWorkspaceRoleValues = new Map<string, number>([
['member', WorkspaceRole.Collaborator],
['admin', WorkspaceRole.Admin],
['owner', WorkspaceRole.Owner],
]);
private docRolesForAction(action: DocAction) {
return Object.entries(this.matrix.doc?.roles ?? {})
.filter(([, actions]) => actions.includes(action))
.map(([role]) => role)
.filter(role => role !== 'none');
}
private inheritedWorkspaceRolesForDocAction(action: DocAction) {
const docRoles = new Set(this.docRolesForAction(action));
return [
docRoles.has('owner') ? 'owner' : null,
docRoles.has('manager') ? 'admin' : null,
].filter((role): role is string => role !== null);
}
private nonMemberDocGrantRolesForAction(action: DocAction) {
const roles = new Set(this.docRolesForAction(action));
roles.delete('external');
roles.delete('manager');
roles.delete('owner');
if (roles.has('editor')) {
roles.add('manager');
roles.add('owner');
}
return [...roles];
}
private legacyNonMemberDocGrantRolesForAction(action: DocAction) {
return this.nonMemberDocGrantRolesForAction(action)
.map(role => this.legacyDocRoleValues.get(role))
.filter(role => role !== undefined);
}
private rawDocIdColumn(column: RawDocIdColumn = 'doc_id') {
switch (column) {
case 'doc_id':
case 'docs.id':
return column;
default:
throw new Error(`Unsupported doc id column: ${column}`);
}
}
docReadableByLegacyTables(input: {
workspaceId: string;
userId: string;
action: DocAction;
docIdColumn?: RawDocIdColumn;
}): PermissionSqlPredicate {
const roles = this.docRolesForAction(input.action)
.map(role => this.legacyDocRoleValues.get(role))
.filter(role => role !== undefined);
const grantRoles = roles.filter(role => role !== DocRole.External);
const nonMemberGrantRoles = this.legacyNonMemberDocGrantRolesForAction(
input.action
);
const legacyActiveMemberRoles = [
WorkspaceRole.Collaborator,
WorkspaceRole.Admin,
WorkspaceRole.Owner,
];
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
input.action
)
.map(role => this.legacyWorkspaceRoleValues.get(role))
.filter(role => role !== undefined);
const docIdColumn = this.rawDocIdColumn(input.docIdColumn);
return {
sql: [
`EXISTS (SELECT 1 FROM workspaces w`,
`LEFT JOIN workspace_pages wp ON wp.workspace_id = w.id`,
`AND wp.page_id = ${docIdColumn}`,
`LEFT JOIN workspace_user_permissions wup ON wup.workspace_id = w.id`,
`AND wup.user_id = ? AND wup.status = 'Accepted'`,
`LEFT JOIN workspace_page_user_permissions p ON p.workspace_id = w.id`,
`AND p.user_id = ? AND p.page_id = ${docIdColumn}`,
`WHERE w.id = ? AND (`,
`(wup.type = ANY(?::smallint[]) AND p.type = ANY(?::smallint[]))`,
`OR ((wup.id IS NULL OR wup.type <> ALL(?::smallint[])) AND w.enable_sharing AND p.type = ANY(?::smallint[]))`,
`OR wup.type = ANY(?::smallint[])`,
`OR (wup.type = ANY(?::smallint[]) AND (p.user_id IS NULL OR p.type IN (?, ?))`,
`AND COALESCE(wp."defaultRole", 30) = ANY(?::smallint[]))`,
`OR (w.enable_sharing AND wp.public AND ? = ANY(?::smallint[]))`,
`))`,
].join(' '),
params: [
input.userId,
input.userId,
input.workspaceId,
legacyActiveMemberRoles,
grantRoles,
legacyActiveMemberRoles,
nonMemberGrantRoles,
inheritedWorkspaceRoles,
legacyActiveMemberRoles,
DocRole.None,
DocRole.External,
grantRoles,
DocRole.External,
roles,
],
};
}
docReadableByLegacyTablesSql(input: {
workspaceId: string;
userId: string;
action: DocAction;
docIdColumn?: Prisma.Sql;
}): Prisma.Sql {
const docRoles = this.docRolesForAction(input.action);
const legacyDocRoles = docRoles
.map(role => this.legacyDocRoleValues.get(role))
.filter(role => role !== undefined);
const legacyGrantRoles = legacyDocRoles.filter(
role => role !== DocRole.External
);
const legacyNonMemberGrantRoles =
this.legacyNonMemberDocGrantRolesForAction(input.action);
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
input.action
)
.map(role => this.legacyWorkspaceRoleValues.get(role))
.filter(role => role !== undefined);
const legacyActiveMemberRoles = [
WorkspaceRole.Collaborator,
WorkspaceRole.Admin,
WorkspaceRole.Owner,
];
const docIdColumn = input.docIdColumn ?? Prisma.raw('doc_id');
return Prisma.sql`
EXISTS (
SELECT 1
FROM workspaces w
LEFT JOIN workspace_pages wp
ON wp.workspace_id = w.id
AND wp.page_id = ${docIdColumn}
LEFT JOIN workspace_user_permissions wup
ON wup.workspace_id = w.id
AND wup.user_id = ${input.userId}
AND wup.status = 'Accepted'::"WorkspaceMemberStatus"
LEFT JOIN workspace_page_user_permissions p
ON p.workspace_id = w.id
AND p.page_id = ${docIdColumn}
AND p.user_id = ${input.userId}
WHERE w.id = ${input.workspaceId}
AND (
(
wup.type = ANY(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
AND p.type = ANY(${Prisma.sql`${legacyGrantRoles}::smallint[]`})
)
OR (
(
wup.id IS NULL
OR wup.type <> ALL(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
)
AND w.enable_sharing
AND p.type = ANY(${Prisma.sql`${legacyNonMemberGrantRoles}::smallint[]`})
)
OR wup.type = ANY(${Prisma.sql`${inheritedWorkspaceRoles}::smallint[]`})
OR (
wup.type = ANY(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
AND (
p.user_id IS NULL
OR p.type IN (${DocRole.None}, ${DocRole.External})
)
AND COALESCE(wp."defaultRole", 30) = ANY(${Prisma.sql`${legacyGrantRoles}::smallint[]`})
)
OR (
w.enable_sharing
AND wp.public
AND 0 = ANY(${Prisma.sql`${legacyDocRoles}::smallint[]`})
)
)
)
`;
}
docReadableByNewTables(input: {
workspaceId: string;
userId?: string;
action: DocAction;
docIdColumn?: RawDocIdColumn;
}): PermissionSqlPredicate {
const docRoles = this.docRolesForAction(input.action);
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
input.action
);
const grantRoles = docRoles.filter(role => role !== 'external');
const nonMemberGrantRoles = this.nonMemberDocGrantRolesForAction(
input.action
);
const docIdColumn = this.rawDocIdColumn(input.docIdColumn);
return {
sql: [
`EXISTS (SELECT 1 FROM workspace_access_policies wap`,
`LEFT JOIN doc_access_policies dap ON dap.workspace_id = wap.workspace_id`,
`AND dap.doc_id = ${docIdColumn}`,
`LEFT JOIN workspace_members wm ON wm.workspace_id = wap.workspace_id`,
`AND wm.user_id = ? AND wm.state = 'active'`,
`LEFT JOIN doc_grants dg ON dg.workspace_id = wap.workspace_id`,
`AND dg.doc_id = ${docIdColumn} AND dg.principal_type = 'user' AND dg.principal_id = ?`,
`WHERE wap.workspace_id = ?`,
`AND (`,
`(wm.id IS NOT NULL AND dg.role = ANY(?::text[]))`,
`OR (wm.id IS NULL AND wap.sharing_enabled AND dg.role = ANY(?::text[]))`,
`OR wm.role = ANY(?::text[])`,
`OR (wm.id IS NOT NULL AND dg.principal_id IS NULL AND COALESCE(dap.member_default_role, wap.member_default_doc_role) = ANY(?::text[]))`,
`OR (wap.sharing_enabled AND dap.visibility = 'public' AND dap.public_role = ANY(?::text[]))`,
`))`,
].join(' '),
params: [
input.userId,
input.userId,
input.workspaceId,
grantRoles,
nonMemberGrantRoles,
inheritedWorkspaceRoles,
grantRoles,
docRoles,
],
};
}
docReadableByNewTablesSql(input: {
workspaceId: string;
userId?: string;
action: DocAction;
docIdColumn?: Prisma.Sql;
}): Prisma.Sql {
const docRoles = this.docRolesForAction(input.action);
const grantRoles = docRoles.filter(role => role !== 'external');
const nonMemberGrantRoles = this.nonMemberDocGrantRolesForAction(
input.action
);
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
input.action
);
const docIdColumn = input.docIdColumn ?? Prisma.raw('doc_id');
return Prisma.sql`
EXISTS (
SELECT 1
FROM workspace_access_policies wap
LEFT JOIN doc_access_policies dap
ON dap.workspace_id = wap.workspace_id
AND dap.doc_id = ${docIdColumn}
LEFT JOIN workspace_members wm
ON wm.workspace_id = wap.workspace_id
AND wm.user_id = ${input.userId}
AND wm.state = 'active'
LEFT JOIN doc_grants dg
ON dg.workspace_id = wap.workspace_id
AND dg.doc_id = ${docIdColumn}
AND dg.principal_type = 'user'
AND dg.principal_id = ${input.userId}
WHERE wap.workspace_id = ${input.workspaceId}
AND (
(wm.id IS NOT NULL AND dg.role = ANY(${Prisma.sql`${grantRoles}::text[]`}))
OR (wm.id IS NULL AND wap.sharing_enabled AND dg.role = ANY(${Prisma.sql`${nonMemberGrantRoles}::text[]`}))
OR wm.role = ANY(${Prisma.sql`${inheritedWorkspaceRoles}::text[]`})
OR (wm.id IS NOT NULL AND dg.principal_id IS NULL AND COALESCE(dap.member_default_role, wap.member_default_doc_role) = ANY(${Prisma.sql`${grantRoles}::text[]`}))
OR (wap.sharing_enabled AND dap.visibility = 'public' AND dap.public_role = ANY(${Prisma.sql`${docRoles}::text[]`}))
)
)
`;
}
}
@@ -145,6 +145,7 @@ export const RoleActionsMap = {
Action.Doc.Delete,
Action.Doc.Properties.Update,
Action.Doc.Update,
Action.Doc.Comments.Update,
Action.Doc.Comments.Resolve,
Action.Doc.Comments.Delete,
];
@@ -1,210 +0,0 @@
import { Injectable } from '@nestjs/common';
import { SpaceAccessDenied } from '../../base';
import { DocRole, Models } from '../../models';
import { AccessController } from './controller';
import { WorkspacePolicyService } from './policy';
import type { Resource } from './resource';
import {
fixupDocRole,
mapDocRoleToPermissions,
mapWorkspaceRoleToPermissions,
WorkspaceAction,
workspaceActionRequiredRole,
WorkspaceRole,
} from './types';
@Injectable()
export class WorkspaceAccessController extends AccessController<'ws'> {
protected readonly type = 'ws';
constructor(
private readonly models: Models,
private readonly policy: WorkspacePolicyService
) {
super();
}
async role(resource: Resource<'ws'>) {
const role = await this.getRole(resource);
return {
role,
permissions: await this.policy.applyWorkspacePermissions(
resource.workspaceId,
mapWorkspaceRoleToPermissions(role)
),
};
}
async can(resource: Resource<'ws'>, action: WorkspaceAction) {
const { permissions, role } = await this.role(resource);
const allow = permissions[action] || false;
if (!allow) {
this.logger.debug('Workspace access check failed', {
action,
resource,
role,
requiredRole: workspaceActionRequiredRole(action),
});
}
return allow;
}
async assert(resource: Resource<'ws'>, action: WorkspaceAction) {
const allow = await this.can(resource, action);
if (!allow) {
throw new SpaceAccessDenied({ spaceId: resource.workspaceId });
}
}
async getRole(payload: Resource<'ws'>) {
const userRole = await this.models.workspaceUser.getActive(
payload.workspaceId,
payload.userId
);
let role = userRole?.type as WorkspaceRole | null;
if (!role) {
role = await this.defaultWorkspaceRole(payload);
}
return role;
}
async docRoles(payload: Resource<'ws'>, docIds: string[]) {
const docRoles = await this.getDocRoles(payload, docIds);
return docRoles.map(role => ({
role,
permissions: mapDocRoleToPermissions(role),
}));
}
async getDocRoles(payload: Resource<'ws'>, docIds: string[]) {
const docRoles: (DocRole | null)[] = [];
if (docIds.length === 0) {
return docRoles;
}
const workspaceRole = await this.getRole(payload);
const sharingAllowed = await this.policy.isSharingEnabled(
payload.workspaceId
);
if (
!sharingAllowed &&
(workspaceRole === null || workspaceRole === WorkspaceRole.External)
) {
return docIds.map(() => null);
}
const userRoles = await this.models.docUser.findMany(
payload.workspaceId,
docIds,
payload.userId
);
const userRolesMap = new Map(userRoles.map(role => [role.docId, role]));
const noUserRoleDocIds = docIds.filter(docId => {
const userRole = userRolesMap.get(docId);
return (userRole?.type ?? null) === null;
});
const defaultDocRoles =
noUserRoleDocIds.length > 0
? await this.getDocDefaultRoles(
payload,
noUserRoleDocIds,
workspaceRole
)
: [];
const defaultDocRolesMap = new Map(
defaultDocRoles.map((role, index) => [noUserRoleDocIds[index], role])
);
for (const docId of docIds) {
const userRole = userRolesMap.get(docId);
let docRole: DocRole | null = userRole?.type ?? null;
// fallback logic
if (docRole === null) {
docRole = defaultDocRolesMap.get(docId) ?? null;
}
// we need to fixup doc role to make sure it's not miss set
// for example: workspace owner will have doc owner role
// workspace external will not have role higher than editor
const role = fixupDocRole(workspaceRole, docRole);
// never return [None]
docRoles.push(role === DocRole.None ? null : role);
}
return docRoles;
}
private async getDocDefaultRoles(
payload: Resource<'ws'>,
docIds: string[],
workspaceRole: WorkspaceRole | null
) {
const fallbackDocRoles: (DocRole | null)[] = [];
if (docIds.length === 0) {
return fallbackDocRoles;
}
const defaultDocRoles = await this.models.doc.findDefaultRoles(
payload.workspaceId,
docIds
);
for (const defaultDocRole of defaultDocRoles) {
let docRole: DocRole | null;
// if user is in workspace but doc role is not set, fallback to default doc role
if (workspaceRole !== null && workspaceRole !== WorkspaceRole.External) {
docRole =
defaultDocRole.external !== null
? // edgecase: when doc role set to [None] for workspace member, but doc is public, we should fallback to external role
Math.max(defaultDocRole.workspace, defaultDocRole.external)
: defaultDocRole.workspace;
} else {
// else fallback to external doc role
docRole = defaultDocRole.external;
}
fallbackDocRoles.push(docRole);
}
return fallbackDocRoles;
}
private async defaultWorkspaceRole(payload: Resource<'ws'>) {
const ws = await this.models.workspace.get(payload.workspaceId);
// NOTE(@forehalo):
// we allow user to use online service with local workspace
// so we always return owner role for local workspace
// copilot session for local workspace is an example
if (!ws) {
if (payload.allowLocal) {
return WorkspaceRole.Owner;
}
return null;
}
if (ws.public) {
const sharingAllowed = await this.policy.canReadWorkspaceByPublicFlag(
ws.id
);
return sharingAllowed ? WorkspaceRole.External : null;
}
return null;
}
}
@@ -0,0 +1,495 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import ava, { ExecutionContext, TestFn } from 'ava';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { EventBus } from '../../../base';
import {
Models,
Workspace,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
} from '../../../plugins/payment/types';
import { EntitlementModule, EntitlementService } from '../../entitlement';
import { QuotaService } from '../service';
import { QuotaServiceModule } from '../service.module';
import { QuotaStateService } from '../state';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
entitlement: EntitlementService;
quota: QuotaService;
state: QuotaStateService;
}
const test = ava.serial as TestFn<Context>;
const ONE_GB = 1024 * 1024 * 1024;
const ONE_DAY_SECONDS = 24 * 60 * 60;
type CaseState = {
userId?: string;
workspaceId?: string;
};
test.before(async t => {
const module = await createTestingModule({
imports: [EntitlementModule, QuotaServiceModule],
});
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.entitlement = module.get(EntitlementService);
t.context.quota = module.get(QuotaService);
t.context.state = module.get(QuotaStateService);
});
test('quota service ignores dirty legacy commercial features', async t => {
const { owner, workspace } = await createWorkspace(t);
await t.context.models.userFeature.add(
owner.id,
'pro_plan_v1',
'dirty legacy feature'
);
await t.context.models.userFeature.add(
owner.id,
'unlimited_copilot',
'dirty legacy feature'
);
await t.context.models.workspaceFeature.add(
workspace.id,
'team_plan_v1',
'dirty legacy feature',
{
memberLimit: 100,
}
);
const userQuota = await t.context.quota.getUserQuota(owner.id);
const workspaceSeats = await t.context.quota.getWorkspaceSeatQuota(
workspace.id
);
t.is(userQuota.name, 'Free');
t.is(userQuota.copilotActionLimit, 10);
t.is(workspaceSeats.memberLimit, 3);
});
test('workspace quota state ignores dirty legacy readonly feature', async t => {
const { workspace } = await createWorkspace(t);
await t.context.models.workspaceFeature.add(
workspace.id,
'quota_exceeded_readonly_workspace_v1',
'dirty legacy feature'
);
const state = await t.context.state.reconcileWorkspaceQuotaState(
workspace.id
);
t.false(state.readonly);
t.deepEqual(state.readonlyReasons, []);
});
test('workspace quota state ignores dirty legacy permission rows', async t => {
const { workspace } = await createWorkspace(t);
const member = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspace.id,
member.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await t.context.db.$transaction(async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
await tx.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: member.id,
},
});
});
const state = await t.context.state.reconcileWorkspaceQuotaState(
workspace.id
);
t.is(state.memberCount, 1);
});
test('quota service exposes history period in seconds', async t => {
const { owner, workspace } = await createWorkspace(t);
await t.context.entitlement.upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
const userState = await t.context.state.reconcileUserQuotaState(owner.id);
const workspaceState = await t.context.state.reconcileWorkspaceQuotaState(
workspace.id
);
const workspaceQuota = await t.context.quota.getWorkspaceQuota(workspace.id);
t.is(userState.historyPeriodSeconds, 30 * ONE_DAY_SECONDS);
t.is(workspaceState.historyPeriodSeconds, 30 * ONE_DAY_SECONDS);
t.is(workspaceQuota.historyPeriod, 30 * ONE_DAY_SECONDS);
t.is(
t.context.quota.formatWorkspaceQuota({
...workspaceQuota,
usedStorageQuota: 0,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 0,
}).historyPeriod,
'30 days'
);
});
test('quota state reconcile does not publish unchanged snapshots', async t => {
const user = await t.context.models.user.create({
email: 'quota-event-owner@affine.pro',
});
await t.context.db.effectiveUserQuotaState.deleteMany({
where: { userId: user.id },
});
const event = t.context.module.get(EventBus);
let changes = 0;
event.on('user.quota_state.changed', ({ userId }) => {
if (userId === user.id) {
changes += 1;
}
});
await t.context.state.reconcileUserQuotaState(user.id);
await t.context.state.reconcileUserQuotaState(user.id);
t.is(changes, 1);
});
test('workspace quota state requires owner from new permission table', async t => {
const { owner, workspace } = await createWorkspace(t);
await t.context.db.$transaction(async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
await tx.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: owner.id,
},
});
});
await t.throwsAsync(
t.context.state.reconcileWorkspaceQuotaState(workspace.id),
{ message: 'Workspace owner not found' }
);
});
test('user quota state aggregates owned storage from new permission table only', async t => {
const { owner, workspace } = await createWorkspace(t);
await addBlob(t, workspace, 'blob', ONE_GB);
const first = await t.context.state.reconcileUserQuotaState(owner.id);
await t.context.db.$transaction(async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
await tx.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: owner.id,
},
});
});
const second = await t.context.state.reconcileUserQuotaState(owner.id);
t.is(first.usedStorageQuota, BigInt(ONE_GB));
t.is(second.usedStorageQuota, 0n);
});
test('user quota state keeps ai capability alongside pro entitlement', async t => {
const { owner } = await createWorkspace(t);
await t.context.entitlement.upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
const state = await t.context.state.reconcileUserQuotaState(owner.id);
const quota = await t.context.quota.getUserQuota(owner.id);
t.is(state.plan, 'pro');
t.deepEqual(state.flags, { unlimitedCopilot: true });
t.is(quota.copilotActionLimit, undefined);
});
test('ai entitlement is a capability overlay on free quota', async t => {
const { owner } = await createWorkspace(t);
await t.context.entitlement.upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
const state = await t.context.state.reconcileUserQuotaState(owner.id);
const quota = await t.context.quota.getUserQuota(owner.id);
t.is(state.plan, 'free');
t.deepEqual(state.flags, { unlimitedCopilot: true });
t.is(quota.name, 'Free');
t.is(quota.copilotActionLimit, undefined);
});
test('workspace team status ignores dirty legacy feature', async t => {
const { workspace } = await createWorkspace(t);
await t.context.models.workspaceFeature.add(
workspace.id,
'team_plan_v1',
'dirty legacy feature',
{
memberLimit: 100,
}
);
t.false(await t.context.models.workspace.isTeamWorkspace(workspace.id));
await t.context.entitlement.upsertFromCloudSubscription({
targetId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
quantity: 5,
});
t.true(await t.context.models.workspace.isTeamWorkspace(workspace.id));
});
test('selfhosted builtin free has cloud pro quota rights', async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific quota semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
const { owner, workspace } = await createWorkspace(t);
const userState = await t.context.state.reconcileUserQuotaState(owner.id);
const userQuota = await t.context.quota.getUserQuota(owner.id);
const workspaceState = await t.context.state.reconcileWorkspaceQuotaState(
workspace.id
);
const workspaceQuota = await t.context.quota.getWorkspaceQuota(
workspace.id
);
t.is(userState.plan, 'selfhost_free');
t.is(userState.storageQuota, BigInt(100 * ONE_GB));
t.is(userQuota.name, 'Pro');
t.is(userQuota.memberLimit, 10);
t.is(workspaceState.plan, 'selfhost_free');
t.is(workspaceQuota.name, 'Pro');
t.is(workspaceQuota.memberLimit, 10);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('reconciles quota states from entitlements and business tables', async t => {
const cases = [
{
name: 'owner fallback uses user entitlement and owner storage usage',
setup: async () => {
const { owner, workspace } = await createWorkspace(t);
await t.context.entitlement.upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
await addBlob(t, workspace, 'blob', ONE_GB);
return { userId: owner.id, workspaceId: workspace.id };
},
assert: async ({ userId, workspaceId }: CaseState) => {
const user = await t.context.state.reconcileUserQuotaState(userId!);
const workspace = await t.context.state.reconcileWorkspaceQuotaState(
workspaceId!
);
t.is(user.plan, 'pro');
t.is(user.usedStorageQuota, BigInt(ONE_GB));
t.true(workspace.usesOwnerQuota);
t.is(workspace.plan, 'pro');
t.is(
(await t.context.quota.getWorkspaceQuota(workspaceId!)).name,
'Pro'
);
t.is(workspace.storageQuota, BigInt(100 * ONE_GB));
t.is(workspace.usedStorageQuota, BigInt(ONE_GB));
},
},
{
name: 'team entitlement owns workspace quota',
setup: async () => {
const { workspace } = await createWorkspace(t);
await t.context.entitlement.upsertFromCloudSubscription({
targetId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
quantity: 5,
});
return { workspaceId: workspace.id };
},
assert: async ({ workspaceId }: CaseState) => {
const workspace = await t.context.state.reconcileWorkspaceQuotaState(
workspaceId!
);
t.false(workspace.usesOwnerQuota);
t.is(workspace.seatLimit, 5);
t.is(workspace.storageQuota, BigInt(200 * ONE_GB));
},
},
{
name: 'overcapacity members set readonly state',
setup: async () => {
const { workspace } = await createWorkspace(t);
await addAcceptedMembers(t, workspace.id, 4);
return { workspaceId: workspace.id };
},
assert: async ({ workspaceId }: CaseState) => {
const workspace = await t.context.state.reconcileWorkspaceQuotaState(
workspaceId!
);
t.true(workspace.readonly);
t.deepEqual(workspace.readonlyReasons, ['member_overflow']);
t.is(workspace.overcapacityMemberCount, 2);
},
},
{
name: 'storage overflow sets readonly state',
setup: async () => {
const { workspace } = await createWorkspace(t);
for (let index = 0; index < 11; index++) {
await addBlob(t, workspace, `blob-${index}`, ONE_GB);
}
return { workspaceId: workspace.id };
},
assert: async ({ workspaceId }: CaseState) => {
const workspace = await t.context.state.reconcileWorkspaceQuotaState(
workspaceId!
);
t.true(workspace.readonly);
t.deepEqual(workspace.readonlyReasons, ['storage_overflow']);
t.is(workspace.usedStorageQuota, BigInt(11 * ONE_GB));
},
},
{
name: 'expired entitlement falls back to free state',
setup: async () => {
const { owner } = await createWorkspace(t);
await t.context.entitlement.upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: 'canceled',
});
return { userId: owner.id };
},
assert: async ({ userId }: CaseState) => {
const user = await t.context.state.reconcileUserQuotaState(userId!);
t.is(user.plan, 'free');
t.is(user.sourceEntitlementId, null);
},
},
];
for (const item of cases) {
await t.context.module.initTestingDB();
const state = await item.setup();
await item.assert(state);
}
});
async function createWorkspace(t: ExecutionContext<Context>) {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
return { owner, workspace };
}
async function addAcceptedMembers(
t: ExecutionContext<Context>,
workspaceId: string,
count: number
) {
for (let index = 0; index < count; index++) {
const member = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspaceId,
member.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
}
}
async function addBlob(
t: ExecutionContext<Context>,
workspace: Workspace,
key: string,
size: number
) {
await t.context.models.blob.upsert({
workspaceId: workspace.id,
key,
mime: 'application/octet-stream',
size,
});
}
@@ -19,4 +19,5 @@ export class QuotaModule {}
export { QuotaService };
export { QuotaServiceModule };
export { QuotaStateService } from './state';
export { WorkspaceQuotaHumanReadableType, WorkspaceQuotaType } from './types';
@@ -0,0 +1,147 @@
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { z } from 'zod';
import { OnEvent, SpaceAccessDenied } from '../../base';
import { Models } from '../../models';
import { registerRealtimeLiveQuery } from '../realtime/provider';
import { RealtimePublisher } from '../realtime/publisher';
import { RealtimeRegistry } from '../realtime/registry';
import {
realtimeUserQuotaStateRoom,
realtimeWorkspaceQuotaStateRoom,
} from '../realtime/rooms';
import { QuotaStateService } from './state';
type UserQuotaStateSnapshot = import('@affine/realtime').UserQuotaStateSnapshot;
type WorkspaceQuotaStateSnapshot =
import('@affine/realtime').WorkspaceQuotaStateSnapshot;
declare module '@affine/realtime' {
interface RealtimeRequestMap {
'user.quota-state.get': {
input: Record<string, never>;
output: { state: UserQuotaStateSnapshot };
};
'workspace.quota-state.get': {
input: { workspaceId: string };
output: { state: WorkspaceQuotaStateSnapshot };
};
}
interface RealtimeTopicMap {
'user.quota-state.changed': {
input: Record<string, never>;
event: { changed: true };
};
'workspace.quota-state.changed': {
input: { workspaceId: string };
event: { changed: true };
};
}
}
@Injectable()
export class QuotaStateRealtimeProvider implements OnModuleInit {
constructor(
private readonly models: Models,
private readonly quotaState: QuotaStateService,
@Optional() private readonly registry?: RealtimeRegistry,
@Optional() private readonly publisher?: RealtimePublisher
) {}
onModuleInit() {
const { registry } = this;
if (!registry) return;
const workspaceInput = z.object({ workspaceId: z.string() });
registerRealtimeLiveQuery(registry, {
request: {
name: 'user.quota-state.get',
input: z.object({}),
handle: async user => ({
state: this.serializeState(
await this.quotaState.reconcileUserQuotaState(user.id)
) as unknown as UserQuotaStateSnapshot,
}),
},
topic: {
name: 'user.quota-state.changed',
input: z.object({}),
authorize: async () => {},
room: user => {
if (!user) {
throw new Error('Authenticated user is required');
}
return realtimeUserQuotaStateRoom(user.id);
},
},
});
registerRealtimeLiveQuery(registry, {
request: {
name: 'workspace.quota-state.get',
input: workspaceInput,
handle: async (user, payload) => {
await this.assertWorkspace(user.id, payload.workspaceId);
return {
state: this.serializeState(
await this.quotaState.reconcileWorkspaceQuotaState(
payload.workspaceId
)
) as unknown as WorkspaceQuotaStateSnapshot,
};
},
},
topic: {
name: 'workspace.quota-state.changed',
input: workspaceInput,
authorize: async (user, payload) => {
await this.assertWorkspace(user.id, payload.workspaceId);
},
room: (_user, payload) =>
realtimeWorkspaceQuotaStateRoom(payload.workspaceId),
},
});
}
@OnEvent('user.quota_state.changed', { suppressError: true })
async onUserQuotaStateChanged({
userId,
}: Events['user.quota_state.changed']) {
this.publisher?.publish(
'user.quota-state.changed',
{},
{ changed: true },
{ room: realtimeUserQuotaStateRoom(userId) }
);
}
@OnEvent('workspace.quota_state.changed', { suppressError: true })
async onWorkspaceQuotaStateChanged({
workspaceId,
}: Events['workspace.quota_state.changed']) {
this.publisher?.publish(
'workspace.quota-state.changed',
{ workspaceId },
{ changed: true },
{ room: realtimeWorkspaceQuotaStateRoom(workspaceId) }
);
}
private async assertWorkspace(userId: string, workspaceId: string) {
const role = await this.models.workspaceUser.getActive(workspaceId, userId);
if (!role) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
}
private serializeState<T extends Record<string, unknown>>(state: T) {
return Object.fromEntries(
Object.entries(state).map(([key, value]) => [
key,
typeof value === 'bigint' ? Number(value) : value,
])
);
}
}
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { EntitlementModule } from '../entitlement';
import { StorageModule } from '../storage';
import { QuotaStateRealtimeProvider } from './realtime';
import { QuotaService } from './service';
import { QuotaStateService } from './state';
@Module({
imports: [StorageModule],
providers: [QuotaService],
exports: [QuotaService],
imports: [StorageModule, EntitlementModule],
providers: [QuotaService, QuotaStateService, QuotaStateRealtimeProvider],
exports: [QuotaService, QuotaStateService],
})
export class QuotaServiceModule {}
+88 -126
View File
@@ -1,13 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { InternalServerError, MemberQuotaExceeded, OnEvent } from '../../base';
import { MemberQuotaExceeded, OnEvent } from '../../base';
import {
Models,
type UserQuota,
WorkspaceQuota as BaseWorkspaceQuota,
WorkspaceRole,
} from '../../models';
import { WorkspaceBlobStorage } from '../storage';
import { QuotaStateService } from './state';
import {
UserQuotaHumanReadableType,
UserQuotaType,
@@ -29,10 +27,7 @@ export type WorkspaceQuotaWithUsage = Omit<
export class QuotaService {
protected logger = new Logger(QuotaService.name);
constructor(
private readonly models: Models,
private readonly storage: WorkspaceBlobStorage
) {}
constructor(private readonly quotaState: QuotaStateService) {}
@OnEvent('user.postCreated')
async onUserCreated({ id }: Events['user.postCreated']) {
@@ -40,121 +35,48 @@ export class QuotaService {
}
async getUserQuota(userId: string): Promise<UserQuota> {
let quota = await this.models.userFeature.getQuota(userId);
const state = await this.quotaState.reconcileUserQuotaState(userId);
// not possible, but just in case, we do a little fix for user to avoid system dump
if (!quota) {
await this.setupUserBaseQuota(userId);
quota = await this.models.userFeature.getQuota(userId);
}
const unlimitedCopilot = await this.models.userFeature.has(
userId,
'unlimited_copilot'
);
if (!quota) {
throw new InternalServerError(
'User quota not found and can not be created.'
);
}
return {
...quota.configs,
copilotActionLimit: unlimitedCopilot
? undefined
: quota.configs.copilotActionLimit,
} as UserQuotaWithUsage;
return this.userQuotaFromState(state);
}
async getUserQuotaWithUsage(userId: string): Promise<UserQuotaWithUsage> {
const quota = await this.getUserQuota(userId);
const usedStorageQuota = await this.getUserStorageUsage(userId);
const state = await this.quotaState.reconcileUserQuotaState(userId);
const quota = this.userQuotaFromState(state);
return { ...quota, usedStorageQuota };
return { ...quota, usedStorageQuota: Number(state.usedStorageQuota) };
}
async getUserStorageUsage(userId: string) {
const workspaces = await this.models.workspaceUser.getUserActiveRoles(
userId,
{
role: WorkspaceRole.Owner,
}
);
const ids = workspaces.map(w => w.workspaceId);
const workspacesWithQuota =
await this.models.workspaceFeature.batchHasQuota(ids);
const sizes = await Promise.allSettled(
ids
.filter(w => !workspacesWithQuota.includes(w))
.map(workspace => this.storage.totalSize(workspace))
);
return sizes.reduce((total, size) => {
if (size.status === 'fulfilled') {
// ensure that size is within the safe range of gql
const totalSize = total + size.value;
if (Number.isSafeInteger(totalSize)) {
return totalSize;
} else {
this.logger.error(`Workspace size is invalid: ${size.value}`);
}
} else {
this.logger.error(`Failed to get workspace size`, size.reason);
}
return total;
}, 0);
const state = await this.quotaState.reconcileUserQuotaState(userId);
return Number(state.usedStorageQuota);
}
async getWorkspaceStorageUsage(workspaceId: string) {
const totalSize = await this.storage.totalSize(workspaceId);
// ensure that size is within the safe range of gql
if (Number.isSafeInteger(totalSize)) {
return totalSize;
} else {
this.logger.error(`Workspace size is invalid: ${totalSize}`);
}
return 0;
const state =
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
return Number(state.usedStorageQuota);
}
async getWorkspaceQuota(workspaceId: string): Promise<WorkspaceQuota> {
const quota = await this.models.workspaceFeature.getQuota(workspaceId);
if (!quota) {
// get and convert to workspace quota from owner's quota
const owner = await this.models.workspaceUser.getOwner(workspaceId);
const ownerQuota = await this.getUserQuota(owner.id);
return {
...ownerQuota,
ownerQuota: owner.id,
};
}
return quota.configs;
const state =
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
return this.workspaceQuotaFromState(state);
}
async getWorkspaceQuotaWithUsage(
workspaceId: string
): Promise<WorkspaceQuotaWithUsage> {
const quota = await this.getWorkspaceQuota(workspaceId);
const usedStorageQuota = quota.ownerQuota
? await this.getUserStorageUsage(quota.ownerQuota)
: await this.getWorkspaceStorageUsage(workspaceId);
const memberCount =
await this.models.workspaceUser.chargedCount(workspaceId);
const overcapacityMemberCount = memberCount - quota.memberLimit;
const state =
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
const quota = this.workspaceQuotaFromState(state);
return {
...quota,
usedStorageQuota,
memberCount,
overcapacityMemberCount,
usedSize: usedStorageQuota,
usedStorageQuota: Number(state.usedStorageQuota),
memberCount: state.memberCount,
overcapacityMemberCount: state.overcapacityMemberCount,
usedSize: Number(state.usedStorageQuota),
};
}
@@ -175,13 +97,12 @@ export class QuotaService {
}
async getWorkspaceSeatQuota(workspaceId: string) {
const quota = await this.getWorkspaceQuota(workspaceId);
const memberCount =
await this.models.workspaceUser.chargedCount(workspaceId);
const state =
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
return {
memberCount,
memberLimit: quota.memberLimit,
memberCount: state.memberCount,
memberLimit: state.seatLimit,
};
}
@@ -215,42 +136,27 @@ export class QuotaService {
}
async getUserQuotaCalculator(userId: string) {
const quota = await this.getUserQuota(userId);
const usedSize = await this.getUserStorageUsage(userId);
const quota = await this.getUserQuotaWithUsage(userId);
return this.generateQuotaCalculator(
quota.storageQuota,
quota.blobLimit,
usedSize
quota.usedStorageQuota
);
}
async getWorkspaceQuotaCalculator(workspaceId: string) {
const quota = await this.getWorkspaceQuota(workspaceId);
const unlimited = await this.models.workspaceFeature.has(
workspaceId,
'unlimited_workspace'
);
// quota check will be disabled for unlimited workspace
// we save a complicated db read for used size
if (unlimited) {
return this.generateQuotaCalculator(0, quota.blobLimit, 0, true);
}
const usedSize = quota.ownerQuota
? await this.getUserStorageUsage(quota.ownerQuota)
: await this.getWorkspaceStorageUsage(workspaceId);
const quota = await this.getWorkspaceQuotaWithUsage(workspaceId);
return this.generateQuotaCalculator(
quota.storageQuota,
quota.blobLimit,
usedSize
quota.usedStorageQuota
);
}
private async setupUserBaseQuota(userId: string) {
await this.models.userFeature.add(userId, 'free_plan_v1', 'sign up');
await this.quotaState.reconcileUserQuotaState(userId);
}
private generateQuotaCalculator(
@@ -278,4 +184,60 @@ export class QuotaService {
};
return checkExceeded;
}
private userQuotaFromState(
state: Awaited<ReturnType<QuotaStateService['reconcileUserQuotaState']>>
): UserQuota {
const flags = state.flags as { unlimitedCopilot?: boolean };
return {
name: this.planName(state.plan),
blobLimit: Number(state.blobLimit),
storageQuota: Number(state.storageQuota),
historyPeriod: state.historyPeriodSeconds,
memberLimit: this.userMemberLimit(state.plan),
copilotActionLimit: flags.unlimitedCopilot
? undefined
: (state.copilotActionLimit ?? undefined),
};
}
private workspaceQuotaFromState(
state: Awaited<
ReturnType<QuotaStateService['reconcileWorkspaceQuotaState']>
>
): WorkspaceQuota {
return {
name: this.planName(state.plan),
blobLimit: Number(state.blobLimit),
storageQuota: Number(state.storageQuota),
historyPeriod: state.historyPeriodSeconds,
memberLimit: state.seatLimit,
ownerQuota: state.usesOwnerQuota
? (state.ownerUserId ?? undefined)
: undefined,
};
}
private userMemberLimit(plan: string) {
return plan === 'pro' || plan === 'lifetime_pro' || plan === 'selfhost_free'
? 10
: 3;
}
private planName(plan: string) {
switch (plan) {
case 'pro':
case 'selfhost_free':
return 'Pro';
case 'lifetime_pro':
return 'Lifetime Pro';
case 'ai':
return 'AI';
case 'team':
case 'selfhost_team':
return 'Team';
default:
return 'Free';
}
}
}
@@ -0,0 +1,413 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { EventBus, OnEvent } from '../../base';
import { EntitlementService } from '../entitlement';
type Quota = Awaited<
ReturnType<EntitlementService['resolveUserEntitlement']>
>['quota'];
const STATE_TTL = 1000 * 60 * 10;
declare global {
interface Events {
'user.quota_state.changed': {
userId: string;
};
'workspace.quota_state.changed': {
workspaceId: string;
};
}
}
@Injectable()
export class QuotaStateService {
constructor(
private readonly db: PrismaClient,
private readonly entitlement: EntitlementService,
private readonly event: EventBus
) {}
async reconcileUserQuotaState(userId: string) {
const [previous, entitlement, entitlements, resolved, usedStorageQuota] =
await Promise.all([
this.db.effectiveUserQuotaState.findUnique({ where: { userId } }),
this.entitlement.getBestEntitlement('user', userId),
this.entitlement.getActiveEntitlements('user', userId),
this.entitlement.resolveUserEntitlement(userId),
this.getOwnerStorageUsage(userId),
]);
const flags = {
...resolved.flags,
unlimitedCopilot: entitlements.some(
entitlement => entitlement.plan === 'ai'
),
};
const now = new Date();
const state = await this.db.effectiveUserQuotaState.upsert({
where: { userId },
update: {
plan: resolved.plan,
sourceEntitlementId: entitlement?.id ?? null,
...this.quotaData(resolved.quota),
usedStorageQuota,
flags,
known: true,
stale: false,
lastReconciledAt: now,
staleAfter: this.staleAfter(now),
},
create: {
userId,
plan: resolved.plan,
sourceEntitlementId: entitlement?.id ?? null,
...this.quotaData(resolved.quota),
usedStorageQuota,
flags,
known: true,
stale: false,
lastReconciledAt: now,
staleAfter: this.staleAfter(now),
},
});
if (this.userQuotaStateChanged(previous, state)) {
await this.event.emitAsync('user.quota_state.changed', { userId });
}
return state;
}
async reconcileWorkspaceQuotaState(workspaceId: string) {
const owner = await this.getWorkspaceOwner(workspaceId);
const [
previous,
entitlement,
resolved,
memberCount,
workspaceStorageUsage,
] = await Promise.all([
this.db.effectiveWorkspaceQuotaState.findUnique({
where: { workspaceId },
}),
this.entitlement.getBestEntitlement('workspace', workspaceId),
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
this.getChargedMemberCount(workspaceId),
this.getWorkspaceStorageUsage(workspaceId),
]);
const usesOwnerQuota = !this.hasStandaloneWorkspaceQuota(resolved.plan);
const [ownerState, ownerEntitlement] = usesOwnerQuota
? await Promise.all([
this.reconcileUserQuotaState(owner.id),
this.entitlement.resolveUserEntitlement(owner.id),
])
: [null, null];
const quota = ownerEntitlement?.quota ?? resolved.quota;
const plan = ownerEntitlement?.plan ?? resolved.plan;
const usedStorageQuota = ownerState
? ownerState.usedStorageQuota
: workspaceStorageUsage;
const storageQuota = BigInt(quota.storageQuota);
const seatLimit = quota.seatLimit ?? 0;
const overcapacityMemberCount = Math.max(memberCount - seatLimit, 0);
const readonlyReasons = [
overcapacityMemberCount > 0 ? 'member_overflow' : null,
usedStorageQuota > storageQuota ? 'storage_overflow' : null,
].filter((reason): reason is string => !!reason);
const now = new Date();
const state = await this.db.effectiveWorkspaceQuotaState.upsert({
where: { workspaceId },
update: {
plan,
sourceEntitlementId: entitlement?.id ?? null,
ownerUserId: owner.id,
usesOwnerQuota,
seatLimit,
memberCount,
overcapacityMemberCount,
...this.workspaceQuotaData(quota),
usedStorageQuota,
readonly: readonlyReasons.length > 0,
readonlyReasons,
flags: resolved.flags,
known: true,
stale: false,
lastReconciledAt: now,
staleAfter: this.staleAfter(now),
},
create: {
workspaceId,
plan,
sourceEntitlementId: entitlement?.id ?? null,
ownerUserId: owner.id,
usesOwnerQuota,
seatLimit,
memberCount,
overcapacityMemberCount,
...this.workspaceQuotaData(quota),
usedStorageQuota,
readonly: readonlyReasons.length > 0,
readonlyReasons,
flags: resolved.flags,
known: true,
stale: false,
lastReconciledAt: now,
staleAfter: this.staleAfter(now),
},
});
if (this.workspaceQuotaStateChanged(previous, state)) {
await this.event.emitAsync('workspace.quota_state.changed', {
workspaceId,
});
}
return state;
}
async reconcileAllEntitlementProjection() {
const [users, workspaces] = await Promise.all([
this.db.user.findMany({ select: { id: true } }),
this.db.workspace.findMany({ select: { id: true } }),
]);
await this.reconcileMany([
...users.map(user => () => this.reconcileUserQuotaState(user.id)),
...workspaces.map(
workspace => () => this.reconcileWorkspaceQuotaState(workspace.id)
),
]);
}
@OnEvent('entitlement.changed')
async onEntitlementChanged({
targetType,
targetId,
}: Events['entitlement.changed']) {
if (targetType === 'user') {
await this.reconcileUserQuotaState(targetId);
await this.reconcileOwnedWorkspaces(targetId);
} else if (targetType === 'workspace') {
await this.reconcileWorkspaceQuotaState(targetId);
}
}
@OnEvent('workspace.members.updated')
async onWorkspaceMembersUpdated({
workspaceId,
}: Events['workspace.members.updated']) {
await this.reconcileWorkspaceQuotaState(workspaceId);
}
@OnEvent('workspace.owner.changed')
async onWorkspaceOwnerChanged({
workspaceId,
from,
to,
}: Events['workspace.owner.changed']) {
await this.reconcileWorkspaceQuotaState(workspaceId);
await Promise.all([
this.reconcileUserQuotaState(from),
this.reconcileUserQuotaState(to),
]);
}
@OnEvent('workspace.blobs.updated')
async onWorkspaceBlobsUpdated({
workspaceId,
}: Events['workspace.blobs.updated']) {
const owner = await this.getWorkspaceOwner(workspaceId);
await Promise.all([
this.reconcileWorkspaceQuotaState(workspaceId),
this.reconcileUserQuotaState(owner.id),
]);
}
private async reconcileOwnedWorkspaces(userId: string) {
const workspaces = await this.getOwnedWorkspaceIds(userId);
await this.reconcileMany(
workspaces.map(
workspaceId => () => this.reconcileWorkspaceQuotaState(workspaceId)
)
);
}
private async getOwnerStorageUsage(userId: string) {
const workspaces = await this.getOwnedWorkspaceIds(userId);
const usages = await this.mapMany(workspaces, async workspaceId => {
const entitlement =
await this.entitlement.resolveWorkspaceEntitlement(workspaceId);
return this.hasStandaloneWorkspaceQuota(entitlement.plan)
? 0n
: this.getWorkspaceStorageUsage(workspaceId);
});
return usages.reduce((total, usage) => total + usage, 0n);
}
private async getWorkspaceOwner(workspaceId: string) {
const owner = await this.db.workspaceMember.findFirst({
where: {
workspaceId,
role: 'owner',
state: 'active',
},
select: {
user: {
select: {
id: true,
},
},
},
});
if (!owner) {
throw new Error('Workspace owner not found');
}
return owner.user;
}
private async getChargedMemberCount(workspaceId: string) {
const [members, invitations] = await Promise.all([
this.db.workspaceMember.count({
where: { workspaceId, state: 'active' },
}),
this.db.workspaceInvitation.count({
where: {
workspaceId,
status: {
not: 'waiting_review',
},
},
}),
]);
return members + invitations;
}
private async getOwnedWorkspaceIds(userId: string) {
const workspaces = await this.db.workspaceMember.findMany({
where: {
userId,
role: 'owner',
state: 'active',
},
select: {
workspaceId: true,
},
});
return workspaces.map(workspace => workspace.workspaceId);
}
private async getWorkspaceStorageUsage(workspaceId: string) {
const sum = await this.db.blob.aggregate({
where: {
workspaceId,
deletedAt: null,
},
_sum: {
size: true,
},
});
return BigInt(sum._sum.size ?? 0);
}
private hasStandaloneWorkspaceQuota(plan: string) {
return plan === 'team' || plan === 'selfhost_team';
}
private quotaData(quota: Quota) {
return {
blobLimit: BigInt(quota.blobLimit),
storageQuota: BigInt(quota.storageQuota),
historyPeriodSeconds: quota.historyPeriod,
copilotActionLimit: quota.copilotActionLimit ?? null,
};
}
private workspaceQuotaData(quota: Quota) {
return {
blobLimit: BigInt(quota.blobLimit),
storageQuota: BigInt(quota.storageQuota),
historyPeriodSeconds: quota.historyPeriod,
};
}
private async reconcileMany(tasks: Array<() => Promise<unknown>>) {
await this.mapMany(tasks, task => task());
}
private async mapMany<T, U>(items: T[], mapper: (item: T) => Promise<U>) {
const batchSize = 16;
const results: U[] = [];
for (let index = 0; index < items.length; index += batchSize) {
results.push(
...(await Promise.all(
items.slice(index, index + batchSize).map(item => mapper(item))
))
);
}
return results;
}
private userQuotaStateChanged(
previous: Awaited<
ReturnType<PrismaClient['effectiveUserQuotaState']['findUnique']>
>,
current: Awaited<
ReturnType<PrismaClient['effectiveUserQuotaState']['upsert']>
>
) {
if (!previous) {
return true;
}
return (
previous.plan !== current.plan ||
previous.sourceEntitlementId !== current.sourceEntitlementId ||
previous.blobLimit !== current.blobLimit ||
previous.storageQuota !== current.storageQuota ||
previous.usedStorageQuota !== current.usedStorageQuota ||
previous.historyPeriodSeconds !== current.historyPeriodSeconds ||
previous.copilotActionLimit !== current.copilotActionLimit ||
previous.known !== current.known ||
previous.stale !== current.stale ||
JSON.stringify(previous.flags) !== JSON.stringify(current.flags)
);
}
private workspaceQuotaStateChanged(
previous: Awaited<
ReturnType<PrismaClient['effectiveWorkspaceQuotaState']['findUnique']>
>,
current: Awaited<
ReturnType<PrismaClient['effectiveWorkspaceQuotaState']['upsert']>
>
) {
if (!previous) {
return true;
}
return (
previous.plan !== current.plan ||
previous.sourceEntitlementId !== current.sourceEntitlementId ||
previous.ownerUserId !== current.ownerUserId ||
previous.usesOwnerQuota !== current.usesOwnerQuota ||
previous.seatLimit !== current.seatLimit ||
previous.memberCount !== current.memberCount ||
previous.overcapacityMemberCount !== current.overcapacityMemberCount ||
previous.blobLimit !== current.blobLimit ||
previous.storageQuota !== current.storageQuota ||
previous.usedStorageQuota !== current.usedStorageQuota ||
previous.historyPeriodSeconds !== current.historyPeriodSeconds ||
previous.readonly !== current.readonly ||
previous.known !== current.known ||
previous.stale !== current.stale ||
previous.readonlyReasons.join(',') !==
current.readonlyReasons.join(',') ||
JSON.stringify(previous.flags) !== JSON.stringify(current.flags)
);
}
private staleAfter(now: Date) {
return new Date(now.getTime() + STATE_TTL);
}
}
@@ -1,4 +1,4 @@
import { OneDay, OneKB } from '../../base';
import { OneKB } from '../../base';
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
@@ -14,6 +14,6 @@ export function formatSize(bytes: number, decimals: number = 2): string {
);
}
export function formatDate(ms: number): string {
return `${(ms / OneDay).toFixed(0)} days`;
export function formatDate(seconds: number): string {
return `${(seconds / (24 * 60 * 60)).toFixed(0)} days`;
}
@@ -1,19 +1,48 @@
import { getRealtimeInputKey } from '@affine/realtime';
import {
getRealtimeInputKey,
type WorkspaceQuotaStateSnapshot,
} from '@affine/realtime';
import test from 'ava';
import { z } from 'zod';
import { PublicDocMode } from '../../../models';
import type { CopilotTranscriptionReader } from '../../../plugins/copilot/transcript';
import { CopilotTranscriptRealtimeProvider } from '../../../plugins/copilot/transcript';
import type { CurrentUser } from '../../auth';
import { CommentRealtimeProvider } from '../../comment/realtime';
import { NotificationRealtimeProvider } from '../../notification/realtime';
import type { AccessController } from '../../permission';
import {
DocRole,
type PermissionAccess,
WorkspaceRole,
} from '../../permission';
import { QuotaStateRealtimeProvider } from '../../quota/realtime';
import { UserRealtimeProvider } from '../../user/realtime';
import {
DocGrantsRealtimeProvider,
DocShareRealtimeProvider,
} from '../../workspaces/doc-realtime';
import {
WorkspaceAccessRealtimeProvider,
WorkspaceConfigRealtimeProvider,
WorkspaceMembersRealtimeProvider,
} from '../../workspaces/realtime';
import { RealtimeGateway } from '../gateway';
import {
realtimeCommentRoom,
realtimeDocGrantsRoom,
realtimeDocShareStateRoom,
realtimeNotificationRoom,
realtimeTranscriptTaskRoom,
realtimeUserAccessTokensRoom,
realtimeUserProfileRoom,
realtimeUserSettingsRoom,
realtimeWorkspaceAccessRoom,
realtimeWorkspaceConfigRoom,
realtimeWorkspaceEmbeddingProgressRoom,
realtimeWorkspaceInviteLinkRoom,
realtimeWorkspaceMembersRoom,
realtimeWorkspaceQuotaStateRoom,
registerRealtimeLiveQuery,
} from '../index';
import { RealtimePublisher } from '../publisher';
@@ -161,6 +190,18 @@ test('room helpers produce stable realtime room names', t => {
realtimeWorkspaceEmbeddingProgressRoom('space'),
'workspace:space:embedding-progress'
);
t.is(realtimeWorkspaceAccessRoom('space'), 'workspace:space:access');
t.is(realtimeWorkspaceConfigRoom('space'), 'workspace:space:config');
t.is(realtimeWorkspaceMembersRoom('space'), 'workspace:space:members');
t.is(realtimeWorkspaceInviteLinkRoom('space'), 'workspace:space:invite-link');
t.is(
realtimeDocShareStateRoom('space', 'doc'),
'workspace:space:doc:doc:share-state'
);
t.is(realtimeDocGrantsRoom('space', 'doc'), 'workspace:space:doc:doc:grants');
t.is(realtimeUserProfileRoom('u1'), 'user:u1:profile');
t.is(realtimeUserSettingsRoom('u1'), 'user:u1:settings');
t.is(realtimeUserAccessTokensRoom('u1'), 'user:u1:access-tokens');
t.is(
realtimeTranscriptTaskRoom('space', 'task'),
'copilot:transcript:space:task'
@@ -214,6 +255,581 @@ test('realtime providers expose runtime injection metadata for registry dependen
CopilotTranscriptRealtimeProvider
).includes(RealtimeRegistry)
);
t.true(
Reflect.getMetadata(
'design:paramtypes',
QuotaStateRealtimeProvider
).includes(RealtimeRegistry)
);
t.true(
Reflect.getMetadata(
'design:paramtypes',
WorkspaceAccessRealtimeProvider
).includes(RealtimeRegistry)
);
t.true(
Reflect.getMetadata(
'design:paramtypes',
WorkspaceConfigRealtimeProvider
).includes(RealtimeRegistry)
);
t.true(
Reflect.getMetadata(
'design:paramtypes',
WorkspaceMembersRealtimeProvider
).includes(RealtimeRegistry)
);
t.true(
Reflect.getMetadata('design:paramtypes', DocShareRealtimeProvider).includes(
RealtimeRegistry
)
);
t.true(
Reflect.getMetadata(
'design:paramtypes',
DocGrantsRealtimeProvider
).includes(RealtimeRegistry)
);
t.true(
Reflect.getMetadata('design:paramtypes', UserRealtimeProvider).includes(
RealtimeRegistry
)
);
});
test('workspace realtime providers register access, config, members and invite link handlers', async t => {
const registry = new RealtimeRegistry();
const assertions: unknown[] = [];
const ac = {
user(userId: string) {
return {
workspace(workspaceId: string) {
return {
async assert(action: string) {
assertions.push({ userId, workspaceId, action });
},
async permissions() {
return {
role: WorkspaceRole.Admin,
permissions: { 'Workspace.Read': true },
};
},
};
},
};
},
} as unknown as PermissionAccess;
const models = {
workspace: {
get: async () => ({
enableAi: true,
enableSharing: false,
enableUrlPreview: true,
enableDocEmbedding: false,
}),
},
workspaceUser: {
search: async () => [],
paginate: async () => [
[
{
id: 'invite',
type: WorkspaceRole.Collaborator,
status: 'Accepted',
user: {
id: 'u1',
name: 'User',
email: 'u1@affine.pro',
avatarUrl: null,
},
},
],
1,
],
count: async () => 1,
},
};
const workspaceService = {
isTeamWorkspace: async () => true,
};
const cache = {
get: async () => ({ inviteId: 'invite-link' }),
ttl: async () => 10,
};
const url = {
link: (path: string) => `https://app.affine.pro${path}`,
};
new WorkspaceAccessRealtimeProvider(
ac,
workspaceService as never,
registry
).onModuleInit();
new WorkspaceConfigRealtimeProvider(
ac,
models as never,
registry
).onModuleInit();
new WorkspaceMembersRealtimeProvider(
cache as never,
url as never,
ac,
models as never,
registry
).onModuleInit();
t.deepEqual(
await registry.getRequest('workspace.access.get').handle(user, {
workspaceId: 'space',
}),
{
access: {
role: 'Admin',
permissions: { Workspace_Read: true },
team: true,
},
}
);
t.deepEqual(
await registry.getRequest('workspace.config.get').handle(user, {
workspaceId: 'space',
}),
{
config: {
enableAi: true,
enableSharing: false,
enableUrlPreview: true,
enableDocEmbedding: false,
},
}
);
t.like(
await registry.getRequest('workspace.members.get').handle(user, {
workspaceId: 'space',
take: 1000,
}),
{ memberCount: 1 }
);
t.like(
await registry.getRequest('workspace.invite-link.get').handle(user, {
workspaceId: 'space',
}),
{
inviteLink: {
link: 'https://app.affine.pro/invite/invite-link',
},
}
);
t.is(
registry
.getTopic('workspace.access.changed')
.room(user, { workspaceId: 'space' }),
realtimeWorkspaceAccessRoom('space')
);
t.is(
registry
.getTopic('workspace.config.changed')
.room(user, { workspaceId: 'space' }),
realtimeWorkspaceConfigRoom('space')
);
t.is(
registry
.getTopic('workspace.members.changed')
.room(user, { workspaceId: 'space' }),
realtimeWorkspaceMembersRoom('space')
);
t.is(
registry
.getTopic('workspace.invite-link.changed')
.room(user, { workspaceId: 'space' }),
realtimeWorkspaceInviteLinkRoom('space')
);
t.true(
assertions.some(
item =>
JSON.stringify(item) ===
JSON.stringify({
userId: 'u1',
workspaceId: 'space',
action: 'Workspace.Users.Read',
})
)
);
});
test('doc realtime providers register share state and grants handlers', async t => {
const registry = new RealtimeRegistry();
const assertedActions: string[] = [];
const ac = {
user(userId: string) {
return {
doc(workspaceId: string, docId: string) {
return {
async assert(action: string) {
t.deepEqual(
{ userId, workspaceId, docId },
{
userId: 'u1',
workspaceId: 'space',
docId: 'doc',
}
);
assertedActions.push(action);
},
};
},
};
},
} as unknown as PermissionAccess;
const models = {
doc: {
getDocInfo: async () => ({
public: true,
mode: PublicDocMode.Page,
defaultRole: DocRole.Reader,
}),
},
docUser: {
findDirectGrantDocIdsByUser: async () => [],
paginate: async () => [
[
{
userId: 'u2',
type: DocRole.Manager,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
1,
],
},
user: {
getWorkspaceUsers: async () => [
{
id: 'u2',
name: 'User 2',
email: 'u2@affine.pro',
avatarUrl: null,
},
],
},
};
const grants = {
paginateGrantedUsers: async () => ({
totalCount: 1,
pageInfo: { endCursor: null, hasNextPage: false },
edges: [
{
node: {
type: DocRole.Manager,
user: {
id: 'u2',
name: 'User 2',
email: 'u2@affine.pro',
avatarUrl: null,
},
},
},
],
}),
};
new DocShareRealtimeProvider(ac, models as never, registry).onModuleInit();
new DocGrantsRealtimeProvider(
ac,
models as never,
grants as never,
registry
).onModuleInit();
t.deepEqual(
await registry.getRequest('doc.share-state.get').handle(user, {
workspaceId: 'space',
docId: 'doc',
}),
{
state: {
public: true,
mode: 'Page',
defaultRole: 'Reader',
},
}
);
t.like(
await registry.getRequest('doc.grants.get').handle(user, {
workspaceId: 'space',
docId: 'doc',
pagination: { first: 10 },
}),
{ totalCount: 1 }
);
t.deepEqual(assertedActions, ['Doc.Read', 'Doc.Users.Read']);
t.is(
registry
.getTopic('doc.share-state.changed')
.room(user, { workspaceId: 'space', docId: 'doc' }),
realtimeDocShareStateRoom('space', 'doc')
);
t.is(
registry
.getTopic('doc.grants.changed')
.room(user, { workspaceId: 'space', docId: 'doc' }),
realtimeDocGrantsRoom('space', 'doc')
);
});
test('user realtime provider snapshots private profile settings and access tokens without plaintext token', async t => {
const registry = new RealtimeRegistry();
const models = {
user: {
get: async () => ({
id: 'u1',
name: 'User',
email: 'u1@affine.pro',
avatarUrl: null,
emailVerifiedAt: new Date(0),
password: 'hash',
disabled: false,
}),
},
userSettings: {
get: async () => ({
receiveInvitationEmail: true,
receiveMentionEmail: false,
receiveCommentEmail: true,
}),
},
userFeature: {
list: async () => ['administrator'],
},
accessToken: {
list: async () => [
{
id: 'token',
name: 'Token',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
expiresAt: null,
},
],
},
};
new UserRealtimeProvider(models as never, registry).onModuleInit();
t.deepEqual(await registry.getRequest('user.profile.get').handle(user, {}), {
user: {
id: 'u1',
name: 'User',
email: 'u1@affine.pro',
emailVerified: true,
hasPassword: true,
avatarUrl: null,
features: ['Admin'],
},
});
t.deepEqual(
await registry
.getRequest('user.profile.get')
.handle(undefined as never, {}),
{ user: null }
);
t.is(
registry.getTopic('user.profile.changed').room(user, {}),
realtimeUserProfileRoom('u1')
);
t.is(
registry.getTopic('user.settings.changed').room(user, {}),
realtimeUserSettingsRoom('u1')
);
t.is(
registry.getTopic('user.access-tokens.changed').room(user, {}),
realtimeUserAccessTokensRoom('u1')
);
t.deepEqual(await registry.getRequest('user.settings.get').handle(user, {}), {
settings: {
receiveInvitationEmail: true,
receiveMentionEmail: false,
receiveCommentEmail: true,
},
});
t.deepEqual(
await registry.getRequest('user.access-tokens.get').handle(user, {}),
{
tokens: [
{
id: 'token',
name: 'Token',
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: null,
},
],
}
);
});
test('new realtime providers publish changed events from domain events', t => {
const published: unknown[][] = [];
const publisher = {
publish: (...args: unknown[]) => published.push(args),
publishChanged: (...args: unknown[]) => published.push(args),
} as unknown as RealtimePublisher;
const workspaceAccess = new WorkspaceAccessRealtimeProvider(
{} as never,
{} as never,
undefined,
publisher
);
workspaceAccess.onMembersUpdated({ workspaceId: 'space' });
const workspaceConfig = new WorkspaceConfigRealtimeProvider(
{} as never,
{} as never,
undefined,
publisher
);
workspaceConfig.onWorkspaceUpdated({ id: 'space' } as never);
const workspaceMembers = new WorkspaceMembersRealtimeProvider(
{} as never,
{} as never,
{} as never,
{} as never,
undefined,
publisher
);
workspaceMembers.onInviteLinkCreated({ workspaceId: 'space' });
const docShare = new DocShareRealtimeProvider(
{} as never,
{} as never,
undefined,
publisher
);
docShare.onPublicStateChanged({ workspaceId: 'space', docId: 'doc' });
const docGrants = new DocGrantsRealtimeProvider(
{} as never,
{} as never,
{} as never,
undefined,
publisher
);
docGrants.onOwnerChanged({
workspaceId: 'space',
docId: 'doc',
userId: 'u2',
});
const userProvider = new UserRealtimeProvider(
{} as never,
undefined,
publisher
);
userProvider.onUserAccessTokenCreated({ userId: 'u1' });
t.deepEqual(
published.map(args => args[0]),
[
'workspace.access.changed',
'workspace.config.changed',
'workspace.invite-link.changed',
'doc.share-state.changed',
'doc.grants.changed',
'user.access-tokens.changed',
]
);
});
test('quota realtime provider exposes effective quota state snapshots', async t => {
const registry = new RealtimeRegistry();
const provider = new QuotaStateRealtimeProvider(
{
workspaceUser: {
getActive: async () => ({ role: 'admin' }),
},
} as never,
{
reconcileUserQuotaState: async () => ({
userId: 'u1',
plan: 'pro',
sourceEntitlementId: null,
blobLimit: 1n,
storageQuota: 2n,
usedStorageQuota: 3n,
historyPeriodSeconds: 4,
copilotActionLimit: null,
flags: {},
known: true,
stale: false,
lastReconciledAt: null,
staleAfter: null,
createdAt: new Date(0),
updatedAt: new Date(0),
}),
reconcileWorkspaceQuotaState: async () => ({
workspaceId: 'space',
plan: 'team',
sourceEntitlementId: null,
ownerUserId: 'u1',
usesOwnerQuota: false,
seatLimit: 5,
memberCount: 4,
overcapacityMemberCount: 0,
blobLimit: 6n,
storageQuota: 7n,
usedStorageQuota: 8n,
historyPeriodSeconds: 9,
readonly: false,
readonlyReasons: [],
flags: {},
known: true,
stale: false,
lastReconciledAt: null,
staleAfter: null,
createdAt: new Date(0),
updatedAt: new Date(0),
}),
} as never,
registry
);
provider.onModuleInit();
t.deepEqual(
await registry.getRequest('user.quota-state.get').handle(user, {}),
{
state: {
userId: 'u1',
plan: 'pro',
sourceEntitlementId: null,
blobLimit: 1,
storageQuota: 2,
usedStorageQuota: 3,
historyPeriodSeconds: 4,
copilotActionLimit: null,
flags: {},
known: true,
stale: false,
lastReconciledAt: null,
staleAfter: null,
createdAt: new Date(0),
updatedAt: new Date(0),
},
}
);
const workspaceQuotaState = (await registry
.getRequest('workspace.quota-state.get')
.handle(user, { workspaceId: 'space' })) as {
state: WorkspaceQuotaStateSnapshot;
};
t.is(workspaceQuotaState.state.memberCount, 4);
t.is(
registry
.getTopic('workspace.quota-state.changed')
.room(user, { workspaceId: 'space' }),
realtimeWorkspaceQuotaStateRoom('space')
);
});
test('copilot transcript realtime provider registers task live query handlers', async t => {
@@ -234,7 +850,7 @@ test('copilot transcript realtime provider registers task live query handlers',
},
};
},
} as unknown as AccessController;
} as unknown as PermissionAccess;
const transcript = {
async queryTask(
userId: string,
@@ -16,11 +16,22 @@ export { RealtimePublisher } from './publisher';
export { RealtimeRegistry } from './registry';
export {
realtimeCommentRoom,
realtimeDocGrantsRoom,
realtimeDocShareStateRoom,
realtimeNotificationRoom,
realtimeTranscriptTaskRoom,
realtimeUserAccessTokensRoom,
realtimeUserProfileRoom,
realtimeUserQuotaStateRoom,
realtimeUserRoom,
realtimeUserSettingsRoom,
realtimeWorkspaceAccessRoom,
realtimeWorkspaceConfigRoom,
realtimeWorkspaceDocRoom,
realtimeWorkspaceEmbeddingProgressRoom,
realtimeWorkspaceInviteLinkRoom,
realtimeWorkspaceMembersRoom,
realtimeWorkspaceQuotaStateRoom,
realtimeWorkspaceRoom,
} from './rooms';
export type { RealtimeRequestHandler, RealtimeTopicHandler } from './types';
@@ -1,6 +1,8 @@
import {
getRealtimeInputKey,
type RealtimeEvent,
type RealtimeTopicEventOf,
type RealtimeTopicInputOf,
type RealtimeTopicName,
} from '@affine/realtime';
import { Injectable, Logger } from '@nestjs/common';
@@ -44,6 +46,20 @@ export class RealtimePublisher {
}
}
publishChanged<Topic extends RealtimeTopicName>(
topic: Topic,
input: RealtimeTopicInputOf<Topic>,
reason: string,
options?: { room?: string }
) {
this.publish(
topic,
input,
{ changed: true, reason } as RealtimeTopicEventOf<Topic>,
options
);
}
publishLocal(payload: RealtimePublishPayload) {
const handler = this.registry.getTopic(payload.topic);
const room = payload.room ?? handler.room(null, payload.input as never);
@@ -32,3 +32,47 @@ export function realtimeCommentRoom(workspaceId: string, docId: string) {
export function realtimeWorkspaceEmbeddingProgressRoom(workspaceId: string) {
return realtimeWorkspaceRoom(workspaceId, 'embedding-progress');
}
export function realtimeUserQuotaStateRoom(userId: string) {
return realtimeUserRoom(userId, 'quota-state');
}
export function realtimeWorkspaceQuotaStateRoom(workspaceId: string) {
return realtimeWorkspaceRoom(workspaceId, 'quota-state');
}
export function realtimeWorkspaceAccessRoom(workspaceId: string) {
return realtimeWorkspaceRoom(workspaceId, 'access');
}
export function realtimeWorkspaceConfigRoom(workspaceId: string) {
return realtimeWorkspaceRoom(workspaceId, 'config');
}
export function realtimeWorkspaceMembersRoom(workspaceId: string) {
return realtimeWorkspaceRoom(workspaceId, 'members');
}
export function realtimeWorkspaceInviteLinkRoom(workspaceId: string) {
return realtimeWorkspaceRoom(workspaceId, 'invite-link');
}
export function realtimeDocShareStateRoom(workspaceId: string, docId: string) {
return realtimeWorkspaceDocRoom(workspaceId, docId, 'share-state');
}
export function realtimeDocGrantsRoom(workspaceId: string, docId: string) {
return realtimeWorkspaceDocRoom(workspaceId, docId, 'grants');
}
export function realtimeUserProfileRoom(userId: string) {
return realtimeUserRoom(userId, 'profile');
}
export function realtimeUserSettingsRoom(userId: string) {
return realtimeUserRoom(userId, 'settings');
}
export function realtimeUserAccessTokensRoom(userId: string) {
return realtimeUserRoom(userId, 'access-tokens');
}
@@ -41,8 +41,8 @@ import {
} from '../doc';
import { applyUpdatesWithNative } from '../doc/merge-updates';
import {
AccessController,
type DocAction,
PermissionAccess,
WorkspaceAction,
} from '../permission';
import { DocID } from '../utils/doc';
@@ -223,7 +223,7 @@ export class SpaceSyncGateway
private activeUsersFlushQueued = false;
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly event: EventBus,
private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly userspace: PgUserspaceDocStorageAdapter,
@@ -899,7 +899,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
constructor(
client: Socket,
storage: DocStorageAdapter,
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly docReader: DocReader,
private readonly models: Models
) {
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { PermissionModule } from '../permission';
import { StorageModule } from '../storage';
import { UserAvatarController } from './controller';
import { UserRealtimeProvider } from './realtime';
import {
UserManagementResolver,
UserResolver,
@@ -11,7 +12,12 @@ import {
@Module({
imports: [StorageModule, PermissionModule],
providers: [UserResolver, UserManagementResolver, UserSettingsResolver],
providers: [
UserResolver,
UserManagementResolver,
UserSettingsResolver,
UserRealtimeProvider,
],
controllers: [UserAvatarController],
})
export class UserModule {}
@@ -0,0 +1,187 @@
import type {
AccessTokenSnapshot,
CurrentUserProfileSnapshot,
UserSettingsSnapshot,
} from '@affine/realtime';
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { z } from 'zod';
import { AuthenticationRequired, OnEvent, UserNotFound } from '../../base';
import { Feature, Models } from '../../models';
import { sessionUser } from '../auth/service';
import { AvailableUserFeatureConfig } from '../features/types';
import { registerRealtimeLiveQuery } from '../realtime/provider';
import { RealtimePublisher } from '../realtime/publisher';
import { RealtimeRegistry } from '../realtime/registry';
import {
realtimeUserAccessTokensRoom,
realtimeUserProfileRoom,
realtimeUserSettingsRoom,
} from '../realtime/rooms';
const emptyInput = z.object({}).strict();
function assertAuthenticated(user?: { id: string }) {
if (!user) {
throw new AuthenticationRequired();
}
return user;
}
@Injectable()
export class UserRealtimeProvider
extends AvailableUserFeatureConfig
implements OnModuleInit
{
constructor(
private readonly models: Models,
@Optional() private readonly registry?: RealtimeRegistry,
@Optional() private readonly publisher?: RealtimePublisher
) {
super();
}
onModuleInit() {
if (!this.registry) return;
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'user.profile.get',
input: emptyInput,
handle: async user => ({
user: user ? await this.getProfile(user.id) : null,
}),
},
topic: {
name: 'user.profile.changed',
input: emptyInput,
authorize: async () => {},
room: user => {
if (!user) {
throw new Error('Authenticated user is required');
}
return realtimeUserProfileRoom(user.id);
},
},
});
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'user.settings.get',
input: emptyInput,
handle: async user => ({
settings: await this.getSettings(assertAuthenticated(user).id),
}),
},
topic: {
name: 'user.settings.changed',
input: emptyInput,
authorize: async () => {},
room: user => {
if (!user) {
throw new Error('Authenticated user is required');
}
return realtimeUserSettingsRoom(user.id);
},
},
});
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'user.access-tokens.get',
input: emptyInput,
handle: async user => ({
tokens: await this.getAccessTokens(assertAuthenticated(user).id),
}),
},
topic: {
name: 'user.access-tokens.changed',
input: emptyInput,
authorize: async () => {},
room: user => {
if (!user) {
throw new Error('Authenticated user is required');
}
return realtimeUserAccessTokensRoom(user.id);
},
},
});
}
@OnEvent('user.updated', { suppressError: true })
onUserUpdated(user: Events['user.updated']) {
this.publisher?.publishChanged('user.profile.changed', {}, 'user-updated', {
room: realtimeUserProfileRoom(user.id),
});
}
@OnEvent('user.settings.updated', { suppressError: true })
onUserSettingsUpdated({ userId }: Events['user.settings.updated']) {
this.publisher?.publishChanged(
'user.settings.changed',
{},
'settings-updated',
{ room: realtimeUserSettingsRoom(userId) }
);
}
@OnEvent('user.access_token.created', { suppressError: true })
onUserAccessTokenCreated({ userId }: Events['user.access_token.created']) {
this.publishAccessTokens(userId, 'access-token-created');
}
@OnEvent('user.access_token.revoked', { suppressError: true })
onUserAccessTokenRevoked({ userId }: Events['user.access_token.revoked']) {
this.publishAccessTokens(userId, 'access-token-revoked');
}
private async getProfile(
userId: string
): Promise<CurrentUserProfileSnapshot> {
const user = await this.models.user.get(userId);
if (!user) {
throw new UserNotFound();
}
const current = sessionUser(user);
return {
id: current.id,
name: current.name,
email: current.email,
emailVerified: current.emailVerified,
hasPassword: current.hasPassword,
avatarUrl: current.avatarUrl ?? null,
features: (await this.models.userFeature.list(userId))
.filter(feature => this.availableUserFeatures().has(feature))
.map(feature => this.serializeFeature(feature)),
};
}
private serializeFeature(feature: string) {
return (
Object.entries(Feature).find(([, value]) => value === feature)?.[0] ??
feature
);
}
private async getSettings(userId: string): Promise<UserSettingsSnapshot> {
return await this.models.userSettings.get(userId);
}
private async getAccessTokens(
userId: string
): Promise<AccessTokenSnapshot[]> {
const tokens = await this.models.accessToken.list(userId);
return tokens.map(token => ({
id: token.id,
name: token.name,
createdAt: token.createdAt.toISOString(),
expiresAt: token.expiresAt?.toISOString() ?? null,
}));
}
private publishAccessTokens(userId: string, reason: string) {
this.publisher?.publishChanged('user.access-tokens.changed', {}, reason, {
room: realtimeUserAccessTokensRoom(userId),
});
}
}
@@ -16,6 +16,7 @@ import { isNil, omitBy } from 'lodash-es';
import {
CannotDeleteOwnAccount,
EventBus,
type FileUpload,
ImageFormatNotSupported,
OneMB,
@@ -186,7 +187,10 @@ export class UserResolver {
@Resolver(() => UserType)
export class UserSettingsResolver {
constructor(private readonly models: Models) {}
constructor(
private readonly models: Models,
private readonly event: EventBus
) {}
@Mutation(() => Boolean, {
name: 'updateSettings',
@@ -199,6 +203,7 @@ export class UserSettingsResolver {
) {
UserSettingsSchema.parse(input);
await this.models.userSettings.set(user.id, input);
this.event.emit('user.settings.updated', { userId: user.id });
return true;
}
@@ -29,7 +29,7 @@ import { buildPublicRootDoc } from '../../native';
import { CurrentUser, Public } from '../auth';
import { PgWorkspaceDocStorageAdapter } from '../doc';
import { DocReader } from '../doc/reader';
import { AccessController, WorkspacePolicyService } from '../permission';
import { PermissionAccess } from '../permission';
import { CommentAttachmentStorage, WorkspaceBlobStorage } from '../storage';
import { DocID } from '../utils/doc';
@@ -39,8 +39,7 @@ export class WorkspacesController {
constructor(
private readonly storage: WorkspaceBlobStorage,
private readonly commentAttachmentStorage: CommentAttachmentStorage,
private readonly ac: AccessController,
private readonly workspacePolicy: WorkspacePolicyService,
private readonly ac: PermissionAccess,
private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly docReader: DocReader,
private readonly models: Models
@@ -113,7 +112,7 @@ export class WorkspacesController {
.workspace(workspaceId)
.can('Workspace.Read');
const canReadSharedWorkspaceBlobs =
await this.workspacePolicy.canReadWorkspaceBySharedDocs(workspaceId);
await this.canReadSharedWorkspaceBlobs(workspaceId);
if (!canReadWorkspace && !canReadSharedWorkspaceBlobs) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
@@ -163,6 +162,14 @@ export class WorkspacesController {
body.pipe(res);
}
private async canReadSharedWorkspaceBlobs(workspaceId: string) {
const [sharingEnabled, publicDocs] = await Promise.all([
this.models.workspace.allowSharing(workspaceId),
this.models.docAccessPolicy.hasPublicExternal(workspaceId),
]);
return sharingEnabled && publicDocs;
}
// get doc binary
@Public()
@Get('/:id/docs/:guid')
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { paginate, PaginationInput } from '../../base';
import { Models } from '../../models';
import type { WorkspaceUserType } from '../user';
@Injectable()
export class DocGrantsService {
constructor(private readonly models: Models) {}
async paginateGrantedUsers(
workspaceId: string,
docId: string,
pagination: PaginationInput
) {
const [permissions, totalCount] = await this.models.docUser.paginate(
workspaceId,
docId,
pagination
);
const workspaceUsers = await this.models.user.getWorkspaceUsers(
permissions.map(p => p.userId)
);
const workspaceUsersMap = new Map(
workspaceUsers.map(user => [user.id, user])
);
return paginate(
permissions.map(permission => {
const user = workspaceUsersMap.get(permission.userId);
if (!user) {
throw new Error(`Doc grant user ${permission.userId} not found`);
}
return {
...permission,
user: user as WorkspaceUserType,
};
}),
'createdAt',
pagination,
totalCount
);
}
}
@@ -0,0 +1,221 @@
import type {
DocShareStateSnapshot,
PaginatedDocGrantedUsersSnapshot,
} from '@affine/realtime';
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { z } from 'zod';
import { OnEvent, PaginationInput } from '../../base';
import { DocRole, Models, PublicDocMode } from '../../models';
import { PermissionAccess } from '../permission';
import { registerRealtimeLiveQuery } from '../realtime/provider';
import { RealtimePublisher } from '../realtime/publisher';
import { RealtimeRegistry } from '../realtime/registry';
import {
realtimeDocGrantsRoom,
realtimeDocShareStateRoom,
} from '../realtime/rooms';
import { DocGrantsService } from './doc-grants';
const docInput = z
.object({ workspaceId: z.string(), docId: z.string() })
.strict();
@Injectable()
export class DocShareRealtimeProvider implements OnModuleInit {
constructor(
private readonly ac: PermissionAccess,
private readonly models: Models,
@Optional() private readonly registry?: RealtimeRegistry,
@Optional() private readonly publisher?: RealtimePublisher
) {}
onModuleInit() {
if (!this.registry) return;
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'doc.share-state.get',
input: docInput,
handle: async (user, input) => ({
state: await this.getShareState(
user.id,
input.workspaceId,
input.docId
),
}),
},
topic: {
name: 'doc.share-state.changed',
input: docInput,
authorize: async (user, input) => {
await this.assertRead(user.id, input.workspaceId, input.docId);
},
room: (_user, input) =>
realtimeDocShareStateRoom(input.workspaceId, input.docId),
},
});
}
@OnEvent('doc.public_state.changed', { suppressError: true })
onPublicStateChanged({
workspaceId,
docId,
}: Events['doc.public_state.changed']) {
this.publish(workspaceId, docId, 'public-state-changed');
}
@OnEvent('doc.default_role.changed', { suppressError: true })
onDefaultRoleChanged({
workspaceId,
docId,
}: Events['doc.default_role.changed']) {
this.publish(workspaceId, docId, 'default-role-changed');
}
private async getShareState(
userId: string,
workspaceId: string,
docId: string
): Promise<DocShareStateSnapshot | null> {
await this.assertRead(userId, workspaceId, docId);
const doc = await this.models.doc.getDocInfo(workspaceId, docId);
if (!doc) {
return null;
}
return {
public: doc.public,
mode: PublicDocMode[doc.mode],
defaultRole: DocRole[doc.defaultRole],
};
}
private async assertRead(userId: string, workspaceId: string, docId: string) {
await this.ac.user(userId).doc(workspaceId, docId).assert('Doc.Read');
}
private publish(workspaceId: string, docId: string, reason: string) {
this.publisher?.publishChanged(
'doc.share-state.changed',
{ workspaceId, docId },
reason,
{ room: realtimeDocShareStateRoom(workspaceId, docId) }
);
}
}
@Injectable()
export class DocGrantsRealtimeProvider implements OnModuleInit {
constructor(
private readonly ac: PermissionAccess,
private readonly models: Models,
private readonly grants: DocGrantsService,
@Optional() private readonly registry?: RealtimeRegistry,
@Optional() private readonly publisher?: RealtimePublisher
) {}
onModuleInit() {
if (!this.registry) return;
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'doc.grants.get',
input: z
.object({
workspaceId: z.string(),
docId: z.string(),
pagination: z
.object({
first: z.number().int().positive(),
offset: z.number().int().nonnegative().optional(),
after: z.string().optional(),
})
.strict(),
})
.strict(),
handle: async (user, input) =>
this.getGrants(user.id, input.workspaceId, input.docId, {
first: input.pagination.first,
offset: input.pagination.offset ?? 0,
after: input.pagination.after,
}),
},
topic: {
name: 'doc.grants.changed',
input: docInput,
authorize: async (user, input) => {
await this.assertRead(user.id, input.workspaceId, input.docId);
},
room: (_user, input) =>
realtimeDocGrantsRoom(input.workspaceId, input.docId),
},
});
}
@OnEvent('doc.grants.changed', { suppressError: true })
onGrantsChanged({ workspaceId, docId }: Events['doc.grants.changed']) {
this.publish(workspaceId, docId, 'grants-changed');
}
@OnEvent('doc.owner.changed', { suppressError: true })
onOwnerChanged({ workspaceId, docId }: Events['doc.owner.changed']) {
this.publish(workspaceId, docId, 'owner-changed');
}
@OnEvent('user.updated', { suppressError: true })
async onUserUpdated(user: Events['user.updated']) {
const grants = await this.models.docUser.findDirectGrantDocIdsByUser(
user.id
);
for (const grant of grants) {
this.publish(grant.workspaceId, grant.docId, 'user-updated');
}
}
private async getGrants(
userId: string,
workspaceId: string,
docId: string,
input: PaginationInput
): Promise<PaginatedDocGrantedUsersSnapshot> {
await this.assertRead(userId, workspaceId, docId);
const pagination = PaginationInput.decode.transform(input, {} as never);
const page = await this.grants.paginateGrantedUsers(
workspaceId,
docId,
pagination
);
return {
totalCount: page.totalCount,
pageInfo: {
endCursor: page.pageInfo.endCursor ?? null,
hasNextPage: page.pageInfo.hasNextPage,
},
edges: page.edges.map(edge => ({
node: {
role: DocRole[edge.node.type],
user: {
id: edge.node.user.id,
name: edge.node.user.name,
email: edge.node.user.email,
avatarUrl: edge.node.user.avatarUrl ?? null,
},
},
})),
};
}
private async assertRead(userId: string, workspaceId: string, docId: string) {
await this.ac.user(userId).doc(workspaceId, docId).assert('Doc.Users.Read');
}
private publish(workspaceId: string, docId: string, reason: string) {
this.publisher?.publishChanged(
'doc.grants.changed',
{ workspaceId, docId },
reason,
{ room: realtimeDocGrantsRoom(workspaceId, docId) }
);
}
}
@@ -26,6 +26,12 @@ declare global {
workspaceId: string;
quantity: number;
};
'workspace.invite_link.created': {
workspaceId: string;
};
'workspace.invite_link.revoked': {
workspaceId: string;
};
}
}
@@ -10,7 +10,17 @@ import { QuotaModule } from '../quota';
import { StorageModule } from '../storage';
import { UserModule } from '../user';
import { WorkspacesController } from './controller';
import { DocGrantsService } from './doc-grants';
import {
DocGrantsRealtimeProvider,
DocShareRealtimeProvider,
} from './doc-realtime';
import { WorkspaceEvents } from './event';
import {
WorkspaceAccessRealtimeProvider,
WorkspaceConfigRealtimeProvider,
WorkspaceMembersRealtimeProvider,
} from './realtime';
import {
DocHistoryResolver,
DocResolver,
@@ -44,7 +54,13 @@ import { WorkspaceStatsJob } from './stats.job';
DocHistoryResolver,
WorkspaceBlobResolver,
WorkspaceService,
DocGrantsService,
WorkspaceEvents,
WorkspaceAccessRealtimeProvider,
WorkspaceConfigRealtimeProvider,
WorkspaceMembersRealtimeProvider,
DocShareRealtimeProvider,
DocGrantsRealtimeProvider,
AdminWorkspaceResolver,
WorkspaceStatsJob,
],
@@ -0,0 +1,401 @@
import {
WORKSPACE_MEMBERS_REQUEST_TAKE_MAX,
type WorkspaceAccessSnapshot,
type WorkspaceConfigSnapshot,
type WorkspaceInviteLinkSnapshot,
type WorkspaceMemberSnapshot,
} from '@affine/realtime';
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { z } from 'zod';
import {
Cache,
isValidCacheTtl,
OnEvent,
QueryTooLong,
URLHelper,
} from '../../base';
import { Models } from '../../models';
import type { WorkspaceUserCompat } from '../../models/workspace-user-compat';
import type { CurrentUser } from '../auth';
import {
mapPermissionsToGraphqlPermissions,
PermissionAccess,
WorkspaceRole,
} from '../permission';
import { registerRealtimeLiveQuery } from '../realtime/provider';
import { RealtimePublisher } from '../realtime/publisher';
import { RealtimeRegistry } from '../realtime/registry';
import {
realtimeWorkspaceAccessRoom,
realtimeWorkspaceConfigRoom,
realtimeWorkspaceInviteLinkRoom,
realtimeWorkspaceMembersRoom,
} from '../realtime/rooms';
import { WorkspaceService } from './service';
const workspaceInput = z.object({ workspaceId: z.string() }).strict();
function serializeWorkspaceMember(
row: WorkspaceUserCompat
): WorkspaceMemberSnapshot {
if (!row.user) {
throw new Error('Workspace member user is required');
}
const role = WorkspaceRole[row.type as WorkspaceRole];
return {
...row.user,
avatarUrl: row.user.avatarUrl ?? null,
permission: role,
role,
inviteId: row.id,
emailVerified: null,
status: row.status,
};
}
@Injectable()
export class WorkspaceAccessRealtimeProvider implements OnModuleInit {
constructor(
private readonly ac: PermissionAccess,
private readonly workspaceService: WorkspaceService,
@Optional() private readonly registry?: RealtimeRegistry,
@Optional() private readonly publisher?: RealtimePublisher
) {}
onModuleInit() {
if (!this.registry) return;
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'workspace.access.get',
input: workspaceInput,
handle: async (user, input) => ({
access: await this.getAccess(user, input.workspaceId),
}),
},
topic: {
name: 'workspace.access.changed',
input: workspaceInput,
authorize: async (user, input) => {
await this.ac
.user(user.id)
.workspace(input.workspaceId)
.assert('Workspace.Read');
},
room: (_user, input) => realtimeWorkspaceAccessRoom(input.workspaceId),
},
});
}
@OnEvent('workspace.members.updated', { suppressError: true })
onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) {
this.publish(workspaceId, 'members-updated');
}
@OnEvent('workspace.members.roleChanged', { suppressError: true })
onMemberRoleChanged({
workspaceId,
}: Events['workspace.members.roleChanged']) {
this.publish(workspaceId, 'member-role-changed');
}
@OnEvent('workspace.owner.changed', { suppressError: true })
onWorkspaceOwnerChanged({ workspaceId }: Events['workspace.owner.changed']) {
this.publish(workspaceId, 'owner-changed');
}
@OnEvent('workspace.quota_state.changed', { suppressError: true })
onWorkspaceQuotaStateChanged({
workspaceId,
}: Events['workspace.quota_state.changed']) {
this.publish(workspaceId, 'quota-state-changed');
}
private async getAccess(
user: CurrentUser,
workspaceId: string
): Promise<WorkspaceAccessSnapshot> {
await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read');
const { role, permissions } = await this.ac
.user(user.id)
.workspace(workspaceId)
.permissions();
return {
role: role ? WorkspaceRole[role] : WorkspaceRole[WorkspaceRole.External],
permissions: mapPermissionsToGraphqlPermissions(permissions),
team: await this.workspaceService.isTeamWorkspace(workspaceId),
};
}
private publish(workspaceId: string, reason: string) {
this.publisher?.publishChanged(
'workspace.access.changed',
{ workspaceId },
reason,
{ room: realtimeWorkspaceAccessRoom(workspaceId) }
);
}
}
@Injectable()
export class WorkspaceConfigRealtimeProvider implements OnModuleInit {
constructor(
private readonly ac: PermissionAccess,
private readonly models: Models,
@Optional() private readonly registry?: RealtimeRegistry,
@Optional() private readonly publisher?: RealtimePublisher
) {}
onModuleInit() {
if (!this.registry) return;
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'workspace.config.get',
input: workspaceInput,
handle: async (user, input) => ({
config: await this.getConfig(user, input.workspaceId),
}),
},
topic: {
name: 'workspace.config.changed',
input: workspaceInput,
authorize: async (user, input) => {
await this.assertRead(user.id, input.workspaceId);
},
room: (_user, input) => realtimeWorkspaceConfigRoom(input.workspaceId),
},
});
}
@OnEvent('workspace.updated', { suppressError: true })
onWorkspaceUpdated(workspace: Events['workspace.updated']) {
this.publisher?.publishChanged(
'workspace.config.changed',
{ workspaceId: workspace.id },
'workspace-updated',
{ room: realtimeWorkspaceConfigRoom(workspace.id) }
);
}
private async getConfig(
user: CurrentUser,
workspaceId: string
): Promise<WorkspaceConfigSnapshot> {
await this.assertRead(user.id, workspaceId);
const workspace = await this.models.workspace.get(workspaceId);
return {
enableAi: Boolean(workspace?.enableAi),
enableSharing: Boolean(workspace?.enableSharing),
enableUrlPreview: Boolean(workspace?.enableUrlPreview),
enableDocEmbedding: Boolean(workspace?.enableDocEmbedding),
};
}
private async assertRead(userId: string, workspaceId: string) {
await this.ac
.user(userId)
.workspace(workspaceId)
.assert('Workspace.Settings.Read');
}
}
@Injectable()
export class WorkspaceMembersRealtimeProvider implements OnModuleInit {
constructor(
private readonly cache: Cache,
private readonly url: URLHelper,
private readonly ac: PermissionAccess,
private readonly models: Models,
@Optional() private readonly registry?: RealtimeRegistry,
@Optional() private readonly publisher?: RealtimePublisher
) {}
onModuleInit() {
if (!this.registry) return;
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'workspace.members.get',
input: z
.object({
workspaceId: z.string(),
skip: z.number().int().nonnegative().optional(),
take: z.number().int().nonnegative().optional(),
query: z.string().optional(),
})
.strict(),
handle: async (user, input) => this.getMembers(user, input),
},
topic: {
name: 'workspace.members.changed',
input: workspaceInput,
authorize: async (user, input) => {
await this.assertMembersRead(user.id, input.workspaceId);
},
room: (_user, input) => realtimeWorkspaceMembersRoom(input.workspaceId),
},
});
registerRealtimeLiveQuery(this.registry, {
request: {
name: 'workspace.invite-link.get',
input: workspaceInput,
handle: async (user, input) => ({
inviteLink: await this.getInviteLink(user, input.workspaceId),
}),
},
topic: {
name: 'workspace.invite-link.changed',
input: workspaceInput,
authorize: async (user, input) => {
await this.assertInviteManage(user.id, input.workspaceId);
},
room: (_user, input) =>
realtimeWorkspaceInviteLinkRoom(input.workspaceId),
},
});
}
@OnEvent('workspace.members.updated', { suppressError: true })
onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) {
this.publishMembers(workspaceId, 'members-updated');
}
@OnEvent('workspace.members.roleChanged', { suppressError: true })
onMemberRoleChanged({
workspaceId,
}: Events['workspace.members.roleChanged']) {
this.publishMembers(workspaceId, 'member-role-changed');
}
@OnEvent('workspace.owner.changed', { suppressError: true })
onWorkspaceOwnerChanged({ workspaceId }: Events['workspace.owner.changed']) {
this.publishMembers(workspaceId, 'owner-changed');
}
@OnEvent('workspace.invite_link.created', { suppressError: true })
onInviteLinkCreated({
workspaceId,
}: Events['workspace.invite_link.created']) {
this.publishInviteLink(workspaceId, 'invite-link-created');
}
@OnEvent('workspace.invite_link.revoked', { suppressError: true })
onInviteLinkRevoked({
workspaceId,
}: Events['workspace.invite_link.revoked']) {
this.publishInviteLink(workspaceId, 'invite-link-revoked');
}
@OnEvent('user.updated', { suppressError: true })
async onUserUpdated(user: Events['user.updated']) {
const workspaceIds = await this.models.workspaceUser.getUserWorkspaceIds(
user.id
);
for (const workspaceId of workspaceIds) {
this.publishMembers(workspaceId, 'user-updated');
}
}
private async getMembers(
user: CurrentUser,
input: {
workspaceId: string;
skip?: number;
take?: number;
query?: string;
}
) {
await this.assertMembersRead(user.id, input.workspaceId);
const pagination = {
offset: Math.max(input.skip ?? 0, 0),
first: Math.min(
Math.max(input.take ?? 8, 1),
WORKSPACE_MEMBERS_REQUEST_TAKE_MAX
),
};
if (input.query) {
if (input.query.length > 255) {
throw new QueryTooLong({ max: 255 });
}
const members = await this.models.workspaceUser.search(
input.workspaceId,
input.query,
pagination
);
return {
members: members.map(serializeWorkspaceMember),
memberCount: await this.models.workspaceUser.count(input.workspaceId),
};
}
const [members, memberCount] = await this.models.workspaceUser.paginate(
input.workspaceId,
pagination
);
return {
members: members.map(serializeWorkspaceMember),
memberCount,
};
}
private async getInviteLink(
user: CurrentUser,
workspaceId: string
): Promise<WorkspaceInviteLinkSnapshot | null> {
await this.assertInviteManage(user.id, workspaceId);
const cacheId = `workspace:inviteLink:${workspaceId}`;
const id = await this.cache.get<{ inviteId: string }>(cacheId);
if (!id) {
return null;
}
const expireTime = await this.cache.ttl(cacheId);
if (!isValidCacheTtl(expireTime)) {
return null;
}
return {
link: this.url.link(`/invite/${id.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000).toISOString(),
};
}
private async assertMembersRead(userId: string, workspaceId: string) {
await this.ac
.user(userId)
.workspace(workspaceId)
.assert('Workspace.Users.Read');
}
private async assertInviteManage(userId: string, workspaceId: string) {
await this.ac
.user(userId)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
}
private publishMembers(workspaceId: string, reason: string) {
this.publisher?.publishChanged(
'workspace.members.changed',
{ workspaceId },
reason,
{ room: realtimeWorkspaceMembersRoom(workspaceId) }
);
}
private publishInviteLink(workspaceId: string, reason: string) {
this.publisher?.publishChanged(
'workspace.invite-link.changed',
{ workspaceId },
reason,
{ room: realtimeWorkspaceInviteLinkRoom(workspaceId) }
);
}
}
@@ -425,9 +425,6 @@ class AdminUpdateWorkspaceInput extends PartialType(
) {
@Field()
id!: string;
@Field(() => [Feature], { nullable: true })
features?: WorkspaceFeatureName[];
}
@Injectable()
@@ -617,28 +614,40 @@ export class AdminWorkspaceResolver {
query,
pagination
);
return list.map(({ user, status, type }) => ({
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
role: type,
status,
}));
return list.flatMap(({ user, status, type }) =>
user
? [
{
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
role: type,
status,
},
]
: []
);
}
const [list] = await this.models.workspaceUser.paginate(
workspaceId,
pagination
);
return list.map(({ user, status, type }) => ({
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
role: type,
status,
}));
return list.flatMap(({ user, status, type }) =>
user
? [
{
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
role: type,
status,
},
]
: []
);
}
@ResolveField(() => [AdminWorkspaceSharedLink], {
@@ -654,7 +663,7 @@ export class AdminWorkspaceResolver {
}
@Mutation(() => AdminWorkspace, {
description: 'Update workspace flags and features for admin',
description: 'Update workspace flags for admin',
nullable: true,
})
async adminUpdateWorkspace(
@@ -662,27 +671,12 @@ export class AdminWorkspaceResolver {
input: AdminUpdateWorkspaceInput
) {
this.assertCloudOnly();
const { id, features, ...updates } = input;
const { id, ...updates } = input;
if (Object.keys(updates).length) {
await this.models.workspace.update(id, updates);
}
if (features) {
const current = await this.models.workspaceFeature.list(id);
const toAdd = features.filter(feature => !current.includes(feature));
const toRemove = current.filter(feature => !features.includes(feature));
await Promise.all([
...toAdd.map(feature =>
this.models.workspaceFeature.add(id, feature, 'admin panel update')
),
...toRemove.map(feature =>
this.models.workspaceFeature.remove(id, feature)
),
]);
}
const { rows } = await this.models.workspace.adminListWorkspaces({
first: 1,
skip: 0,
@@ -25,7 +25,7 @@ import {
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser } from '../../auth';
import { AccessController, WorkspacePolicyService } from '../../permission';
import { PermissionAccess } from '../../permission';
import { QuotaService } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import {
@@ -125,8 +125,7 @@ class ListedBlob {
export class WorkspaceBlobResolver {
logger = new Logger(WorkspaceBlobResolver.name);
constructor(
private readonly ac: AccessController,
private readonly policy: WorkspacePolicyService,
private readonly ac: PermissionAccess,
private readonly quota: QuotaService,
private readonly storage: WorkspaceBlobStorage,
private readonly models: Models
@@ -467,7 +466,10 @@ export class WorkspaceBlobResolver {
return false;
}
await this.policy.assertCanDeleteBlob(user.id, workspaceId);
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Blobs.Write');
await this.storage.delete(workspaceId, key, permanently);
@@ -479,7 +481,10 @@ export class WorkspaceBlobResolver {
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.policy.assertCanDeleteBlob(user.id, workspaceId);
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Blobs.Write');
await this.storage.release(workspaceId);

Some files were not shown because too many files have changed in this diff Show More