mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
734715f461 | ||
|
|
b290c1d922 | ||
|
|
dd068e31d0 | ||
|
|
90cffbfcde | ||
|
|
25acf14c8b | ||
|
|
be781c5f1b | ||
|
|
4cb0730875 | ||
|
|
00b2fcdea3 | ||
|
|
f70347b579 | ||
|
|
3d9691b11a | ||
|
|
c2fe813929 | ||
|
|
ef7fc05dcc | ||
|
|
1c2d33f149 | ||
|
|
b1fdc8d665 | ||
|
|
1ecc23cf83 | ||
|
|
7230580b18 | ||
|
|
1460751b41 | ||
|
|
4f96d371f4 | ||
|
|
c5cf1dc390 | ||
|
|
503a450b40 | ||
|
|
f4dfbd735b | ||
|
|
81402918d2 | ||
|
|
32c950de5b | ||
|
|
792361507e | ||
|
|
4f267fb83e |
No files matched your search
@@ -1,61 +0,0 @@
|
||||
# Default owner, including backend and SDK areas without a specialist below.
|
||||
# The last matching rule takes precedence.
|
||||
* @Salazareo
|
||||
|
||||
# GUI
|
||||
/src/gui/ @jelveh @Salazareo
|
||||
|
||||
# Documentation, developer site, and CLI
|
||||
/doc/ @reynaldichernando
|
||||
/src/docs/ @reynaldichernando
|
||||
/src/dev-center/ @reynaldichernando
|
||||
/src/cli/ @reynaldichernando
|
||||
|
||||
# Backend: AI, hosting, workers, networking, and WebDAV
|
||||
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/util/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland
|
||||
/src/worker/ @ProgrammerIn-wonderland
|
||||
/src/worker-types/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
|
||||
|
||||
# Backend: sharing, permissions, and teams
|
||||
/src/backend/controllers/share/ @jfcastro92
|
||||
/src/backend/controllers/team/ @jfcastro92
|
||||
/src/backend/services/share/ @jfcastro92
|
||||
/src/backend/services/permission/ @jfcastro92
|
||||
/src/backend/services/acl/ @jfcastro92
|
||||
/src/backend/services/team/ @jfcastro92
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92
|
||||
/src/backend/stores/share/ @jfcastro92
|
||||
/src/backend/stores/permission/ @jfcastro92
|
||||
/src/backend/stores/group/ @jfcastro92
|
||||
/src/backend/stores/team/ @jfcastro92
|
||||
|
||||
# SDK: AI, hosting, workers, and networking
|
||||
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
|
||||
|
||||
# SDK: sharing and permissions
|
||||
/src/puter-js/src/modules/perms/ @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
|
||||
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
|
||||
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
|
||||
@@ -1,20 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -17,10 +17,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -1,39 +1,28 @@
|
||||
name: Backend Tests
|
||||
|
||||
# Always triggers so it can be a required PR check; the `changes` job below
|
||||
# decides whether backend/extension paths actually changed (test-only changes
|
||||
# match the same globs, since tests are co-located with the code), and every
|
||||
# other job is skipped — not absent — when they didn't, which still satisfies
|
||||
# a required check. puter.js SDK changes are covered by puterjs-tests.yaml.
|
||||
# Runs on backend and extension changes. Tests are co-located with the
|
||||
# code (src/backend/**/*.test.ts, extensions/**/*.test.ts), so test-only
|
||||
# changes match the same globs. puter.js SDK changes are covered by
|
||||
# puterjs-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/backend/**'
|
||||
- 'extensions/**'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
# The type-check job below reads these.
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.build.json'
|
||||
- '.github/workflows/backend-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'src/backend/**'
|
||||
- 'extensions/**'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.build.json'
|
||||
- '.github/workflows/backend-tests.yaml'
|
||||
|
||||
# `tsconfig.build.json` builds with `noCheck: true`, so nothing else in CI
|
||||
# runs the type checker and a missing export compiles to `undefined`,
|
||||
# surfacing only when the call is finally reached at runtime. This diffs
|
||||
@@ -46,15 +35,13 @@ jobs:
|
||||
# Its own job rather than a step in `test`: that one runs a base/PR matrix for
|
||||
# coverage comparison, and the check only needs the PR ref, once.
|
||||
typecheck:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
@@ -65,19 +52,7 @@ jobs:
|
||||
- name: Type check (new errors only)
|
||||
run: npm run typecheck
|
||||
|
||||
# `needs.changes` gates the steps below, not this job, on purpose: a
|
||||
# job-level `if` on a matrix job skips the whole job as one unexpanded
|
||||
# check ("test (${{ matrix.artifact }})", literally, since there's no
|
||||
# matrix context to fill it in) instead of the two real per-artifact
|
||||
# checks a required rule pins to ("test (base)" / "test (pr)") — so the
|
||||
# required check never gets reported and the PR blocks forever. Letting
|
||||
# the matrix always expand keeps the check names stable either way.
|
||||
test:
|
||||
needs: changes
|
||||
# Overrides the default matrix naming, which would otherwise bake the
|
||||
# base/head branch name (matrix.ref) into the check name — making it a
|
||||
# different string on every PR and impossible to pin as a required check.
|
||||
name: test (${{ matrix.artifact }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -90,53 +65,49 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests with coverage
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
run: npm run test:backend -- --coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: needs.changes.outputs.backend == 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-${{ matrix.artifact }}
|
||||
path: src/backend/coverage/
|
||||
retention-days: 14
|
||||
|
||||
report-coverage:
|
||||
needs: [changes, test]
|
||||
if: always() && needs.changes.outputs.backend == 'true'
|
||||
needs: test
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-pr
|
||||
path: src/backend/coverage
|
||||
|
||||
- name: Download base coverage
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Dependabot auto-merge
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
automerge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Enable auto-merge for patch updates
|
||||
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -45,17 +45,17 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -64,11 +64,11 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sync README to Docker Hub
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
update-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config user.name github-actions
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: release
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.find-pr.outputs.number == ''
|
||||
with:
|
||||
ref: release
|
||||
|
||||
@@ -1,42 +1,28 @@
|
||||
name: Puter.js API Tests
|
||||
|
||||
# Always triggers so it can be a required PR check; the `changes` job below
|
||||
# decides whether puter.js-relevant paths actually changed (the suites live
|
||||
# in src/puter-js/tests, so test-only changes are covered by the same glob,
|
||||
# plus the pieces the runners depend on: the worker preamble, the in-memory
|
||||
# test env in testUtil, and the backend vitest config the API-tests config
|
||||
# extends), and every other job is skipped — not absent — when they didn't,
|
||||
# which still satisfies a required check. Backend and extension changes run
|
||||
# backend-tests.yaml instead.
|
||||
# Runs on puter.js SDK changes — the suites live in src/puter-js/tests,
|
||||
# so test changes are covered by the same glob — plus the pieces the
|
||||
# runners depend on: the worker preamble, the in-memory test env in
|
||||
# testUtil, and the backend vitest config the API-tests config extends.
|
||||
# Backend and extension changes run backend-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/puter-js/**'
|
||||
- 'src/worker/**'
|
||||
- 'src/backend/testUtil.ts'
|
||||
- 'src/backend/vitest.config.ts'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/puterjs-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
puterjs: ${{ steps.filter.outputs.puterjs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
puterjs:
|
||||
- 'src/puter-js/**'
|
||||
- 'src/worker/**'
|
||||
- 'src/backend/testUtil.ts'
|
||||
- 'src/backend/vitest.config.ts'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/puterjs-tests.yaml'
|
||||
|
||||
# The JSDoc in src/puter-js/src is the source of truth for the SDK's public
|
||||
# types. The declarations shipped to npm are generated from it at build time
|
||||
# and never committed, so what needs guarding is the JSDoc itself: this
|
||||
@@ -46,16 +32,14 @@ jobs:
|
||||
# Its own job rather than a step in `test`: that one builds bundles and
|
||||
# installs a browser, and this needs neither.
|
||||
types:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
@@ -67,16 +51,14 @@ jobs:
|
||||
run: npm run check:puterjs:types
|
||||
|
||||
test:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
@@ -101,18 +83,7 @@ jobs:
|
||||
# SDK coverage on both refs, reported as a PR comment — same shape as
|
||||
# backend-tests.yaml. This run uses the istanbul-instrumented bundle
|
||||
# (the `test` job above keeps exercising the production build).
|
||||
# `needs.changes` gates the steps below, not this job, on purpose: a
|
||||
# job-level `if` on a matrix job skips the whole job as one unexpanded
|
||||
# check ("coverage (${{ matrix.artifact }})", literally, since there's no
|
||||
# matrix context to fill it in) instead of two real per-artifact checks —
|
||||
# confusing to read, and unusable if this ever gets pinned as a required
|
||||
# check. Letting the matrix always expand keeps the check names stable.
|
||||
coverage:
|
||||
needs: changes
|
||||
# Overrides the default matrix naming, which would otherwise bake the
|
||||
# base/head branch name (matrix.ref) into the check name — making it a
|
||||
# different string on every PR and impossible to pin as a required check.
|
||||
name: coverage (${{ matrix.artifact }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -125,39 +96,34 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright chromium
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# The base leg is best-effort — it only enriches the PR comment with
|
||||
# a comparison, and the base ref may predate the coverage script.
|
||||
- name: Run puter.js API tests with coverage
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
continue-on-error: ${{ matrix.artifact == 'base' }}
|
||||
run: npm run test:puterjs:coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: needs.changes.outputs.puterjs == 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-${{ matrix.artifact }}
|
||||
path: src/puter-js/coverage/coverage-*.json
|
||||
@@ -165,15 +131,15 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
|
||||
report-coverage:
|
||||
needs: [changes, coverage]
|
||||
if: always() && needs.changes.outputs.puterjs == 'true'
|
||||
needs: coverage
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
path: src/puter-js/coverage
|
||||
@@ -183,7 +149,7 @@ jobs:
|
||||
- name: Download base coverage
|
||||
id: base-coverage
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
@@ -45,14 +45,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create alias tag
|
||||
env:
|
||||
|
||||
@@ -35,8 +35,6 @@ These apply everywhere — backend, puter.js, and GUI.
|
||||
|
||||
Keep comments light; prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. Use `//` for single lines and `/** ... */` JSDoc when it genuinely needs more — if a comment runs long, it's probably too long. Don't restate the code and don't reference the current task, PR, or version — those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names; describe the why in domain terms, not project-management terms. Use plain ASCII `-` in comment section dividers (`// -- Section --`), never box-drawing characters.
|
||||
|
||||
**Be concise — a comment is a sentence or two, not a paragraph block.** One or two lines for `//`, a few for JSDoc. Don't narrate the history ("used to swallow the failure, which meant…"), don't argue the design at length, and don't explain each parameter when the signature already does. State the constraint and stop. If the reasoning genuinely needs paragraphs, it belongs in a design doc, not above the function.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
Before opening a PR, scan the diff for:
|
||||
@@ -54,7 +52,6 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
|
||||
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo or dead import in files you're already touching; don't ride a refactor along with a bug fix.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
|
||||
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/util/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
|
||||
|
||||
---
|
||||
|
||||
@@ -94,7 +91,6 @@ Follow the same layered structure inside an extension — unless it only needs a
|
||||
- Vitest; test files sit next to the code they test (`*.test.ts` / `*.test.js`). Run with `npm run test:backend`.
|
||||
- **Mock data, not methods.** Stub inputs (fixtures, fake rows, payloads), not the function under test or the layer beneath it — over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service).
|
||||
- **Prefer the test server over mocking deps.** `setupPuterTestEnv()` in [src/backend/testUtil.ts](src/backend/testUtil.ts) boots a fully in-memory backend; hit a real database/client shape where reasonable — integration shapes catch what mocked unit tests miss.
|
||||
- **Test code, not docs.** Never write a test that reads a file under `src/docs/` and asserts on its wording or numbers. Docs are kept in step by the PR (see the limits rule above) and checked in review; a test that greps a markdown page fails on every rewording and verifies nothing about behavior.
|
||||
|
||||
---
|
||||
|
||||
@@ -115,15 +111,17 @@ Declare a shape where it belongs, and reference it with an `import(...)` type fr
|
||||
Use `@typedef {Object}` + `@property` for any shape whose fields need documenting — it is the only JSDoc form that carries a doc comment per field into the generated declaration. Keep the inline object-literal form for small internal shapes with nothing to say about each field, and prefer `unknown` over `*`:
|
||||
|
||||
```js
|
||||
/** @typedef {{ key: string; value: unknown }} KVEntry */
|
||||
/** @typedef {{ key: string, value: unknown }} KVEntry */
|
||||
```
|
||||
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads _are_ the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads *are* the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
|
||||
|
||||
Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example:
|
||||
|
||||
```js
|
||||
/** @typedef {{ key: string; value: unknown }} KVEntry */
|
||||
/**
|
||||
* @typedef {{key:string, value: unknown}} KVEntry
|
||||
*/
|
||||
|
||||
/** @type {KVEntry[]} */
|
||||
let entries = [];
|
||||
|
||||
+35
-39
@@ -6,33 +6,33 @@ We at **Puter** are committed to maintaining a secure experience for our users a
|
||||
|
||||
The following are in scope for this program:
|
||||
|
||||
- **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
- **`puter.com`**
|
||||
- **`api.puter.com`**
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
* **`puter.com`**
|
||||
* **`api.puter.com`**
|
||||
|
||||
Out-of-scope:
|
||||
|
||||
- Third-party services, applications, or libraries not maintained by Puter.
|
||||
- Social engineering attacks (e.g., phishing against staff).
|
||||
- Denial of Service (DoS), spam, or volumetric attacks.
|
||||
- Physical security issues.
|
||||
* Third-party services, applications, or libraries not maintained by Puter.
|
||||
* Social engineering attacks (e.g., phishing against staff).
|
||||
* Denial of Service (DoS), spam, or volumetric attacks.
|
||||
* Physical security issues.
|
||||
|
||||
## Known Non-Issues (Please Check Before Submitting)
|
||||
|
||||
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
|
||||
|
||||
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we _do_ evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
- **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
- **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
- **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
- **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
- **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
- **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
* **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
* **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
* **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
* **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
* **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
* **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
* **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
|
||||
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
|
||||
|
||||
@@ -40,7 +40,7 @@ If you believe you have a **genuinely new** exploit chain that defeats one of th
|
||||
|
||||
To participate, you must:
|
||||
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see _Proof of Reproduction_ below).
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
|
||||
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
|
||||
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
|
||||
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
|
||||
@@ -51,30 +51,26 @@ Reports that do not meet these guidelines may not be eligible for a reward.
|
||||
|
||||
## Proof of Reproduction
|
||||
|
||||
Reports **MUST** demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
|
||||
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
- The **observed** result versus the **expected** result.
|
||||
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
|
||||
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
* The **observed** result versus the **expected** result.
|
||||
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
* For server-side bugs: a runnable proof-of-concept.
|
||||
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
|
||||
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
|
||||
Please submit **one issue per report**.
|
||||
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
|
||||
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
|
||||
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
|
||||
Include:
|
||||
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested remediation (if available)
|
||||
- Paypal id where we can send the bounty (if eligible)
|
||||
* A description of the vulnerability
|
||||
* Steps to reproduce
|
||||
* Potential impact
|
||||
* Suggested remediation (if available)
|
||||
|
||||
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
|
||||
|
||||
@@ -82,10 +78,10 @@ We aim to acknowledge receipt within **72 hours** and provide a resolution timel
|
||||
|
||||
We offer monetary rewards based on the severity of the vulnerability, as determined by our internal assessment (using CVSS as a guide).
|
||||
|
||||
- **Critical: \$1,000 – \$2,000**
|
||||
- **High: \$500 – \$1,000**
|
||||
- **Medium: \$200 – \$500**
|
||||
- **Low: \$50 – \$100**
|
||||
* **Critical: \$1,000 – \$2,000**
|
||||
* **High: \$500 – \$1,000**
|
||||
* **Medium: \$200 – \$500**
|
||||
* **Low: \$50 – \$100**
|
||||
|
||||
Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward.
|
||||
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive.
|
||||
|
||||
@@ -32,8 +32,6 @@ Don't commit code you couldn't have written, debugged, or defended yourself. Rea
|
||||
|
||||
If you add or change a public API (an endpoint, driver method, or puter-js method), follow [doc/contributing-apis.md](doc/contributing-apis.md) — backward compatibility, [developer docs](src/docs/), types, and tests all move in the same PR.
|
||||
|
||||
The same rule applies to limits: rate limits, concurrency caps, quotas, and allowances are published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md), and a PR that changes one of those numbers updates that page in the same PR. A limit nobody published is a limit developers only discover as a service failure.
|
||||
|
||||
## 6. Boy Scout Rule — leave it 1% better
|
||||
|
||||

|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
·
|
||||
<a href="https://developer.puter.com" target="_blank">Developers</a>
|
||||
·
|
||||
<a href="https://discord.com/invite/PQcx7Teh8u">Discord</a>
|
||||
·
|
||||
<a href="https://reddit.com/r/puter">Reddit</a>
|
||||
·
|
||||
<a href="https://twitter.com/HeyPuter">X</a>
|
||||
</p>
|
||||
|
||||
@@ -78,7 +82,10 @@ Puter is available as a hosted service at [**puter.com**](https://puter.com).
|
||||
Connect with the maintainers and community through these channels:
|
||||
|
||||
- Bug report or feature request? Please [open an issue](https://github.com/HeyPuter/puter/issues/new/choose).
|
||||
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
|
||||
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
|
||||
- Security issues or abuse reports? [[email protected]](mailto:[email protected])
|
||||
- Email maintainers at [[email protected]](mailto:[email protected])
|
||||
|
||||
@@ -113,9 +120,7 @@ This repository, including all its contents, sub-projects, modules, and componen
|
||||
- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md)
|
||||
- [Malay / Bahasa Malaysia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.my.md)
|
||||
- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md)
|
||||
- [Dutch / Nederlands](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.nl.md)
|
||||
- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md)
|
||||
- [Odia / ଓଡ଼ିଆ](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.od.md)
|
||||
- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md)
|
||||
- [Punjabi / ਪੰਜਾਬੀ](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pa.md)
|
||||
- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md)
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"disable_user_signup": false,
|
||||
"teams_enabled": false,
|
||||
"max_teams_per_user": 1,
|
||||
"max_seats_per_team": 50,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
+3
-70
@@ -251,53 +251,6 @@
|
||||
}
|
||||
},
|
||||
|
||||
// ── Sharing ─────────────────────────────────────────────────────────
|
||||
// Email a recipient who already has an account about a new share. On
|
||||
// unless set to false; they opt out via the unsubscribe link, or by
|
||||
// blocking a sender. An invite to an address with no account always goes.
|
||||
// "share_email_notifications": false,
|
||||
//
|
||||
// New shares one account may create per UTC day. Default 200.
|
||||
// "share_daily_limit": 200,
|
||||
//
|
||||
// How often a share may interrupt its recipient — the notification pushed
|
||||
// to their screen and the email with it. Over budget the share still
|
||||
// succeeds and their notification is still brought up to date; only the
|
||||
// interruption is dropped. The recipient bounds are what stop many senders
|
||||
// burying one person between them. A non-positive value removes a bound.
|
||||
// "share_notify_limits": {
|
||||
// "pairWindowSeconds": 900,
|
||||
// "pairDaily": 20,
|
||||
// "recipientHourly": 10,
|
||||
// "recipientDaily": 50,
|
||||
// "emailBatchSeconds": 90
|
||||
// },
|
||||
|
||||
// ── Teams / teams ──────────────────────────────────────────────
|
||||
// Off by default, and off is the state every existing install stays in.
|
||||
// The tables ship either way and sit inert; this gates whether `/teams`
|
||||
// is registered at all, so with it off the paths do not exist rather than
|
||||
// existing and refusing. It is also the backout: turning it off removes
|
||||
// the feature without touching data.
|
||||
"teams_enabled": false,
|
||||
//
|
||||
// Live teams one user may own. Soft-deleted ones do not count, so
|
||||
// deleting yours frees the slot. Default 1.
|
||||
"max_teams_per_user": 1,
|
||||
//
|
||||
// Seats one team may provision. Each seat is a real Puter account
|
||||
// taking a name from the global username pool, so this is what bounds a
|
||||
// team's blast radius until per-seat billing lands. Lowering it below
|
||||
// a team's current seat count blocks new provisioning and disables
|
||||
// nobody. Default 50.
|
||||
"max_seats_per_team": 50,
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────
|
||||
// How long a notification is kept, in days from creation. Acknowledged or
|
||||
// not, a row past this is swept. Off by default; any value above 0 arms
|
||||
// the sweep.
|
||||
// "notificationRetentionDays": 14,
|
||||
|
||||
// ── Alarms / alerting ───────────────────────────────────────────────
|
||||
// Where system alarms go. Severity is the routing decision — each
|
||||
// transport takes everything at or above its own `minSeverity`:
|
||||
@@ -371,10 +324,6 @@
|
||||
"apiURL": ""
|
||||
},
|
||||
"gemini": { "apiKey": "" },
|
||||
// Meta Model API (Muse Spark). `muse-spark-1.2-contributor`
|
||||
// trades a ~12x discount for Meta training on its prompts and
|
||||
// completions, so it is only served to callers that name it.
|
||||
"meta": { "apiKey": "" },
|
||||
"groq": { "apiKey": "" },
|
||||
"deepseek": { "apiKey": "" },
|
||||
"mistral": { "apiKey": "" },
|
||||
@@ -392,20 +341,6 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://llm.onerouter.pro/v1"
|
||||
},
|
||||
// BytePlus ModelArk. One key powers chat (Seed/GLM/DeepSeek), image
|
||||
// generation (Seedream) and video generation (Seedance); `apiBaseUrl`
|
||||
// selects the region; see
|
||||
// https://docs.byteplus.com/en/docs/ModelArk/1330310 for options.
|
||||
"byteplus": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3"
|
||||
},
|
||||
"zai": { "apiKey": "" },
|
||||
"alibaba": { "apiKey": "" },
|
||||
"hoonify": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.hoonify.ai/v1"
|
||||
},
|
||||
"together-ai": { "apiKey": "" },
|
||||
// Local Ollama. `enabled: false` skips the auto-probe at startup
|
||||
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
|
||||
@@ -427,6 +362,7 @@
|
||||
"xai-image-generation": { "apiKey": "" },
|
||||
|
||||
// ─ Video generation ─
|
||||
"openai-video-generation": { "apiKey": "" },
|
||||
"together-video-generation": { "apiKey": "" },
|
||||
"gemini-video-generation": { "apiKey": "" },
|
||||
|
||||
@@ -512,11 +448,8 @@
|
||||
// operations that spend it — file transfers, KV calls. Recording is
|
||||
// unaffected either way. `workers` extends the same refusal to
|
||||
// worker-driven calls, which are exempt by default because a deployed
|
||||
// worker has nowhere to surface a payment prompt. `subscriptions` covers
|
||||
// the separate question of surfaces reserved for paid plans (the
|
||||
// vendor-compatible AI endpoints): turn it off on a deployment with no
|
||||
// paid plans, or every account is refused them.
|
||||
// "meteringEnforcement": { "enabled": true, "workers": false, "subscriptions": true },
|
||||
// worker has nowhere to surface a payment prompt.
|
||||
// "meteringEnforcement": { "enabled": true, "workers": false },
|
||||
|
||||
// Fleet-wide spend rate, in micro-cents per minute, past which metering
|
||||
// raises the `metering:excessiveGlobalUsageRate` alarm. Omit it (the
|
||||
|
||||
+2
-22
@@ -44,15 +44,7 @@ pass one explicitly unless you really mean "page someone".
|
||||
event; the 429 is the whole signal, and alarming on it only produces noise
|
||||
proportional to traffic. An *upstream provider* rate-limiting us is the
|
||||
opposite case and still alarms (`upstream_rate_limited`, `info`) — that one
|
||||
is not something we chose. The exception is a free model: nothing is billed,
|
||||
nothing is actionable, and the throttling is the price of the model, so the
|
||||
AI chat driver marks those `noAlarm` and only paid models still record.
|
||||
|
||||
`noAlarm` is the code-level mute: an `HttpError` carrying it skips the
|
||||
terminal gate entirely, no matter its status or code. It is for failures the
|
||||
call site *already knows* are expected and traffic-proportional — reach for it
|
||||
there, not as a way to quiet an alarm you haven't diagnosed
|
||||
(`severityOverrides` below is the knob for that).
|
||||
is not something we chose.
|
||||
|
||||
An extension whose signals are all one tier can default its own local
|
||||
`raiseAlarm` helper to that tier instead of repeating it at every call site —
|
||||
@@ -78,11 +70,6 @@ this.clients.alarm.create(alarmId, message, fields, 'critical', {
|
||||
|
||||
The HTTP error handler uses it: its id is route + error signature, so a hot
|
||||
loop of the same crash is one incident with N occurrences instead of N pages.
|
||||
Dedup and Slack's repeat throttle mean a responder sees only the latest
|
||||
occurrence, so the handler attaches the `HttpError`'s `fields` to the alarm as
|
||||
`details` — put what a responder needs (which upstreams failed, and how)
|
||||
there. Everything in `fields` is also returned to the client in the response
|
||||
body, so it has to be safe to show the caller.
|
||||
Reach for it anywhere else only when the id is that specific — otherwise a
|
||||
per-request alarm can flood the pager.
|
||||
|
||||
@@ -126,14 +113,7 @@ config always has the last word.
|
||||
|
||||
### Repeat throttling
|
||||
|
||||
Two levels, and they stack.
|
||||
|
||||
The client itself backs off before a repeat is reported at all: every
|
||||
occurrence is reported up to a small burst, then one per interval. A fault
|
||||
recurring hundreds of times a second still counts every occurrence, but it
|
||||
does not write a log line or build an alert payload for each one.
|
||||
|
||||
Below that, the chat transport won't repost the same alarm id within
|
||||
The chat transport won't repost the same alarm id within
|
||||
`repeatThrottleMs` (default 15 minutes). The first occurrence always posts,
|
||||
and the next one that gets through reports how many piled up in between —
|
||||
so a hot loop reads as one message with a count, not a wall of them.
|
||||
@@ -96,18 +96,6 @@ extension.on('user.signup', (_key, data) => {
|
||||
});
|
||||
```
|
||||
|
||||
The `app.recommended` event lets extensions customize desktop recommendations.
|
||||
Its `appNames` array starts as a fresh copy of the default ordered names on each
|
||||
call. Listeners may mutate or replace it, including setting it to an empty array.
|
||||
Async listeners are awaited before names are resolved to app summaries; names
|
||||
that do not exist are skipped.
|
||||
|
||||
```ts
|
||||
extension.on('app.recommended', (_key, data) => {
|
||||
data.appNames = ['editor', 'camera'];
|
||||
});
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript preferred** in new code where feasible. Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
|
||||
@@ -53,10 +53,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
#### Middleware and gates
|
||||
|
||||
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
|
||||
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
@@ -122,10 +122,6 @@ cat > puter/config/config.json <<EOF
|
||||
"ollama": { "enabled": false }
|
||||
},
|
||||
|
||||
"meteringEnforcement": {
|
||||
"subscriptions": false
|
||||
},
|
||||
|
||||
"trust_proxy": 1
|
||||
}
|
||||
EOF
|
||||
@@ -140,7 +136,6 @@ Why these knobs:
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
|
||||
- `meteringEnforcement.subscriptions: false` — a few surfaces (the OpenAI/Anthropic-compatible AI endpoints) are declared paid-plan-only. A self-hosted install has no paid plans, so every account sits on the free policy and those routes would answer `402 subscription_required` for everyone. Turning the plan gates off opens them; nothing else about metering changes.
|
||||
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
|
||||
- `s3.s3Config.forcePathStyle: true` — RustFS / MinIO / fauxqs need path-style URLs (`<endpoint>/<bucket>`). Real AWS S3 wants virtual-hosted (`<bucket>.<endpoint>`) — drop this flag (or set `false`) when you swap to real S3.
|
||||
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. Caddy routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
|
||||
@@ -300,18 +295,6 @@ Used for password resets, email confirmation, and notifications. Without it thos
|
||||
|
||||
To require email confirmation before login, also set `"strict_email_verification_required": true`.
|
||||
|
||||
To read the mail yourself instead of sending it, point the transport at a local SMTP sink — [MailHog](https://github.com/mailhog/MailHog) catches everything and serves it at `http://localhost:8025`. Publish both ports; a container that only exposes them is unreachable from the host:
|
||||
|
||||
```sh
|
||||
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
|
||||
```
|
||||
|
||||
```json
|
||||
"email": { "from": "\"Puter\" <[email protected]>", "host": "127.0.0.1", "port": 1025, "secure": false, "ignoreTLS": true }
|
||||
```
|
||||
|
||||
Share email is on once a transport exists: a recipient who already has an account is emailed as well as notified in-app, and they opt out with the unsubscribe link the mail carries. Set `"share_email_notifications": false` to keep those in the app only — an address with no account is still emailed, since there is nothing else to reach them with.
|
||||
|
||||
### Sign in with Google (or another OIDC provider)
|
||||
|
||||
```json
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { extension } from '../src/backend/extensions.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { handleInstalledApps } from './installedApps.ts';
|
||||
@@ -52,14 +51,13 @@ const seedUser = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const seedApp = async (ownerUserId: number, overrides = {}) => {
|
||||
const seedApp = async (ownerUserId: number) => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.app.create(
|
||||
{
|
||||
name: `iaapp_${slug}`,
|
||||
title: `Installed App ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
...overrides,
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
@@ -147,43 +145,6 @@ describe('installedApps extension — handleInstalledApps', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('pairs iconUrl with the direct subdomain URL for a generated icon', async () => {
|
||||
const user = await seedUser();
|
||||
// The resize pipeline leaves an http(s) icon column behind, which is
|
||||
// what says the sized PNG is on the icons subdomain.
|
||||
const app = await seedApp(user.id as number, {
|
||||
icon: 'https://api.puter.com/app-icon/x',
|
||||
});
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
const { protocol, static_hosting_domain } = extension.config;
|
||||
expect(list[0].iconCdnUrl).toBe(
|
||||
`${protocol}://puter-app-icons.${static_hosting_domain}/${app!.uid}-256.png`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports no subdomain URL for an app with no generated icon', async () => {
|
||||
const user = await seedUser();
|
||||
const app = await seedApp(user.id as number);
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
expect(list[0].iconCdnUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('flags apps with no owner_user_id as external', async () => {
|
||||
const user = await seedUser();
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
@@ -2,10 +2,7 @@ import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import {
|
||||
getAppIconCdnUrl,
|
||||
getAppIconUrl,
|
||||
} from '@heyputer/backend/src/util/appIcon.js';
|
||||
import { getAppIconUrl } from '@heyputer/backend/src/util/appIcon.js';
|
||||
|
||||
const clients = extension.import('client');
|
||||
|
||||
@@ -76,8 +73,6 @@ export const handleInstalledApps = async (
|
||||
return {
|
||||
...rest,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
// Direct subdomain URL for the client to try before iconUrl.
|
||||
iconCdnUrl: getAppIconCdnUrl(app, extension.config),
|
||||
external,
|
||||
};
|
||||
}),
|
||||
|
||||
+3
-107
@@ -6,10 +6,6 @@ import {
|
||||
servicesContainers,
|
||||
} from '@heyputer/backend/src/exports';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import {
|
||||
creditMultiplierFrom,
|
||||
toCredits,
|
||||
} from '@heyputer/backend/src/services/metering/utils';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const services = extension.import('service');
|
||||
@@ -54,42 +50,6 @@ function collectAllCosts(): Record<string, unknown>[] {
|
||||
return all;
|
||||
}
|
||||
|
||||
// -- Credits scaling ---------------------------------------------------
|
||||
//
|
||||
// When the deployment configures a credit multiplier, every monetary amount
|
||||
// is scaled to display credits BEFORE it leaves the server — raw metered
|
||||
// amounts are never reported. Counts, units, and byte figures pass through
|
||||
// untouched. A response that was scaled says so (`unit: 'credits'`); without
|
||||
// a configured multiplier amounts pass through raw and clients render them
|
||||
// as dollars.
|
||||
|
||||
/** The per-usage-type records of a UsageByType map, costs scaled. */
|
||||
const scaleUsageByType = (
|
||||
usage: Record<string, unknown>,
|
||||
multiplier: number,
|
||||
): Record<string, unknown> => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(usage)) {
|
||||
// `allowanceUsed` is the month's allowance-charged spend — monetary,
|
||||
// scalar, scaled like the total.
|
||||
if (key === 'total' || key === 'allowanceUsed') {
|
||||
out[key] = toCredits(Number(value) || 0, multiplier);
|
||||
} else if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
typeof (value as { cost?: unknown }).cost === 'number'
|
||||
) {
|
||||
out[key] = {
|
||||
...(value as Record<string, unknown>),
|
||||
cost: toCredits((value as { cost: number }).cost, multiplier),
|
||||
};
|
||||
} else {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const handleMeteringUsage = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
@@ -101,44 +61,7 @@ export const handleMeteringUsage = async (
|
||||
services.metering.getActorCurrentMonthUsageDetails(actor),
|
||||
services.metering.getAllowedUsage(actor),
|
||||
]);
|
||||
const multiplier = creditMultiplierFrom(extension.config);
|
||||
if (!multiplier) {
|
||||
res.json({ ...actorUsage, allowanceInfo });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
usage: scaleUsageByType(
|
||||
actorUsage.usage as unknown as Record<string, unknown>,
|
||||
multiplier,
|
||||
),
|
||||
appTotals: Object.fromEntries(
|
||||
Object.entries(actorUsage.appTotals).map(([appId, totals]) => [
|
||||
appId,
|
||||
{ ...totals, total: toCredits(totals.total, multiplier) },
|
||||
]),
|
||||
),
|
||||
allowanceInfo: {
|
||||
...allowanceInfo,
|
||||
remaining: toCredits(allowanceInfo.remaining, multiplier),
|
||||
monthUsageAllowance: toCredits(
|
||||
allowanceInfo.monthUsageAllowance,
|
||||
multiplier,
|
||||
),
|
||||
// Monetary addon fields scale; absent ones stay absent rather
|
||||
// than materializing as NaN/null. purchasedStorage is bytes.
|
||||
addons: Object.fromEntries(
|
||||
Object.entries(allowanceInfo.addons ?? {}).map(([k, v]) => [
|
||||
k,
|
||||
(k === 'purchasedCredits' ||
|
||||
k === 'consumedPurchaseCredits') &&
|
||||
typeof v === 'number'
|
||||
? toCredits(v, multiplier)
|
||||
: v,
|
||||
]),
|
||||
),
|
||||
unit: 'credits',
|
||||
},
|
||||
});
|
||||
res.json({ ...actorUsage, allowanceInfo });
|
||||
};
|
||||
|
||||
export const handleMeteringUsageForApp = async (
|
||||
@@ -169,18 +92,7 @@ export const handleMeteringUsageForApp = async (
|
||||
actor,
|
||||
appId,
|
||||
);
|
||||
const multiplier = creditMultiplierFrom(extension.config);
|
||||
if (!multiplier) {
|
||||
res.json(appUsage);
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
...scaleUsageByType(
|
||||
appUsage as unknown as Record<string, unknown>,
|
||||
multiplier,
|
||||
),
|
||||
unit: 'credits',
|
||||
});
|
||||
res.json(appUsage);
|
||||
};
|
||||
|
||||
export const handleMeteringGlobalUsage = async (
|
||||
@@ -197,23 +109,7 @@ export const handleMeteringAllCosts = async (
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
if (!cachedAllCosts) {
|
||||
const raw = collectAllCosts();
|
||||
const multiplier = creditMultiplierFrom(extension.config);
|
||||
// Price the catalogue in the same unit the usage endpoints report:
|
||||
// per-unit credits when a multiplier is configured, raw otherwise.
|
||||
cachedAllCosts = multiplier
|
||||
? raw.map(({ ucentsPerUnit, costs_currency: _cc, ...rest }) =>
|
||||
typeof ucentsPerUnit === 'number'
|
||||
? {
|
||||
...rest,
|
||||
creditsPerUnit: toCredits(
|
||||
ucentsPerUnit,
|
||||
multiplier,
|
||||
),
|
||||
}
|
||||
: rest,
|
||||
)
|
||||
: raw;
|
||||
cachedAllCosts = collectAllCosts();
|
||||
}
|
||||
res.json({ costs: cachedAllCosts });
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { configContainer } from '../src/backend/exports.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { handleWhoami } from './whoami.ts';
|
||||
@@ -119,50 +118,6 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body).toHaveProperty('taskbar_items');
|
||||
});
|
||||
|
||||
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
|
||||
const user = await server.stores.user.create({
|
||||
username: `wuser_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashedpw',
|
||||
email: `${Math.random().toString(36).slice(2, 8)}@example.com`,
|
||||
requires_phone_verification: true,
|
||||
} as never);
|
||||
const prev = configContainer.phone_verification_card_fallback;
|
||||
configContainer.phone_verification_card_fallback = {
|
||||
enabled: true,
|
||||
} as never;
|
||||
try {
|
||||
const before = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), before.res),
|
||||
);
|
||||
// Phone-gated, but the user has attempts left — no offer yet.
|
||||
expect(
|
||||
before.captured.body as Record<string, unknown>,
|
||||
).toMatchObject({ card_fallback_available: false });
|
||||
|
||||
// Exhausting SMS attempts stamps this flag; whoami is then the only
|
||||
// thing that can still tell a reloading GUI about the offer, since
|
||||
// further sends are rejected by the route's own rate limit.
|
||||
await server.stores.kv.set({
|
||||
key: `card-fallback-open:${user.id}`,
|
||||
value: true,
|
||||
});
|
||||
|
||||
const after = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), after.res),
|
||||
);
|
||||
expect(
|
||||
after.captured.body as Record<string, unknown>,
|
||||
).toMatchObject({ card_fallback_available: true });
|
||||
} finally {
|
||||
configContainer.phone_verification_card_fallback = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('only forwards allow-listed feature flags', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -251,84 +206,6 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||||
});
|
||||
|
||||
it('never exposes phone, card fingerprint or signup identity', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.update(user.id as number, {
|
||||
phone: '+15551234567',
|
||||
card_fingerprint: 'fp_ABC123',
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
});
|
||||
// Guard against a vacuous assertion below: the columns really do hold
|
||||
// the values we then expect never to see on the wire.
|
||||
const stored = await server.stores.user.getById(user.id as number, {
|
||||
cached: false,
|
||||
force: true,
|
||||
});
|
||||
expect(stored?.phone).toBe('+15551234567');
|
||||
expect(stored?.card_fingerprint).toBe('fp_ABC123');
|
||||
|
||||
for (const actor of [
|
||||
{ user: { uuid: user.uuid, id: user.id as number } },
|
||||
{
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.phone).toBeUndefined();
|
||||
expect(body.card_fingerprint).toBeUndefined();
|
||||
expect(body.password).toBeUndefined();
|
||||
expect(body.otp_secret).toBeUndefined();
|
||||
expect(body.signup_ip).toBeUndefined();
|
||||
// Not just absent as a key — the values must not appear anywhere
|
||||
// in the payload (nested under metadata, taskbar items, …).
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain('5551234567');
|
||||
expect(serialized).not.toContain('fp_ABC123');
|
||||
// The verification flags, which the GUI acts on, still ship.
|
||||
expect(body).toHaveProperty('requires_phone_verification');
|
||||
expect(body).toHaveProperty('requires_card_verification');
|
||||
}
|
||||
});
|
||||
|
||||
it('scrubs sensitive keys added to metadata, and leaves the cached row intact', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
billing: { card_fingerprint: 'fp_NESTED', tier: 'pro' },
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Nested sensitive keys are removed too; siblings survive.
|
||||
expect(metadata.billing).toEqual({ tier: 'pro' });
|
||||
expect(JSON.stringify(body)).not.toContain('fp_NESTED');
|
||||
|
||||
// The scrub works on a copy — server-side state is untouched.
|
||||
const fresh = await server.stores.user.getById(user.id as number, {
|
||||
cached: false,
|
||||
force: true,
|
||||
});
|
||||
expect(fresh?.metadata?.tmp_password).toBe('bootstrap-secret');
|
||||
expect(
|
||||
(fresh?.metadata?.billing as Record<string, unknown>)
|
||||
?.card_fingerprint,
|
||||
).toBe('fp_NESTED');
|
||||
});
|
||||
|
||||
it('marks the user as oidc_only when password is null', async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const oidcUser = await server.stores.user.create({
|
||||
|
||||
+6
-113
@@ -1,7 +1,5 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
@@ -35,68 +33,6 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
|
||||
'prompt_user_when_navigation_away_from_puter',
|
||||
]);
|
||||
|
||||
// Keys that must never leave the server, whoever put them on the response.
|
||||
// `details` is an explicit pick, but the `whoami.details` event hands
|
||||
// listeners the full UserRow next to the object they may write to, and
|
||||
// `metadata` is a free-form blob — so any of these can arrive on the
|
||||
// response without an edit to the pick above. The scrub runs last, over the
|
||||
// whole payload, and is the one place that decides what "sensitive" means.
|
||||
//
|
||||
// Credentials and single-use tokens, the payment/phone identifiers used for
|
||||
// verification (`card_fingerprint` is the Stripe fingerprint, stable per
|
||||
// card number), the network identity recorded at signup, and internal
|
||||
// anti-abuse bookkeeping. `requires_phone_verification` /
|
||||
// `requires_card_verification` stay: they are the flags the GUI acts on, and
|
||||
// they carry no identifier.
|
||||
const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
|
||||
'password',
|
||||
'tmp_password',
|
||||
'pass_recovery_token',
|
||||
'email_confirm_code',
|
||||
'email_confirm_token',
|
||||
'change_email_confirm_token',
|
||||
'otp_secret',
|
||||
'otp_recovery_codes',
|
||||
'card_fingerprint',
|
||||
'phone',
|
||||
'clean_email',
|
||||
'signup_ip',
|
||||
'signup_ip_forwarded',
|
||||
'signup_user_agent',
|
||||
'signup_origin',
|
||||
'signup_server',
|
||||
'audit_metadata',
|
||||
]);
|
||||
|
||||
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
|
||||
// at any level (`metadata` and `taskbar_items` are both nested structures).
|
||||
const scrubSensitive = (
|
||||
value: unknown,
|
||||
seen: Set<object> = new Set(),
|
||||
depth = 0,
|
||||
): void => {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') return;
|
||||
if (seen.has(value as object)) return;
|
||||
seen.add(value as object);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) scrubSensitive(entry, seen, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
delete (value as Record<string, unknown>)[key];
|
||||
continue;
|
||||
}
|
||||
scrubSensitive(
|
||||
(value as Record<string, unknown>)[key],
|
||||
seen,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleWhoami = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -115,12 +51,13 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const oidcOnly = user.password === null;
|
||||
const ALLOWED_ICON_SIZES = new Set([16, 32, 64, 128, 256, 512]);
|
||||
const rawIconSize =
|
||||
typeof req.query?.icon_size === 'string'
|
||||
? Number(req.query.icon_size)
|
||||
: undefined;
|
||||
const iconSize =
|
||||
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
|
||||
rawIconSize !== undefined && ALLOWED_ICON_SIZES.has(rawIconSize)
|
||||
? rawIconSize
|
||||
: undefined;
|
||||
const noIcons = !iconSize;
|
||||
@@ -137,12 +74,8 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-copied (it is decoded JSON) so the scrub below edits the response
|
||||
// and not the cached UserRow. Sensitive keys inside it — tmp_password and
|
||||
// anything else on the denylist — are removed by scrubSensitive.
|
||||
const metadata = user.metadata
|
||||
? structuredClone(user.metadata)
|
||||
: user.metadata;
|
||||
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
|
||||
if (metadata) delete metadata.tmp_password;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
@@ -151,47 +84,14 @@ export const handleWhoami = async (
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
// The phone number itself is deliberately absent: nothing on the
|
||||
// client reads it, and it is PII that would otherwise be handed to
|
||||
// every app actor. Only the verification flag ships.
|
||||
phone: user.phone,
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
// The SMS-to-card escape hatch: true once this user is out of SMS send
|
||||
// attempts and may verify a card instead. It has to ship from here
|
||||
// because /send-confirm-phone can no longer say so — by the time the
|
||||
// fallback opens, further sends are rejected by that route's own rate
|
||||
// limit before any handler runs, so a page reload would otherwise lose
|
||||
// an offer that stays valid for 24 hours. Only for user actors, and it
|
||||
// costs no KV read unless the account is actually phone-gated.
|
||||
card_fallback_available: isUser
|
||||
? await isCardFallbackEligible(
|
||||
extension.config,
|
||||
user,
|
||||
async (key) => (await stores.kv.get({ key })).res,
|
||||
{
|
||||
smsConfigured: () =>
|
||||
Boolean(clients.prelude?.isConfigured()),
|
||||
probeCardVerification: async () => {
|
||||
const status = { enabled: null as boolean | null };
|
||||
await clients.event?.emitAndWait(
|
||||
'puter.card-verification.status',
|
||||
status,
|
||||
{},
|
||||
);
|
||||
return status.enabled;
|
||||
},
|
||||
},
|
||||
)
|
||||
: false,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
is_user_token: true,
|
||||
// Present only once the account has actually asked for a code (see the
|
||||
// referral extension) — null until then, and never minted from here:
|
||||
// this endpoint is polled, and a mint is a write.
|
||||
referral_code: user.referral_code,
|
||||
oidc_only: oidcOnly,
|
||||
taskbar_items: isUser
|
||||
? await getTaskbarItems(
|
||||
@@ -201,7 +101,6 @@ export const handleWhoami = async (
|
||||
stores,
|
||||
services,
|
||||
apiBaseUrl: String(extension.config.api_base_url ?? ''),
|
||||
config: extension.config,
|
||||
},
|
||||
{ iconSize, noIcons },
|
||||
)
|
||||
@@ -273,8 +172,6 @@ export const handleWhoami = async (
|
||||
delete details.created_ts;
|
||||
delete details.is_user_token;
|
||||
delete details.metadata;
|
||||
// An app has no business reading the code its user earns credit with.
|
||||
delete details.referral_code;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
@@ -292,17 +189,13 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
delete subscription.offering.price_id;
|
||||
}
|
||||
|
||||
// Last word on what ships, after every listener has had its say.
|
||||
scrubSensitive(details);
|
||||
|
||||
res.json(details);
|
||||
};
|
||||
|
||||
|
||||
@@ -131,11 +131,6 @@ S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
S3_BUCKET=puter-local
|
||||
EOF
|
||||
|
||||
# `meteringEnforcement.subscriptions` is off here because a self-hosted
|
||||
# install has no paid plans to be on: every account resolves to the free
|
||||
# policy, so leaving the plan gates armed would 402 the surfaces that
|
||||
# declare one (the OpenAI/Anthropic-compatible AI endpoints) for everybody.
|
||||
# The code default is on — this is a property of the install, not of Puter.
|
||||
log "writing puter/config/config.json"
|
||||
cat > puter/config/config.json <<EOF
|
||||
{
|
||||
@@ -196,10 +191,6 @@ EOF
|
||||
"ollama": { "enabled": false }
|
||||
},
|
||||
|
||||
"meteringEnforcement": {
|
||||
"subscriptions": false
|
||||
},
|
||||
|
||||
"trust_proxy": $PUTER_TRUST_PROXY
|
||||
}
|
||||
EOF
|
||||
|
||||
Generated
+3949
-2679
File diff suppressed because it is too large.
Load diff
+26
-15
@@ -13,9 +13,14 @@
|
||||
"@babel/core": "^7.29.7",
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"@vitest/coverage-v8": "^4.0.14",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-istanbul": "^8.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
@@ -25,19 +30,25 @@
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-rule-composer": "^0.3.0",
|
||||
"globals": "^15.15.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"license-check-and-add": "^4.0.5",
|
||||
"nodemon": "^3.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-jsdoc": "^1.8.1",
|
||||
"simple-git": "^3.32.3",
|
||||
"typescript": "^5.4.5",
|
||||
"uglify-js": "^3.17.4",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vitest": "^4.1.5",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.1"
|
||||
"webpack-cli": "^5.1.1",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test:backend": "npm run setupExtensions && npm run build:workerLib && vitest run --config src/backend/vitest.config.ts ",
|
||||
@@ -62,7 +73,9 @@
|
||||
"setupExtensions": "node ./tools/extensionSetup.mjs"
|
||||
},
|
||||
"workspaces": [
|
||||
"src/*"
|
||||
"src/*",
|
||||
"tools/*",
|
||||
"experiments/js-parse-and-output"
|
||||
],
|
||||
"nodemonConfig": {
|
||||
"ext": "js, json, mjs, jsx, svg, css",
|
||||
@@ -72,11 +85,14 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.25",
|
||||
"@aws-sdk/client-s3": "^3.1020.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"@heyputer/putility": "^1.0.2",
|
||||
"ai": "^6.0.73",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.12",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
@@ -85,17 +101,12 @@
|
||||
},
|
||||
"allowScripts": {
|
||||
"@google/genai": false,
|
||||
"bcrypt": true,
|
||||
"better-sqlite3": true,
|
||||
"browser-tabs-lock": true,
|
||||
"classic-level": true,
|
||||
"esbuild": true,
|
||||
"fsevents": true,
|
||||
"protobufjs": true,
|
||||
"sharp": true,
|
||||
"workerd": true
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^7.29.0"
|
||||
"bcrypt@5.1.1": true,
|
||||
"better-sqlite3@12.11.1": true,
|
||||
"browser-tabs-lock@1.3.0": true,
|
||||
"classic-level@3.0.0": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true
|
||||
}
|
||||
}
|
||||
@@ -375,18 +375,18 @@ describe('AlarmClient alarm registry', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 500; i++) {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
client.create('hot', `occurrence ${i}`, { i });
|
||||
}
|
||||
|
||||
const alarm = client.get('hot')!;
|
||||
expect(alarm.count).toBe(500);
|
||||
expect(alarm.count).toBe(50);
|
||||
expect(alarm.occurrences).toHaveLength(20);
|
||||
expect(alarm.timestamps).toHaveLength(20);
|
||||
// The window kept is the most recent one, not the oldest.
|
||||
expect(alarm.occurrences[19].message).toBe('occurrence 499');
|
||||
expect(alarm.occurrences[19].message).toBe('occurrence 49');
|
||||
// Trimming history must not rewind what the transports are told.
|
||||
expect(seen.at(-1)).toMatchObject({ repeatCount: 500, isRepeat: true });
|
||||
expect(seen[49]).toMatchObject({ repeatCount: 50, isRepeat: true });
|
||||
});
|
||||
|
||||
it('names anonymous handlers by their registration order', () => {
|
||||
@@ -519,53 +519,3 @@ describe('AlarmClient known-error rules', () => {
|
||||
expect(chat[0].severity).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlarmClient repeat reporting', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
pdEvent.mockClear();
|
||||
});
|
||||
|
||||
it('reports every occurrence up to the burst, then backs off', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 12; i++) client.create('flap', 'same fault');
|
||||
|
||||
expect(seen).toHaveLength(10);
|
||||
expect(seen[9].repeatCount).toBe(10);
|
||||
});
|
||||
|
||||
it('reports again once the interval is reached', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 500; i++) client.create('flap', 'same fault');
|
||||
|
||||
expect(seen).toHaveLength(11);
|
||||
expect(seen[10].repeatCount).toBe(500);
|
||||
});
|
||||
|
||||
it('keeps a fast-repeating fault out of the log after the burst', () => {
|
||||
const client = makeClient();
|
||||
capture(client);
|
||||
const warn = vi.mocked(console.warn);
|
||||
warn.mockClear();
|
||||
|
||||
for (let i = 0; i < 100; i++) client.create('flap', 'same fault');
|
||||
|
||||
// Occurrences 2-10 only; the first is an ACTIVE line on console.error.
|
||||
expect(warn.mock.calls).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('skips the payload entirely when no transport accepts the severity', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client, 'critical');
|
||||
|
||||
client.create('quiet', 'nobody is listening', {}, 'info');
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -69,15 +69,6 @@ const FALLBACK_SEVERITY: PagerSeverity = 'critical';
|
||||
* and `count` keeps that.
|
||||
*/
|
||||
const OCCURRENCE_HISTORY_LIMIT = 20;
|
||||
|
||||
// A fast-recurring fault repeats far faster than it is useful to report, and
|
||||
// every report allocates a log line and a payload. Report every occurrence up
|
||||
// to the burst, then one per interval; `count` still tracks all of them.
|
||||
const REPEAT_REPORT_BURST = 10;
|
||||
const REPEAT_REPORT_INTERVAL = 500;
|
||||
|
||||
const shouldReportRepeat = (count: number): boolean =>
|
||||
count <= REPEAT_REPORT_BURST || count % REPEAT_REPORT_INTERVAL === 0;
|
||||
/** Keeps `info` alarms out of the paging system unless config says otherwise. */
|
||||
const DEFAULT_PAGERDUTY_MIN_SEVERITY: PagerSeverity = 'warning';
|
||||
/** Slack's ceiling once a pager exists: chat gets what doesn't page. */
|
||||
@@ -465,8 +456,6 @@ export class AlarmClient extends PuterClient {
|
||||
private handleRepeat(alarm: Alarm): void {
|
||||
this.applyKnownErrors(alarm);
|
||||
|
||||
if (!shouldReportRepeat(alarm.count)) return;
|
||||
|
||||
console.warn(
|
||||
`[alarm] REPEAT ${displayId(alarm)} :: ${alarm.message} (${alarm.count})`,
|
||||
);
|
||||
@@ -494,18 +483,6 @@ export class AlarmClient extends PuterClient {
|
||||
);
|
||||
}
|
||||
|
||||
private hasHandlerFor(severity: PagerSeverity): boolean {
|
||||
for (const { minSeverity, maxSeverity } of this.alertHandlers) {
|
||||
if (
|
||||
meetsMinSeverity(severity, minSeverity) &&
|
||||
withinMaxSeverity(severity, maxSeverity)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private dispatchAlert(alarm: Alarm): void {
|
||||
const resolved = this.resolveSeverity(alarm);
|
||||
if (resolved === 'mute') {
|
||||
@@ -517,10 +494,6 @@ export class AlarmClient extends PuterClient {
|
||||
}
|
||||
alarm.severity = resolved;
|
||||
|
||||
// Build nothing for a severity no transport accepts — the payload
|
||||
// (cleaned fields plus the stack, twice) is the expensive part.
|
||||
if (!this.hasHandlerFor(resolved)) return;
|
||||
|
||||
const fieldsClean = cleanFields(alarm.fields);
|
||||
const repeatCount = alarm.count;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -27,15 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 79;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
* second, but the whole migration chain runs against the filesystem — enough
|
||||
* that a loaded runner blows the 5s default and reports a timeout where there
|
||||
* is no fault.
|
||||
*/
|
||||
const DISK_MIGRATION_TIMEOUT_MS = 30_000;
|
||||
const CURRENT_SCHEMA_VERSION = 61;
|
||||
const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
|
||||
const sqliteConfig = (
|
||||
@@ -60,7 +52,7 @@ const userVersionOf = async (client: SqliteDatabaseClient): Promise<number> => {
|
||||
return row.user_version as number;
|
||||
};
|
||||
|
||||
describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATION_TIMEOUT_MS }, () => {
|
||||
describe('SqliteDatabaseClient — boot and migrations', () => {
|
||||
let client: SqliteDatabaseClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -81,250 +73,6 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it('stamps a version whose migration did not run (known off-by-one)', async () => {
|
||||
// Existing behaviour, not an endorsement: the stamp overshoots the
|
||||
// applied work by one entry, so 70 is reported without 0074 running.
|
||||
const partial = await bootClient({ targetVersion: 70 });
|
||||
try {
|
||||
expect(await userVersionOf(partial)).toBe(70);
|
||||
await expect(
|
||||
partial.read(
|
||||
"SELECT 1 FROM pragma_table_info('group') WHERE name = 'handle'",
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
} finally {
|
||||
partial.onServerShutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the team columns and indexes from 0076', async () => {
|
||||
const columnsOf = async (table: string) =>
|
||||
(
|
||||
(await client.read(
|
||||
`SELECT name FROM pragma_table_info('${table}')`,
|
||||
)) as { name: string }[]
|
||||
).map((r) => r.name);
|
||||
|
||||
expect(await columnsOf('group')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'kind',
|
||||
'name',
|
||||
'handle',
|
||||
'deleted_at',
|
||||
]),
|
||||
);
|
||||
expect(await columnsOf('jct_user_group')).toContain('org_owned');
|
||||
expect(await columnsOf('user')).toContain('requires_password_change');
|
||||
|
||||
const indexes = (await client.read(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (?, ?, ?)",
|
||||
['idx_group_handle', 'idx_group_owner', 'idx_jct_user_group_group'],
|
||||
)) as { name: string }[];
|
||||
expect(indexes.map((r) => r.name).sort()).toEqual([
|
||||
'idx_group_handle',
|
||||
'idx_group_owner',
|
||||
'idx_jct_user_group_group',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies the event_subscriptions indexes from 0081', async () => {
|
||||
const indexes = (await client.read(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (?, ?, ?)",
|
||||
[
|
||||
'idx_event_subscriptions_app_handler',
|
||||
'idx_event_subscriptions_expires',
|
||||
'idx_event_subscriptions_suspended',
|
||||
],
|
||||
)) as { name: string }[];
|
||||
expect(indexes.map((r) => r.name).sort()).toEqual([
|
||||
'idx_event_subscriptions_app_handler',
|
||||
'idx_event_subscriptions_expires',
|
||||
'idx_event_subscriptions_suspended',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps `handle` unique but lets the seeded groups share a NULL one', async () => {
|
||||
const seeded = (await client.read(
|
||||
'SELECT COUNT(*) AS n FROM `group` WHERE `handle` IS NULL',
|
||||
)) as { n: number }[];
|
||||
expect(seeded[0].n).toBeGreaterThan(1);
|
||||
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['taken'],
|
||||
);
|
||||
await expect(
|
||||
client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
|
||||
['taken'],
|
||||
),
|
||||
).rejects.toThrow(/UNIQUE/iu);
|
||||
});
|
||||
|
||||
it('treats `handle` case-insensitively, as usernames are treated', async () => {
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['design-team'],
|
||||
);
|
||||
// Without NOCASE this differs per engine: mysql rejects, the others accept.
|
||||
await expect(
|
||||
client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
|
||||
['Design-Team'],
|
||||
),
|
||||
).rejects.toThrow(/UNIQUE/iu);
|
||||
});
|
||||
|
||||
it('matches a handle case-insensitively on lookup, not just on insert', async () => {
|
||||
// Index-only NOCASE would leave `WHERE handle = ?` case-sensitive here while
|
||||
// mysql's utf8mb4_unicode_ci column matched -- one query, two behaviours.
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['design-team'],
|
||||
);
|
||||
await expect(
|
||||
client.read('SELECT `handle` FROM `group` WHERE `handle` = ?', [
|
||||
'Design-Team',
|
||||
]),
|
||||
).resolves.toEqual([{ handle: 'design-team' }]);
|
||||
});
|
||||
|
||||
it('rejects a duplicate membership pair after 0078', async () => {
|
||||
const [{ user_id, group_id }] = (await client.read(
|
||||
'SELECT (SELECT MIN(`id`) FROM `user`) AS user_id, ' +
|
||||
'(SELECT MIN(`id`) FROM `group`) AS group_id',
|
||||
)) as { user_id: number; group_id: number }[];
|
||||
|
||||
const insert = () =>
|
||||
client.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
|
||||
[user_id, group_id],
|
||||
);
|
||||
|
||||
await insert();
|
||||
// `GroupStore.addUsers` has no conflict clause, so before 0075 this
|
||||
// second call silently doubled every group permission the user reads.
|
||||
await expect(insert()).rejects.toThrow(/UNIQUE/iu);
|
||||
});
|
||||
|
||||
it('applies the audit table and group-share columns from 0079', async () => {
|
||||
const columns = (await client.read(
|
||||
"SELECT name FROM pragma_table_info('audit_team_membership')",
|
||||
)) as { name: string }[];
|
||||
expect(columns.map((r) => r.name)).toEqual([
|
||||
'id',
|
||||
'group_id',
|
||||
'group_id_keep',
|
||||
'user_id',
|
||||
'user_id_keep',
|
||||
'actor_user_id',
|
||||
'action',
|
||||
'reason',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
const shareColumns = (await client.read(
|
||||
"SELECT name FROM pragma_table_info('share')",
|
||||
)) as { name: string }[];
|
||||
expect(shareColumns.map((r) => r.name)).toContain('holder_group_id');
|
||||
|
||||
const indexes = (await client.read(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' " +
|
||||
'AND (name LIKE ? OR name LIKE ?)',
|
||||
['idx_audit_team_membership%', 'idx_share_holder_group%'],
|
||||
)) as { name: string }[];
|
||||
expect(indexes.map((r) => r.name).sort()).toEqual([
|
||||
// The `_fk` three stop a user or group delete scanning the audit table.
|
||||
'idx_audit_team_membership_actor_fk',
|
||||
'idx_audit_team_membership_group',
|
||||
'idx_audit_team_membership_group_fk',
|
||||
'idx_audit_team_membership_user',
|
||||
'idx_audit_team_membership_user_fk',
|
||||
'idx_share_holder_group',
|
||||
'idx_share_holder_group_entry_issuer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an audit row after its group is deleted, blanking only the FK', async () => {
|
||||
// 0043 leaves `PRAGMA foreign_keys = ON`, so this is real behaviour.
|
||||
const [group] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `group`',
|
||||
)) as { id: number }[];
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
|
||||
await client.write(
|
||||
'INSERT INTO `audit_team_membership` ' +
|
||||
'(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' +
|
||||
'`actor_user_id`, `action`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[group.id, group.id, user.id, user.id, user.id, 'reset_member_password'],
|
||||
);
|
||||
|
||||
await client.write('DELETE FROM `group` WHERE `id` = ?', [group.id]);
|
||||
|
||||
await expect(
|
||||
client.read(
|
||||
'SELECT `group_id`, `group_id_keep`, `action` FROM `audit_team_membership`',
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
group_id: null,
|
||||
group_id_keep: group.id,
|
||||
action: 'reset_member_password',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('constrains team shares that the user-holder index cannot', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
const groups = (await client.read(
|
||||
'SELECT `id` FROM `group` ORDER BY `id` LIMIT 2',
|
||||
)) as { id: number }[];
|
||||
|
||||
await client.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
['11111111-1111-4111-8111-111111111111', 'shared.txt', user.id, 0],
|
||||
);
|
||||
const [entry] = (await client.read(
|
||||
'SELECT `id` FROM `fsentries` WHERE `uuid` = ?',
|
||||
['11111111-1111-4111-8111-111111111111'],
|
||||
)) as { id: number }[];
|
||||
|
||||
const insertShare = (uid: string, groupId: number) =>
|
||||
client.write(
|
||||
'INSERT INTO `share` ' +
|
||||
'(`uid`, `issuer_user_id`, `recipient_email`, `holder_user_id`, ' +
|
||||
'`holder_group_id`, `fsentry_id`) VALUES (?, ?, ?, NULL, ?, ?)',
|
||||
[uid, user.id, '[email protected]', groupId, entry.id],
|
||||
);
|
||||
|
||||
await insertShare('share-team-a', groups[0].id);
|
||||
// Different group, same file and issuer -- allowed.
|
||||
await insertShare('share-team-b', groups[1].id);
|
||||
// Both have `holder_user_id` NULL, so the user-holder index permitted them.
|
||||
await expect(insertShare('share-team-c', groups[0].id)).rejects.toThrow(
|
||||
/UNIQUE/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches a handle case-insensitively on lookup, not just on insert', async () => {
|
||||
// Index-only NOCASE would leave `WHERE handle = ?` case-sensitive here while
|
||||
// mysql's utf8mb4_unicode_ci column matched -- one query, two behaviours.
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['design-team'],
|
||||
);
|
||||
await expect(
|
||||
client.read('SELECT `handle` FROM `group` WHERE `handle` = ?', [
|
||||
'Design-Team',
|
||||
]),
|
||||
).resolves.toEqual([{ handle: 'design-team' }]);
|
||||
});
|
||||
|
||||
it('runs the javascript migrations, not just the .sql ones', async () => {
|
||||
// The `system` user only exists because 0025 (a .dbmig.js file) ran
|
||||
// inside the migration VM.
|
||||
@@ -335,289 +83,6 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
expect(rows).toEqual([{ username: 'system' }]);
|
||||
});
|
||||
|
||||
// 0070 sweeps three by-hand groups no code reads. Both tables it can reach
|
||||
// cascade on delete, so the guard is the point: a group that still carries
|
||||
// permissions or members has to survive, or the sweep silently revokes
|
||||
// them. Re-running the statement is safe — it is a plain conditional DELETE.
|
||||
describe('0070 drop-orphaned-default-groups', () => {
|
||||
const MIGRATION = readFileSync(
|
||||
new URL(
|
||||
'./migrations/sqlite/0070_drop-orphaned-default-groups.sql',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const seedGroup = async (name: string): Promise<number> => {
|
||||
const [system] = await client.read(
|
||||
'SELECT `id` FROM `user` WHERE `uuid` = ?',
|
||||
[SYSTEM_USER_UUID],
|
||||
);
|
||||
const uid = `grp-${name}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await client.write(
|
||||
'INSERT INTO `group` (`uid`, `owner_user_id`, `extra`, `metadata`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
[uid, system.id, JSON.stringify({ name }), '{}'],
|
||||
);
|
||||
const [row] = await client.read(
|
||||
'SELECT `id` FROM `group` WHERE `uid` = ?',
|
||||
[uid],
|
||||
);
|
||||
return Number(row.id);
|
||||
};
|
||||
|
||||
const stillThere = async (id: number): Promise<boolean> => {
|
||||
const rows = await client.read(
|
||||
'SELECT `id` FROM `group` WHERE `id` = ?',
|
||||
[id],
|
||||
);
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
it('drops an orphaned freeai/experimental/dangerous group', async () => {
|
||||
const ids = await Promise.all(
|
||||
['freeai', 'experimental', 'dangerous'].map(seedGroup),
|
||||
);
|
||||
await client.write(MIGRATION);
|
||||
for (const id of ids) expect(await stillThere(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('spares one that still carries a permission row', async () => {
|
||||
const id = await seedGroup('freeai');
|
||||
const [system] = await client.read(
|
||||
'SELECT `id` FROM `user` WHERE `uuid` = ?',
|
||||
[SYSTEM_USER_UUID],
|
||||
);
|
||||
await client.write(
|
||||
'INSERT INTO `user_to_group_permissions` ' +
|
||||
'(`user_id`, `group_id`, `permission`, `extra`) VALUES (?, ?, ?, ?)',
|
||||
[system.id, id, 'service:some-paid-thing', '{}'],
|
||||
);
|
||||
|
||||
await client.write(MIGRATION);
|
||||
|
||||
expect(await stillThere(id)).toBe(true);
|
||||
// And the grant it carries is still intact, not cascaded away.
|
||||
const perms = await client.read(
|
||||
'SELECT `permission` FROM `user_to_group_permissions` WHERE `group_id` = ?',
|
||||
[id],
|
||||
);
|
||||
expect(perms).toEqual([{ permission: 'service:some-paid-thing' }]);
|
||||
});
|
||||
|
||||
it('spares one that still has members', async () => {
|
||||
const id = await seedGroup('experimental');
|
||||
const [system] = await client.read(
|
||||
'SELECT `id` FROM `user` WHERE `uuid` = ?',
|
||||
[SYSTEM_USER_UUID],
|
||||
);
|
||||
await client.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
|
||||
[system.id, id],
|
||||
);
|
||||
|
||||
await client.write(MIGRATION);
|
||||
|
||||
expect(await stillThere(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the groups the platform actually depends on alone', async () => {
|
||||
// system, admin, user and temp are named by config or code.
|
||||
await client.write(MIGRATION);
|
||||
const rows = await client.read(
|
||||
"SELECT json_extract(`extra`, '$.name') AS name FROM `group`",
|
||||
);
|
||||
const names = rows.map((r) => String(r.name));
|
||||
for (const name of ['system', 'admin', 'user']) {
|
||||
expect(names).toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 0072 rebuilds `notification` for the scope tuple, and picks up the
|
||||
// user_id index and the delete cascade mysql and postgres already had.
|
||||
describe('0072 notification-scope', () => {
|
||||
// Only the backfill re-runs: the rebuild half is once-only by
|
||||
// construction, but mysql and postgres replay their whole file on
|
||||
// every boot, so these statements have to be safe twice.
|
||||
const BACKFILL = readFileSync(
|
||||
new URL(
|
||||
'./migrations/sqlite/0072_notification-scope.sql',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
.split(/;\s*\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => /(^|\n)UPDATE /.test(s));
|
||||
|
||||
const seedUser = async (): Promise<number> => {
|
||||
const name = `notif-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await client.write(
|
||||
'INSERT INTO `user` (`username`, `uuid`) VALUES (?, ?)',
|
||||
[name, `${name}-uuid`],
|
||||
);
|
||||
const [row] = await client.read(
|
||||
'SELECT `id` FROM `user` WHERE `username` = ?',
|
||||
[name],
|
||||
);
|
||||
return Number(row.id);
|
||||
};
|
||||
|
||||
const seedLegacy = async (
|
||||
userId: number,
|
||||
value: Record<string, unknown> | string,
|
||||
): Promise<string> => {
|
||||
const uid = `n-${Math.random().toString(36).slice(2, 12)}`;
|
||||
await client.write(
|
||||
'INSERT INTO `notification` (`uid`, `user_id`, `value`) VALUES (?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
userId,
|
||||
typeof value === 'string' ? value : JSON.stringify(value),
|
||||
],
|
||||
);
|
||||
return uid;
|
||||
};
|
||||
|
||||
const scopeOf = async (uid: string) => {
|
||||
const [row] = await client.read(
|
||||
'SELECT `type`, `audience`, `app_uid` FROM `notification` WHERE `uid` = ?',
|
||||
[uid],
|
||||
);
|
||||
return row;
|
||||
};
|
||||
|
||||
const runBackfill = async () => {
|
||||
for (const stmt of BACKFILL) await client.write(stmt);
|
||||
};
|
||||
|
||||
it('carries every backfill statement', () => {
|
||||
expect(BACKFILL).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('classifies the markers legacy rows actually carried', async () => {
|
||||
const userId = await seedUser();
|
||||
const received = await seedLegacy(userId, {
|
||||
source: 'sharing',
|
||||
template: 'file-shared-with-you',
|
||||
});
|
||||
const claimed = await seedLegacy(userId, {
|
||||
source: 'sharing',
|
||||
template: 'file-shared-before-you-joined',
|
||||
});
|
||||
const deployed = await seedLegacy(userId, {
|
||||
source: 'worker',
|
||||
title: 'Successfully deployed https://x.puter.work',
|
||||
template: 'user-requesting-share',
|
||||
});
|
||||
const failed = await seedLegacy(userId, {
|
||||
source: 'worker',
|
||||
title: 'Failed to deploy x! boom',
|
||||
template: 'user-requesting-share',
|
||||
});
|
||||
|
||||
await runBackfill();
|
||||
|
||||
expect(await scopeOf(received)).toEqual({
|
||||
type: 'share.received',
|
||||
audience: 'account',
|
||||
app_uid: null,
|
||||
});
|
||||
expect(await scopeOf(claimed)).toEqual({
|
||||
type: 'share.claimed',
|
||||
audience: 'account',
|
||||
app_uid: null,
|
||||
});
|
||||
// The app is unrecoverable — the payload only ever named a worker.
|
||||
expect(await scopeOf(deployed)).toEqual({
|
||||
type: 'app.worker.deployed',
|
||||
audience: 'developer',
|
||||
app_uid: null,
|
||||
});
|
||||
expect(await scopeOf(failed)).toEqual({
|
||||
type: 'app.worker.deployFailed',
|
||||
audience: 'developer',
|
||||
app_uid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves anything it does not recognise reading as legacy', async () => {
|
||||
const userId = await seedUser();
|
||||
const unknown = await seedLegacy(userId, {
|
||||
source: 'sharing',
|
||||
template: 'something-else',
|
||||
});
|
||||
const bare = await seedLegacy(userId, { title: 'hi' });
|
||||
const notJson = await seedLegacy(userId, 'plain text');
|
||||
|
||||
await runBackfill();
|
||||
|
||||
for (const uid of [unknown, bare, notJson]) {
|
||||
expect(await scopeOf(uid)).toEqual({
|
||||
type: '',
|
||||
audience: 'account',
|
||||
app_uid: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reclassify on replay', async () => {
|
||||
const userId = await seedUser();
|
||||
const uid = await seedLegacy(userId, {
|
||||
source: 'sharing',
|
||||
template: 'file-shared-with-you',
|
||||
});
|
||||
|
||||
await runBackfill();
|
||||
// A row already classified as something else must survive a
|
||||
// second pass untouched.
|
||||
await client.write(
|
||||
'UPDATE `notification` SET `audience` = ?, `app_uid` = ? WHERE `uid` = ?',
|
||||
['app-user', 'app-1234', uid],
|
||||
);
|
||||
await runBackfill();
|
||||
|
||||
expect(await scopeOf(uid)).toEqual({
|
||||
type: 'share.received',
|
||||
audience: 'app-user',
|
||||
app_uid: 'app-1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('indexes user_id and the scope tuple', async () => {
|
||||
const rows = await client.read('PRAGMA index_list(`notification`)');
|
||||
const names = rows.map((r) => String(r.name));
|
||||
expect(names).toContain('idx_notification_user_id');
|
||||
expect(names).toContain('idx_notification_scope');
|
||||
});
|
||||
|
||||
it('retires a deleted user rows', async () => {
|
||||
const [{ foreign_keys: enforcing }] = await client.read(
|
||||
'PRAGMA foreign_keys',
|
||||
);
|
||||
expect(enforcing).toBe(1);
|
||||
|
||||
const userId = await seedUser();
|
||||
const uid = await seedLegacy(userId, { title: 'orphan-to-be' });
|
||||
await client.write('DELETE FROM `user` WHERE `id` = ?', [userId]);
|
||||
|
||||
expect(
|
||||
await client.read(
|
||||
'SELECT `uid` FROM `notification` WHERE `uid` = ?',
|
||||
[uid],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('indexes notification.created_at for the retention sweep', async () => {
|
||||
const rows = await client.read('PRAGMA index_list(`notification`)');
|
||||
expect(rows.map((r) => String(r.name))).toContain(
|
||||
'idx_notification_created_at',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an already-migrated database untouched on a second boot', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
|
||||
const path = join(dir, 'nested', 'puter.sqlite');
|
||||
@@ -651,66 +116,6 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
}
|
||||
});
|
||||
|
||||
it('deduplicates pre-existing membership rows when 0078 applies', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-dedup-'));
|
||||
const path = join(dir, 'puter.sqlite');
|
||||
try {
|
||||
// Stop at 67 so the duplicates exist the way a live database's do:
|
||||
// written before the unique index, not after it.
|
||||
const before = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path, targetVersion: 67 }),
|
||||
);
|
||||
await before.onServerStart();
|
||||
|
||||
const [{ user_id, group_id }] = (await before.read(
|
||||
'SELECT (SELECT MIN(`id`) FROM `user`) AS user_id, ' +
|
||||
'(SELECT MIN(`id`) FROM `group`) AS group_id',
|
||||
)) as { user_id: number; group_id: number }[];
|
||||
|
||||
for (const pair of [
|
||||
[user_id, group_id],
|
||||
[user_id, group_id],
|
||||
[user_id, group_id],
|
||||
[user_id, group_id + 1],
|
||||
]) {
|
||||
await before.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
|
||||
pair,
|
||||
);
|
||||
}
|
||||
const seeded = (await before.read(
|
||||
'SELECT `id`, `group_id` FROM `jct_user_group` ORDER BY `id`',
|
||||
)) as { id: number; group_id: number }[];
|
||||
expect(seeded).toHaveLength(4);
|
||||
before.onServerShutdown();
|
||||
|
||||
const after = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path }),
|
||||
);
|
||||
await after.onServerStart();
|
||||
expect(await userVersionOf(after)).toBe(CURRENT_SCHEMA_VERSION);
|
||||
|
||||
// Three copies collapse to the lowest id; the distinct pair is left
|
||||
// alone. Losing the higher ids costs nothing -- `addUsers` writes
|
||||
// only the two id columns, so `extra` and `metadata` are NULL.
|
||||
await expect(
|
||||
after.read(
|
||||
'SELECT `id`, `group_id` FROM `jct_user_group` ORDER BY `id`',
|
||||
),
|
||||
).resolves.toEqual([seeded[0], seeded[3]]);
|
||||
|
||||
await expect(
|
||||
after.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
|
||||
[user_id, group_id],
|
||||
),
|
||||
).rejects.toThrow(/UNIQUE/iu);
|
||||
after.onServerShutdown();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('stops early at a configured target version', async () => {
|
||||
const partial = await bootClient({ targetVersion: 5 });
|
||||
try {
|
||||
@@ -732,10 +137,7 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
});
|
||||
});
|
||||
|
||||
describe(
|
||||
'SqliteDatabaseClient — legacy version inference',
|
||||
{ timeout: DISK_MIGRATION_TIMEOUT_MS },
|
||||
() => {
|
||||
describe('SqliteDatabaseClient — legacy version inference', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -95,24 +95,6 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
[60, ['0065_app-feedback.sql']],
|
||||
[61, ['0066_owned-email-unique.sql']],
|
||||
[62, ['0067_share_entries.sql']],
|
||||
[63, ['0068_referral-code-unique.sql']],
|
||||
[64, ['0069_user-block.sql']],
|
||||
[65, ['0070_drop-orphaned-default-groups.sql']],
|
||||
[66, ['0071_share_issuer_index.sql']],
|
||||
[67, ['0072_notification-scope.sql']],
|
||||
[68, ['0073_notification-created-at.sql']],
|
||||
[69, ['0074_add_user_home.sql']],
|
||||
[70, ['0075_event-subscriptions.sql']],
|
||||
[71, ['0076_event-handlers.sql']],
|
||||
[72, ['0077_teams.sql']],
|
||||
[73, ['0078_jct-user-group-pair-unique.sql']],
|
||||
[74, ['0079_team-audit-and-group-shares.sql']],
|
||||
[75, ['0080_kv-share-handles.sql']],
|
||||
[76, ['0081_event-subscriptions-indexes.sql']],
|
||||
[77, ['0082_temp-password-expiry.sql']],
|
||||
[78, ['0083_team-directory.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -1060,7 +1060,6 @@ CREATE TABLE IF NOT EXISTS `user` (
|
||||
`signup_server` varchar(255) DEFAULT NULL,
|
||||
`metadata` json DEFAULT (json_object()),
|
||||
`reputation` smallint DEFAULT '100',
|
||||
`home` varchar(64) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uid` (`uuid`),
|
||||
UNIQUE KEY `username` (`username`),
|
||||
@@ -1152,7 +1151,6 @@ CALL _puter_add_col('user', 'signup_user_agent', '`signup_user_agent` varchar(51
|
||||
CALL _puter_add_col('user', 'signup_origin', '`signup_origin` varchar(255) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'signup_server', '`signup_server` varchar(255) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'metadata', '`metadata` json DEFAULT (json_object())');
|
||||
CALL _puter_add_col('user', 'home', '`home` varchar(64) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'reputation', '`reputation` smallint DEFAULT ''100''');
|
||||
|
||||
--
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Enforce "at most one account owns an email address". Mirrors SQLite
|
||||
-- migration 0066.
|
||||
--
|
||||
-- `user.email` is deliberately not UNIQUE: several rows may legitimately hold
|
||||
-- the same address while unconfirmed (admin-provisioned placeholders, signups
|
||||
-- that were never confirmed, temp accounts on their way to becoming real). What
|
||||
-- must never happen is two rows both *owning* an address — owning meaning the
|
||||
-- row is confirmed, or holds a password and so can drive password recovery for
|
||||
-- that inbox.
|
||||
--
|
||||
-- Signup, save-account, change-email, OIDC and admin provisioning each check for
|
||||
-- an owner before writing, but a check and an insert are not one operation: two
|
||||
-- requests can both read "free" and both write. This index is what actually
|
||||
-- holds the invariant; the application checks just produce a nicer error most of
|
||||
-- the time.
|
||||
--
|
||||
-- SQLite expresses that with a partial index. MySQL has none, so the predicate
|
||||
-- lives in a generated column that evaluates to NULL for every row that does not
|
||||
-- own its address — and NULLs do not collide in an InnoDB unique index, which is
|
||||
-- exactly the "unlimited unconfirmed placeholders" behaviour we need.
|
||||
--
|
||||
-- The column is VIRTUAL, not STORED, on purpose: adding a stored generated
|
||||
-- column rebuilds the table, while a virtual one is a metadata-only change and
|
||||
-- the index that follows builds INPLACE. On a `user` table of any size with
|
||||
-- read replicas attached, that is the difference between a routine change and an
|
||||
-- outage. The ALGORITHM/LOCK clauses are spelled out so a server that cannot
|
||||
-- honour them refuses the statement instead of quietly copying the table.
|
||||
--
|
||||
-- Matching is on the canonical address so provider aliases
|
||||
-- (`[email protected]` vs `[email protected]`) collide. `clean_email` is
|
||||
-- written on every modern write path; the COALESCE covers rows old enough to
|
||||
-- predate the column. Run the `clean_email` backfill before this migration or
|
||||
-- alias collisions among those rows go unnoticed.
|
||||
--
|
||||
-- Idempotent: both steps are guarded on INFORMATION_SCHEMA so the directory
|
||||
-- replays safely.
|
||||
--
|
||||
-- If the index creation fails with ER_DUP_ENTRY, the DB already contains
|
||||
-- duplicate owners. Collapse them first (admin → One-off Jobs → Collapse
|
||||
-- Duplicate Emails) — there is no safe automatic merge of two accounts.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_owned_email;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_owned_email()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND COLUMN_NAME = 'owned_email'
|
||||
) THEN
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `owned_email` VARCHAR(256)
|
||||
CHARACTER SET latin1 COLLATE latin1_swedish_ci
|
||||
GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN `email` IS NOT NULL
|
||||
AND (`email_confirmed` = 1 OR `password` IS NOT NULL)
|
||||
THEN COALESCE(`clean_email`, LOWER(`email`))
|
||||
ELSE NULL
|
||||
END
|
||||
) VIRTUAL,
|
||||
ALGORITHM=INSTANT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_owned_email'
|
||||
) THEN
|
||||
ALTER TABLE `user`
|
||||
ADD UNIQUE KEY `idx_user_owned_email` (`owned_email`),
|
||||
ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_owned_email();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_owned_email;
|
||||
|
||||
-- One Puter account per external identity. OIDCStore.link already assumes this
|
||||
-- constraint exists — it catches the unique violation to tell "re-linking the
|
||||
-- same account" apart from "this sub belongs to someone else" — but the table
|
||||
-- never actually had it, so two concurrent first-time logins could each create
|
||||
-- an account and each link the same sub. Subsequent logins then resolved to
|
||||
-- whichever row came back first.
|
||||
--
|
||||
-- Dedupe `user_oidc_providers` before applying this: keep the lowest `id` per
|
||||
-- (provider, provider_sub) and point it at the account the collapse job kept.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_oidc_sub_unique;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_oidc_sub_unique()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_oidc_providers'
|
||||
AND INDEX_NAME = 'idx_user_oidc_provider_sub'
|
||||
) THEN
|
||||
ALTER TABLE `user_oidc_providers`
|
||||
ADD UNIQUE KEY `idx_user_oidc_provider_sub` (`provider`, `provider_sub`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_oidc_sub_unique();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_oidc_sub_unique;
|
||||
@@ -1,106 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Grow `share` from a pending-email-invite table into the index of active
|
||||
-- shares. See sqlite/0067_share_entries.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: columns go through _puter_add_col (from mig_1), indexes and
|
||||
-- foreign keys through the guarded procedure below. There is no per-file
|
||||
-- applied-state tracking, so every statement must tolerate a re-run.
|
||||
|
||||
CALL _puter_add_col('share', 'holder_user_id', '`holder_user_id` int unsigned DEFAULT NULL');
|
||||
CALL _puter_add_col('share', 'fsentry_id', '`fsentry_id` int unsigned DEFAULT NULL');
|
||||
CALL _puter_add_col('share', 'mode', '`mode` varchar(20) DEFAULT NULL');
|
||||
CALL _puter_add_col('share', 'applied_at', '`applied_at` timestamp NULL DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_index_constraints;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_share_index_constraints()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_holder'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD INDEX `idx_share_holder` (`holder_user_id`, `id`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_fsentry'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD INDEX `idx_share_fsentry` (`fsentry_id`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_holder_entry_issuer'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_holder_entry_issuer`
|
||||
(`holder_user_id`, `fsentry_id`, `issuer_user_id`);
|
||||
END IF;
|
||||
|
||||
-- Retiring a deleted node's grants looks them up by permission text
|
||||
-- (`fs:<uuid>` and `fs:<uuid>:%`); the subject lives in that text rather
|
||||
-- than a column, so no foreign key can cascade it. The primary key is
|
||||
-- (issuer, holder, permission), which never puts `permission` first, so
|
||||
-- without this both the equality and the left-anchored LIKE degrade to a
|
||||
-- full scan on the delete path. The column is ascii varchar(255), well
|
||||
-- inside InnoDB's key limit, so it is indexed whole.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_to_user_permissions'
|
||||
AND INDEX_NAME = 'idx_user_to_user_permissions_permission'
|
||||
) THEN
|
||||
ALTER TABLE `user_to_user_permissions`
|
||||
ADD INDEX `idx_user_to_user_permissions_permission` (`permission`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND CONSTRAINT_NAME = 'share_holder_user_fk'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD CONSTRAINT `share_holder_user_fk`
|
||||
FOREIGN KEY (`holder_user_id`) REFERENCES `user` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
|
||||
-- The cascade that retires a share with its file.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND CONSTRAINT_NAME = 'share_fsentry_fk'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD CONSTRAINT `share_fsentry_fk`
|
||||
FOREIGN KEY (`fsentry_id`) REFERENCES `fsentries` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_share_index_constraints();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_index_constraints;
|
||||
@@ -1,35 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- One user refusing contact from another. See sqlite/0068_user-block.sql for
|
||||
-- the rationale. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- so the directory can replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_block` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`blocker_user_id` INT UNSIGNED NOT NULL,
|
||||
`blocked_user_id` INT UNSIGNED NOT NULL,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_user_block_pair` (`blocker_user_id`, `blocked_user_id`),
|
||||
CONSTRAINT `fk_user_block_blocker` FOREIGN KEY (`blocker_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_user_block_blocked` FOREIGN KEY (`blocked_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -1,61 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Widen `notification.shown` and `notification.acknowledged` to hold the unix
|
||||
-- second the store has been writing to them since the backend rework.
|
||||
--
|
||||
-- Both arrived from the v1 schema as `tinyint(1)`, where they were flags. The
|
||||
-- rework changed the writes to timestamps and sqlite (`INTEGER`) and postgres
|
||||
-- (`bigint`) took them; only mysql was left too narrow, so every `markShown`
|
||||
-- and `markAcknowledged` there fails with ER_WARN_DATA_OUT_OF_RANGE and the
|
||||
-- column stays NULL. Dismissing a notification therefore never sticks, and one
|
||||
-- already delivered is re-sent on every reconnect.
|
||||
--
|
||||
-- No backfill: every reader tests `IS NULL` / `IS NOT NULL` only, so a legacy
|
||||
-- `1` keeps meaning "yes" once widened.
|
||||
--
|
||||
-- Guarded on the current type rather than run unconditionally. Changing a
|
||||
-- column type copies the table, and this one grows without bound — a migration
|
||||
-- directory that replays on every boot must not rebuild it every time.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_notification_stamps;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_widen_notification_stamps()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND COLUMN_NAME = 'shown'
|
||||
AND DATA_TYPE = 'tinyint'
|
||||
) THEN
|
||||
ALTER TABLE `notification` MODIFY `shown` BIGINT DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND COLUMN_NAME = 'acknowledged'
|
||||
AND DATA_TYPE = 'tinyint'
|
||||
) THEN
|
||||
ALTER TABLE `notification` MODIFY `acknowledged` BIGINT DEFAULT NULL;
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL _puter_widen_notification_stamps();
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_notification_stamps;
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
|
||||
-- `dangerous`. No migration creates them — they were added by hand back when
|
||||
-- hardcoded permissions were keyed by group name. That map is now a flat
|
||||
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
|
||||
-- so a group nothing looks up grants nothing, and the rows are dead weight.
|
||||
--
|
||||
-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and
|
||||
-- `jct_user_group.group_id` both cascade on delete, so removing a group that
|
||||
-- still carries permissions or members would silently revoke them from every
|
||||
-- member. Only a group with neither is dropped. A group that survives this
|
||||
-- migration has dependents and needs a deliberate decision, not a sweep --
|
||||
-- query `user_to_group_permissions` by `group_id` to see what it holds.
|
||||
--
|
||||
-- The joins rather than `NOT IN` subqueries: MySQL will not read from the table
|
||||
-- it is deleting from inside a subquery on the same statement.
|
||||
DELETE g FROM `group` g
|
||||
LEFT JOIN `user_to_group_permissions` p ON p.`group_id` = g.`id`
|
||||
LEFT JOIN `jct_user_group` j ON j.`group_id` = g.`id`
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(g.`extra`, '$.name'))
|
||||
IN ('freeai', 'experimental', 'dangerous')
|
||||
AND p.`group_id` IS NULL
|
||||
AND j.`group_id` IS NULL;
|
||||
@@ -1,80 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Give `notification` a scope tuple: what the row is (`type`), the app it is
|
||||
-- about (`app_uid`, NULL for platform), and who the recipient is in relation
|
||||
-- to it (`audience`). Attribution lived inside the `value` JSON as two
|
||||
-- free-form markers, `source` and `template`, that could disagree; these
|
||||
-- columns replace them and are what a scoped list can be indexed on.
|
||||
--
|
||||
-- `app_uid` is char(40) to match `apps`.`uid`, which is `app-` plus a uuid.
|
||||
--
|
||||
-- Idempotent: columns go through `_puter_add_col` (mig_1), the index through
|
||||
-- the guarded procedure. There is no per-file applied-state tracking, so every
|
||||
-- statement here tolerates a re-run.
|
||||
|
||||
CALL _puter_add_col('notification', 'app_uid', '`app_uid` char(40) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
|
||||
CALL _puter_add_col('notification', 'audience', '`audience` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''account''');
|
||||
CALL _puter_add_col('notification', 'type', '`type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''''');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_scope_index()
|
||||
BEGIN
|
||||
-- Serves both the scoped list and the unacknowledged count.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_scope'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_scope`
|
||||
(`user_id`, `audience`, `app_uid`, `acknowledged`);
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL _puter_add_notification_scope_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_index;
|
||||
|
||||
-- Backfill from the markers the payload actually carried. `type = ''` is the
|
||||
-- not-yet-classified state, so re-running matches nothing the first pass
|
||||
-- claimed, and a row matching no marker keeps the defaults (`''` / 'account')
|
||||
-- and reads as legacy.
|
||||
|
||||
UPDATE `notification`
|
||||
SET `type` = 'share.received', `audience` = 'account', `app_uid` = NULL
|
||||
WHERE `type` = ''
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.template')) = 'file-shared-with-you';
|
||||
|
||||
UPDATE `notification`
|
||||
SET `type` = 'share.claimed', `audience` = 'account', `app_uid` = NULL
|
||||
WHERE `type` = ''
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.template')) = 'file-shared-before-you-joined';
|
||||
|
||||
-- Worker rows carried only `source: 'worker'` and a title, so the app they
|
||||
-- belong to is unrecoverable and they stay unattributed. The title prefix is
|
||||
-- the sole surviving signal of which way the deploy went.
|
||||
UPDATE `notification`
|
||||
SET `audience` = 'developer',
|
||||
`app_uid` = NULL,
|
||||
`type` = CASE
|
||||
WHEN JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.title')) LIKE 'Successfully deployed %'
|
||||
THEN 'app.worker.deployed'
|
||||
ELSE 'app.worker.deployFailed'
|
||||
END
|
||||
WHERE `type` = ''
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.source')) = 'worker';
|
||||
@@ -1,42 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Index `notification`.`created_at`. The retention sweep selects by age alone
|
||||
-- and by nothing else, so no existing index on the table narrows it — every
|
||||
-- pass would otherwise scan the whole table to find the oldest few hundred
|
||||
-- rows.
|
||||
--
|
||||
-- Idempotent: the guarded procedure, as mig_26. There is no per-file
|
||||
-- applied-state tracking, so a replay has to be a no-op.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_created_at_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_created_at_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_created_at'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_created_at`
|
||||
(`created_at`);
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL _puter_add_notification_created_at_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_created_at_index;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Home region column. Mirrors SQLite migration 0074. Home region this
|
||||
-- account's data belongs in — a node id, not a storage region. NULL for
|
||||
-- accounts that predate the column, which fall back to `signup_server`.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations).
|
||||
|
||||
CALL _puter_add_col('user', 'home', '`home` VARCHAR(64) DEFAULT NULL');
|
||||
@@ -1,59 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Subscriptions that outlive the connection that made them. See
|
||||
-- sqlite/0075_event-subscriptions.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- as mig_23. There is no per-file applied-state tracking, so a replay has to
|
||||
-- be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_subscriptions` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`sub_id` varchar(80) NOT NULL,
|
||||
`token` varchar(255) NOT NULL,
|
||||
`owner_user_id` int unsigned NOT NULL,
|
||||
`holder_user_id` int unsigned NOT NULL,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion. No foreign key: a row outlives the
|
||||
-- app that made it, which is what keeps it revocable afterwards.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci
|
||||
DEFAULT NULL,
|
||||
`subject` varchar(4096) NOT NULL,
|
||||
`anchor_uid` char(36) NOT NULL,
|
||||
`anchor_path` varchar(4096) NOT NULL,
|
||||
`match` varchar(1024) DEFAULT NULL,
|
||||
`delivery` varchar(16) NOT NULL,
|
||||
`ops` varchar(64) DEFAULT NULL,
|
||||
`handler_name` varchar(128) DEFAULT NULL,
|
||||
`targets` json NOT NULL,
|
||||
`context` text,
|
||||
`permission` varchar(32) NOT NULL,
|
||||
`suspended_at` bigint DEFAULT NULL,
|
||||
`suspended_reason` varchar(64) DEFAULT NULL,
|
||||
`expires_at` bigint DEFAULT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_event_subscriptions_sub_id` (`sub_id`),
|
||||
KEY `idx_event_subscriptions_token` (`token`),
|
||||
KEY `idx_event_subscriptions_holder` (`holder_user_id`, `app_uid`),
|
||||
KEY `idx_event_subscriptions_owner` (`owner_user_id`),
|
||||
CONSTRAINT `fk_event_subscriptions_owner` FOREIGN KEY (`owner_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_event_subscriptions_holder` FOREIGN KEY (`holder_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -1,43 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `event_subscriptions`.`anchor_uid` was sized for a filesystem node uuid.
|
||||
-- A key-value subscription anchors on an app instead, and `apps`.`uid` is
|
||||
-- `char(40)`, so the column has to hold one.
|
||||
--
|
||||
-- Idempotent: the guarded procedure, as mig_27. There is no per-file
|
||||
-- applied-state tracking, so a replay has to be a no-op.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_event_subscriptions_anchor_uid;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_widen_event_subscriptions_anchor_uid()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND COLUMN_NAME = 'anchor_uid'
|
||||
AND CHARACTER_MAXIMUM_LENGTH < 40
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
MODIFY COLUMN `anchor_uid` varchar(40) NOT NULL;
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_widen_event_subscriptions_anchor_uid();
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_event_subscriptions_anchor_uid;
|
||||
@@ -1,37 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Named handlers an app deploys once. See sqlite/0076_event-handlers.sql for
|
||||
-- the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- as mig_29. There is no per-file applied-state tracking, so a replay has to
|
||||
-- be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_handlers` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`name` varchar(128) NOT NULL,
|
||||
`source` mediumtext NOT NULL,
|
||||
`source_hash` char(64) NOT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
`updated_at` bigint NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_event_handlers_app_name` (`app_uid`, `name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -1,58 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Teams. See sqlite/0077_teams.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so every statement must tolerate a re-run:
|
||||
-- columns go through _puter_add_col, indexes through the guarded procedure below.
|
||||
|
||||
CALL _puter_add_col('group', 'kind', '`kind` varchar(20) DEFAULT NULL');
|
||||
CALL _puter_add_col('group', 'name', '`name` varchar(255) DEFAULT NULL');
|
||||
CALL _puter_add_col('group', 'handle', '`handle` varchar(64) DEFAULT NULL');
|
||||
CALL _puter_add_col('group', 'deleted_at', '`deleted_at` timestamp NULL DEFAULT NULL');
|
||||
CALL _puter_add_col('jct_user_group', 'org_owned', '`org_owned` tinyint(1) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'requires_password_change', '`requires_password_change` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_team_indexes()
|
||||
BEGIN
|
||||
-- Already case-insensitive: the table is utf8mb4_unicode_ci. NULLs stay distinct.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'group'
|
||||
AND INDEX_NAME = 'idx_group_handle'
|
||||
) THEN
|
||||
ALTER TABLE `group` ADD UNIQUE INDEX `idx_group_handle` (`handle`);
|
||||
END IF;
|
||||
|
||||
-- Composite, unlike the existing single-column `group_id` key.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'jct_user_group'
|
||||
AND INDEX_NAME = 'idx_jct_user_group_group'
|
||||
) THEN
|
||||
ALTER TABLE `jct_user_group`
|
||||
ADD INDEX `idx_jct_user_group_group` (`group_id`, `user_id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_team_indexes();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
|
||||
@@ -1,47 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Deduplicate then constrain. See sqlite/0075 for why this is lossless.
|
||||
|
||||
-- Both steps sit behind the index check: mysql re-runs this file on every boot, and
|
||||
-- guarding them together stops a rolling deploy deleting and indexing concurrently.
|
||||
DROP PROCEDURE IF EXISTS _puter_add_membership_pair_index;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_membership_pair_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'jct_user_group'
|
||||
AND INDEX_NAME = 'idx_jct_user_group_pair'
|
||||
) THEN
|
||||
-- Self-join because mysql refuses `NOT IN (SELECT ... FROM jct_user_group)` (1093).
|
||||
DELETE dup FROM `jct_user_group` dup
|
||||
JOIN `jct_user_group` keep
|
||||
ON dup.`user_id` = keep.`user_id`
|
||||
AND dup.`group_id` = keep.`group_id`
|
||||
AND dup.`id` > keep.`id`;
|
||||
|
||||
ALTER TABLE `jct_user_group`
|
||||
ADD UNIQUE INDEX `idx_jct_user_group_pair` (`user_id`, `group_id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_membership_pair_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_membership_pair_index;
|
||||
@@ -1,84 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0076. Types follow mysql_mig_1; the guarded procedure follows mysql_mig_22.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `audit_team_membership` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int unsigned DEFAULT NULL,
|
||||
`group_id_keep` int unsigned NOT NULL,
|
||||
`user_id` int unsigned DEFAULT NULL,
|
||||
`user_id_keep` int unsigned NOT NULL,
|
||||
`actor_user_id` int unsigned DEFAULT NULL,
|
||||
`action` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_audit_team_membership_group` (`group_id_keep`, `id`),
|
||||
KEY `idx_audit_team_membership_user` (`user_id_keep`, `id`),
|
||||
KEY `idx_audit_team_membership_group_fk` (`group_id`),
|
||||
KEY `idx_audit_team_membership_user_fk` (`user_id`),
|
||||
KEY `idx_audit_team_membership_actor_fk` (`actor_user_id`),
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
CONSTRAINT `fk_audit_team_membership_group` FOREIGN KEY (`group_id`)
|
||||
REFERENCES `group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_audit_team_membership_user` FOREIGN KEY (`user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_audit_team_membership_actor` FOREIGN KEY (`actor_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CALL _puter_add_col('share', 'holder_group_id', '`holder_group_id` int unsigned DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_group_share_constraints;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_group_share_constraints()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_holder_group'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD INDEX `idx_share_holder_group` (`holder_group_id`, `id`);
|
||||
END IF;
|
||||
-- Team shares leave `holder_user_id` NULL, so the existing unique index binds none.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_holder_group_entry_issuer'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_holder_group_entry_issuer`
|
||||
(`holder_group_id`, `fsentry_id`, `issuer_user_id`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND CONSTRAINT_NAME = 'share_holder_group_fk'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD CONSTRAINT `share_holder_group_fk`
|
||||
FOREIGN KEY (`holder_group_id`) REFERENCES `group` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_group_share_constraints();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_group_share_constraints;
|
||||
@@ -1,68 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Opaque names for shared regions of a user's key-value namespace. See
|
||||
-- sqlite/0080_kv-share-handles.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- as mig_29. There is no per-file applied-state tracking, so a replay has to
|
||||
-- be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `kv_share_handles` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`handle` varchar(64) NOT NULL,
|
||||
`owner_user_id` int unsigned NOT NULL,
|
||||
`grantee_user_id` int unsigned NOT NULL,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion. No foreign key: a handle outlives
|
||||
-- the app it was minted against, which is what keeps it revocable.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`key_prefix` varchar(1024) NOT NULL,
|
||||
`permission` varchar(1024) NOT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
`revoked_at` bigint DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_kv_share_handles_handle` (`handle`),
|
||||
KEY `idx_kv_share_handles_owner` (`owner_user_id`, `id`),
|
||||
CONSTRAINT `fk_kv_share_handles_owner` FOREIGN KEY (`owner_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_kv_share_handles_grantee` FOREIGN KEY (`grantee_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- A subscription made through a handle stores the grant string it was
|
||||
-- authorized under rather than an access mode, and a grant string carries a
|
||||
-- user uuid, an app uid and a key prefix. Guarded on the current length, as
|
||||
-- mig_24: replaying a column change on a growing table rebuilds it every boot.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_subscription_permission;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_widen_subscription_permission()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND COLUMN_NAME = 'permission'
|
||||
AND CHARACTER_MAXIMUM_LENGTH < 1024
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions` MODIFY `permission` varchar(1024) NOT NULL;
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL _puter_widen_subscription_permission();
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_subscription_permission;
|
||||
@@ -1,60 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `event_subscriptions` grew three hot queries with no index behind them:
|
||||
-- a handler's own rows, the expiry reaper and the suspended reaper. See
|
||||
-- sqlite/0081_event-subscriptions-indexes.sql for the query shapes.
|
||||
--
|
||||
-- Idempotent: the guarded procedure, as mig_30/mig_34/mig_35. There is no
|
||||
-- per-file applied-state tracking, so a replay has to be a no-op.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_event_subscriptions_indexes()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND INDEX_NAME = 'idx_event_subscriptions_app_handler'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD INDEX `idx_event_subscriptions_app_handler` (`app_uid`, `handler_name`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND INDEX_NAME = 'idx_event_subscriptions_expires'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD INDEX `idx_event_subscriptions_expires` (`expires_at`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND INDEX_NAME = 'idx_event_subscriptions_suspended'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD INDEX `idx_event_subscriptions_suspended` (`suspended_at`, `id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_event_subscriptions_indexes();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_indexes;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0082_temp-password-expiry.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so the column goes through _puter_add_col.
|
||||
|
||||
CALL _puter_add_col('user', 'temp_password_expires_at', '`temp_password_expires_at` bigint DEFAULT NULL');
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0083_team-directory.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so the column goes through _puter_add_col.
|
||||
|
||||
CALL _puter_add_col('group', 'directory_enabled', '`directory_enabled` tinyint(1) NOT NULL DEFAULT 0');
|
||||
@@ -72,8 +72,7 @@ CREATE TABLE IF NOT EXISTS "user" (
|
||||
signup_origin varchar(255),
|
||||
signup_server varchar(255),
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
reputation smallint DEFAULT 100,
|
||||
home varchar(64)
|
||||
reputation smallint DEFAULT 100
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Enforce "at most one account owns an email address". Mirrors SQLite
|
||||
-- migration 0066 and MySQL migration 21; see those for the full rationale.
|
||||
--
|
||||
-- In short: `user.email` is deliberately not UNIQUE because several rows may
|
||||
-- hold the same address while unconfirmed. What must not happen is two rows
|
||||
-- both owning it — confirmed, or holding a password and so able to drive
|
||||
-- password recovery for that inbox. The application checks for an owner before
|
||||
-- every write, but a check and a write are not one operation.
|
||||
--
|
||||
-- If this fails, the DB already contains duplicate owners; collapse them first
|
||||
-- (admin → One-off Jobs → Collapse Duplicate Emails).
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_owned_email
|
||||
ON "user" (COALESCE(clean_email, LOWER(email)))
|
||||
WHERE email IS NOT NULL
|
||||
AND (email_confirmed = TRUE OR password IS NOT NULL);
|
||||
|
||||
-- One Puter account per external identity. OIDCStore.link already assumes this
|
||||
-- constraint exists — it catches the unique violation to tell "re-linking the
|
||||
-- same account" apart from "this sub belongs to someone else" — but the table
|
||||
-- never actually had it.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_provider_sub
|
||||
ON user_oidc_providers (provider, provider_sub);
|
||||
@@ -1,42 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Grow share from a pending-email-invite table into the index of active
|
||||
-- shares. See sqlite/0067_share_entries.sql for the column rationale.
|
||||
|
||||
ALTER TABLE share
|
||||
ADD COLUMN IF NOT EXISTS holder_user_id integer
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS fsentry_id integer
|
||||
REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS mode varchar(20),
|
||||
ADD COLUMN IF NOT EXISTS applied_at timestamp;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_holder
|
||||
ON share (holder_user_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_fsentry
|
||||
ON share (fsentry_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_holder_entry_issuer
|
||||
ON share (holder_user_id, fsentry_id, issuer_user_id);
|
||||
|
||||
-- Retiring a deleted node's grants looks them up by permission text, and the
|
||||
-- primary key is (issuer, holder, permission) — `permission` is never the
|
||||
-- leading column, so the delete path would otherwise scan the table. The
|
||||
-- text_pattern_ops class is what makes the left-anchored LIKE a range scan
|
||||
-- under a non-C collation; plain equality still uses it.
|
||||
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_permission
|
||||
ON user_to_user_permissions (permission text_pattern_ops);
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- One user refusing contact from another. See sqlite/0068_user-block.sql for
|
||||
-- the rationale. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_block (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
blocker_user_id integer NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
blocked_user_id integer NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_block_pair
|
||||
ON user_block (blocker_user_id, blocked_user_id);
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
|
||||
-- `dangerous`. No migration creates them — they were added by hand back when
|
||||
-- hardcoded permissions were keyed by group name. That map is now a flat
|
||||
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
|
||||
-- so a group nothing looks up grants nothing, and the rows are dead weight.
|
||||
--
|
||||
-- Guarded, and deliberately so: user_to_group_permissions.group_id and
|
||||
-- jct_user_group.group_id both cascade on delete, so removing a group that
|
||||
-- still carries permissions or members would silently revoke them from every
|
||||
-- member. Only a group with neither is dropped. A group that survives this
|
||||
-- migration has dependents and needs a deliberate decision, not a sweep --
|
||||
-- query user_to_group_permissions by group_id to see what it holds.
|
||||
DELETE FROM "group" g
|
||||
WHERE g.extra ->> 'name' IN ('freeai', 'experimental', 'dangerous')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_to_group_permissions p WHERE p.group_id = g.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jct_user_group j WHERE j.group_id = g.id
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- "Shared by me" pages by keyset: issuer equality, then `id` as the cursor.
|
||||
-- The single-column issuer index (postgres_mig_1) serves the equality but not
|
||||
-- the ordering; this composite lets the issued half of that listing range-scan
|
||||
-- without a sort.
|
||||
CREATE INDEX IF NOT EXISTS idx_share_issuer
|
||||
ON share (issuer_user_id, id);
|
||||
@@ -1,55 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Scope tuple for `notification`. See mysql/mysql_mig_26.sql for the rationale.
|
||||
-- `app_uid` is varchar(40) — the width of `apps`.`uid` — and varchar rather
|
||||
-- than char for the same reason `uid` is here: blank padding is not what the
|
||||
-- column means.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS and the `type = ''` guard on the backfill.
|
||||
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS app_uid varchar(40);
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS audience varchar(16) NOT NULL DEFAULT 'account';
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS type varchar(64) NOT NULL DEFAULT '';
|
||||
|
||||
-- Serves both the scoped list and the unacknowledged count.
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_scope
|
||||
ON notification (user_id, audience, app_uid, acknowledged);
|
||||
|
||||
UPDATE notification
|
||||
SET type = 'share.received', audience = 'account', app_uid = NULL
|
||||
WHERE type = ''
|
||||
AND value->>'template' = 'file-shared-with-you';
|
||||
|
||||
UPDATE notification
|
||||
SET type = 'share.claimed', audience = 'account', app_uid = NULL
|
||||
WHERE type = ''
|
||||
AND value->>'template' = 'file-shared-before-you-joined';
|
||||
|
||||
-- Worker rows carried only `source: 'worker'` and a title, so the app they
|
||||
-- belong to is unrecoverable and they stay unattributed. The title prefix is
|
||||
-- the sole surviving signal of which way the deploy went.
|
||||
UPDATE notification
|
||||
SET audience = 'developer',
|
||||
app_uid = NULL,
|
||||
type = CASE
|
||||
WHEN value->>'title' LIKE 'Successfully deployed %'
|
||||
THEN 'app.worker.deployed'
|
||||
ELSE 'app.worker.deployFailed'
|
||||
END
|
||||
WHERE type = ''
|
||||
AND value->>'source' = 'worker';
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Index `notification`.`created_at` for the retention sweep. See
|
||||
-- mysql/mysql_mig_27.sql for the rationale.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_created_at
|
||||
ON notification (created_at);
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Home region column. Mirrors SQLite migration 0074. Home region this
|
||||
-- account's data belongs in — a node id, not a storage region. NULL for
|
||||
-- accounts that predate the column, which fall back to `signup_server`.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS home varchar(64);
|
||||
@@ -1,53 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Subscriptions that outlive the connection that made them. See
|
||||
-- sqlite/0075_event-subscriptions.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sub_id VARCHAR(80) NOT NULL UNIQUE,
|
||||
token VARCHAR(255) NOT NULL,
|
||||
owner_user_id INTEGER NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
holder_user_id INTEGER NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_uid VARCHAR(40) DEFAULT NULL,
|
||||
subject VARCHAR(4096) NOT NULL,
|
||||
anchor_uid VARCHAR(36) NOT NULL,
|
||||
anchor_path VARCHAR(4096) NOT NULL,
|
||||
"match" VARCHAR(1024) DEFAULT NULL,
|
||||
delivery VARCHAR(16) NOT NULL,
|
||||
ops VARCHAR(64) DEFAULT NULL,
|
||||
handler_name VARCHAR(128) DEFAULT NULL,
|
||||
targets JSONB NOT NULL,
|
||||
context TEXT DEFAULT NULL,
|
||||
permission VARCHAR(32) NOT NULL,
|
||||
suspended_at BIGINT DEFAULT NULL,
|
||||
suspended_reason VARCHAR(64) DEFAULT NULL,
|
||||
expires_at BIGINT DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_token
|
||||
ON event_subscriptions (token);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_holder
|
||||
ON event_subscriptions (holder_user_id, app_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_owner
|
||||
ON event_subscriptions (owner_user_id);
|
||||
@@ -1,35 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `event_subscriptions.anchor_uid` was sized for a filesystem node uuid. A
|
||||
-- key-value subscription anchors on an app instead, and an app uid is 40
|
||||
-- characters. See mysql/mysql_mig_30.sql.
|
||||
--
|
||||
-- Guarded: an unconditional ALTER COLUMN TYPE takes an ACCESS EXCLUSIVE lock
|
||||
-- on every replay, even widening to the same type. Skipped once the column is
|
||||
-- already wide enough.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (
|
||||
SELECT character_maximum_length FROM information_schema.columns
|
||||
WHERE table_name = 'event_subscriptions' AND column_name = 'anchor_uid'
|
||||
) < 40 THEN
|
||||
ALTER TABLE event_subscriptions
|
||||
ALTER COLUMN anchor_uid TYPE VARCHAR(40);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,34 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Named handlers an app deploys once. See sqlite/0076_event-handlers.sql for
|
||||
-- the column rationale.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_handlers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
source_hash VARCHAR(64) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_handlers_app_name
|
||||
ON event_handlers (app_uid, name);
|
||||
@@ -1,38 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Teams. See sqlite/0077_teams.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS kind text;
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS name text;
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS handle text;
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS deleted_at timestamp;
|
||||
|
||||
-- On lower(handle): text is case-sensitive here, unlike MySQL's utf8mb4_unicode_ci.
|
||||
-- Handle lookups must therefore compare lower(handle) to hit this index.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_handle ON "group" (lower(handle));
|
||||
|
||||
-- No idx_group_owner here: idx_group_owner_user_id already covers it.
|
||||
|
||||
ALTER TABLE jct_user_group ADD COLUMN IF NOT EXISTS org_owned smallint;
|
||||
|
||||
-- Composite, unlike the existing single-column idx_jct_user_group_group_id.
|
||||
CREATE INDEX IF NOT EXISTS idx_jct_user_group_group
|
||||
ON jct_user_group (group_id, user_id);
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_password_change smallint NOT NULL DEFAULT 0;
|
||||
@@ -1,34 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Deduplicate then constrain. See sqlite/0075 for why this is lossless.
|
||||
|
||||
-- Guarded because postgres re-runs every file on each boot; `to_regclass` follows
|
||||
-- search_path, so it resolves under a test schema too.
|
||||
DO $mig15$
|
||||
BEGIN
|
||||
IF to_regclass('idx_jct_user_group_pair') IS NULL THEN
|
||||
DELETE FROM jct_user_group
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM jct_user_group GROUP BY user_id, group_id
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$mig15$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_jct_user_group_pair
|
||||
ON jct_user_group (user_id, group_id);
|
||||
@@ -1,57 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0076. Types follow `audit_user_to_group_permissions` in postgres_mig_1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_team_membership (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
group_id integer REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
group_id_keep integer NOT NULL,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
actor_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
|
||||
action varchar(255) NOT NULL,
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_group
|
||||
ON audit_team_membership (group_id_keep, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_user
|
||||
ON audit_team_membership (user_id_keep, id);
|
||||
|
||||
-- SET NULL has to find the child rows; postgres does not index FK columns for you.
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_group_fk
|
||||
ON audit_team_membership (group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_user_fk
|
||||
ON audit_team_membership (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_actor_fk
|
||||
ON audit_team_membership (actor_user_id);
|
||||
|
||||
ALTER TABLE share ADD COLUMN IF NOT EXISTS holder_group_id integer
|
||||
REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_holder_group
|
||||
ON share (holder_group_id, id);
|
||||
|
||||
-- Default NULLS DISTINCT, matching the other engines, so this binds only team shares.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_holder_group_entry_issuer
|
||||
ON share (holder_group_id, fsentry_id, issuer_user_id);
|
||||
@@ -1,64 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Opaque names for shared regions of a user's key-value namespace. See
|
||||
-- sqlite/0080_kv-share-handles.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kv_share_handles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
handle VARCHAR(64) NOT NULL,
|
||||
owner_user_id INTEGER NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
grantee_user_id INTEGER NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
key_prefix VARCHAR(1024) NOT NULL,
|
||||
permission VARCHAR(1024) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
revoked_at BIGINT DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_kv_share_handles_handle
|
||||
ON kv_share_handles (handle);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner
|
||||
ON kv_share_handles (owner_user_id, id);
|
||||
|
||||
-- A cascading delete has to find the child rows, and postgres does not index
|
||||
-- FK columns for you.
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee_fk
|
||||
ON kv_share_handles (grantee_user_id);
|
||||
|
||||
-- A subscription made through a handle stores the grant string it was
|
||||
-- authorized under rather than an access mode, and a grant string carries a
|
||||
-- user uuid, an app uid and a key prefix.
|
||||
--
|
||||
-- Guarded: an unconditional ALTER COLUMN TYPE takes an ACCESS EXCLUSIVE lock
|
||||
-- on every replay, even widening to the same type. Skipped once the column is
|
||||
-- already wide enough.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (
|
||||
SELECT character_maximum_length FROM information_schema.columns
|
||||
WHERE table_name = 'event_subscriptions' AND column_name = 'permission'
|
||||
) < 1024 THEN
|
||||
ALTER TABLE event_subscriptions
|
||||
ALTER COLUMN permission TYPE VARCHAR(1024);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,31 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `event_subscriptions` grew three hot queries with no index behind them:
|
||||
-- a handler's own rows, the expiry reaper and the suspended reaper. See
|
||||
-- sqlite/0081_event-subscriptions-indexes.sql for the query shapes.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_app_handler
|
||||
ON event_subscriptions (app_uid, handler_name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_expires
|
||||
ON event_subscriptions (expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_suspended
|
||||
ON event_subscriptions (suspended_at, id);
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0082_temp-password-expiry.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS temp_password_expires_at bigint DEFAULT NULL;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0083_team-directory.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS directory_enabled smallint NOT NULL DEFAULT 0;
|
||||
@@ -1,53 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Enforce "at most one account owns an email address".
|
||||
--
|
||||
-- `user.email` is deliberately not UNIQUE: several rows may legitimately hold
|
||||
-- the same address while unconfirmed (admin-provisioned placeholders, signups
|
||||
-- that were never confirmed, temp accounts on their way to becoming real). What
|
||||
-- must never happen is two rows both *owning* an address — owning meaning the
|
||||
-- row is confirmed, or holds a password and so can drive password recovery for
|
||||
-- that inbox.
|
||||
--
|
||||
-- Signup, save-account, change-email, OIDC and admin provisioning each check for
|
||||
-- an owner before writing, but a check and an insert are not one operation: two
|
||||
-- requests can both read "free" and both write. This index is what actually
|
||||
-- holds the invariant; the application checks just produce a nicer error most of
|
||||
-- the time.
|
||||
--
|
||||
-- Matching is on the canonical address so provider aliases
|
||||
-- (`[email protected]` vs `[email protected]`) collide. `clean_email` is
|
||||
-- written on every modern write path; the COALESCE covers rows old enough to
|
||||
-- predate the column.
|
||||
--
|
||||
-- If this CREATE fails, the DB already contains duplicate owners. Collapse them
|
||||
-- first (admin → One-off Jobs → Collapse Duplicate Emails) — there is no safe
|
||||
-- automatic merge of two accounts.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_owned_email
|
||||
ON user(COALESCE(clean_email, lower(email)))
|
||||
WHERE email IS NOT NULL
|
||||
AND (email_confirmed = 1 OR password IS NOT NULL);
|
||||
|
||||
-- One Puter account per external identity. OIDCStore.link already assumes this
|
||||
-- constraint exists — it catches the unique violation to tell "re-linking the
|
||||
-- same account" apart from "this sub belongs to someone else" — but the table
|
||||
-- never actually had it, so two concurrent first-time logins could each create
|
||||
-- an account and each link the same sub. Subsequent logins then resolved to
|
||||
-- whichever row came back first.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_provider_sub
|
||||
ON user_oidc_providers(provider, provider_sub);
|
||||
@@ -1,60 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Grow `share` from a pending-email-invite table into the index of active
|
||||
-- shares. Permissions stay the source of truth for access; this is what makes
|
||||
-- shares listable and gives them a lifecycle.
|
||||
--
|
||||
-- - `holder_user_id` : NULL while an invite awaits signup.
|
||||
-- - `fsentry_id` : the shared node. ON DELETE CASCADE retires the share
|
||||
-- with the file, which permissions alone don't do.
|
||||
-- - `mode` : see|list|read|write|manage. Unconstrained on purpose —
|
||||
-- ACLService owns the mode set and adding one shouldn't
|
||||
-- need a three-dialect migration.
|
||||
-- - `applied_at` : set when an invite is claimed. Claiming updates the
|
||||
-- row rather than deleting it, so the share stays
|
||||
-- queryable.
|
||||
|
||||
ALTER TABLE `share` ADD COLUMN `holder_user_id` INTEGER DEFAULT NULL
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE `share` ADD COLUMN `fsentry_id` INTEGER DEFAULT NULL
|
||||
REFERENCES `fsentries` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE `share` ADD COLUMN `mode` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `share` ADD COLUMN `applied_at` TIMESTAMP DEFAULT NULL;
|
||||
|
||||
-- "Shared with me", keyset-paginated: ORDER BY ends in `id` as the tiebreaker.
|
||||
CREATE INDEX IF NOT EXISTS `idx_share_holder`
|
||||
ON `share` (`holder_user_id`, `id`);
|
||||
|
||||
-- Who has access to one node — also how an owner sees a manage-delegate's
|
||||
-- re-grants, which the issuer/holder permission tables can't answer.
|
||||
CREATE INDEX IF NOT EXISTS `idx_share_fsentry` ON `share` (`fsentry_id`);
|
||||
|
||||
-- One row per (holder, node, issuer). Pending invites have a NULL
|
||||
-- holder_user_id and so aren't covered here; dedup for those belongs with the
|
||||
-- invite flow.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_holder_entry_issuer`
|
||||
ON `share` (`holder_user_id`, `fsentry_id`, `issuer_user_id`);
|
||||
|
||||
-- Retiring a deleted node's grants looks them up by permission text
|
||||
-- (`fs:<uuid>` and `fs:<uuid>:%`), and the subject only lives in that text —
|
||||
-- no column, so no foreign key can cascade it. The table's primary key is
|
||||
-- (issuer, holder, permission), which never puts `permission` first, so
|
||||
-- without this the equality and the left-anchored LIKE both degrade to a full
|
||||
-- scan on the delete path. With it, each is a range scan.
|
||||
CREATE INDEX IF NOT EXISTS `idx_user_to_user_permissions_permission`
|
||||
ON `user_to_user_permissions` (`permission`);
|
||||
@@ -1,32 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- One referral code, one account. The mysql and postgres baselines have
|
||||
-- declared `user.referral_code` UNIQUE since the column was introduced; the
|
||||
-- sqlite baseline only declared the column, so the invariant held on hosted
|
||||
-- deployments and not on self-hosted ones.
|
||||
--
|
||||
-- Codes are minted by picking at random and writing, so this index is not a
|
||||
-- nicety: it is what makes "did anyone already take this code" a decision the
|
||||
-- database makes once, instead of a read the next mint can race. A caller that
|
||||
-- loses the race sees a unique violation and picks again.
|
||||
--
|
||||
-- NULL is distinct from NULL in a sqlite unique index, so accounts that have
|
||||
-- never asked for a code (the majority) are unaffected.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_referral_code
|
||||
ON user(referral_code)
|
||||
WHERE referral_code IS NOT NULL;
|
||||
@@ -1,39 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- One user refusing contact from another. Sharing is what reads it today —
|
||||
-- a blocked sender's share is refused outright, and their pending invite is
|
||||
-- dropped when the blocker confirms the address it was aimed at — but the
|
||||
-- table is deliberately not share-specific: the same answer serves anything
|
||||
-- else one person can push at another.
|
||||
--
|
||||
-- Rows are read on the share path by exact (blocker, blocked) pair, which the
|
||||
-- unique index answers as a point lookup; the same index's leading column
|
||||
-- serves "who have I blocked". `created_at` is unix seconds, matching
|
||||
-- `app_feedback`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_block` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"blocker_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"blocked_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_user_block_pair`
|
||||
ON `user_block` (`blocker_user_id`, `blocked_user_id`);
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
|
||||
-- `dangerous`. No migration creates them — they were added by hand back when
|
||||
-- hardcoded permissions were keyed by group name. That map is now a flat
|
||||
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
|
||||
-- so a group nothing looks up grants nothing, and the rows are dead weight.
|
||||
--
|
||||
-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and
|
||||
-- `jct_user_group.group_id` both cascade on delete, so removing a group that
|
||||
-- still carries permissions or members would silently revoke them from every
|
||||
-- member. Only a group with neither is dropped. A group that survives this
|
||||
-- migration has dependents and needs a deliberate decision, not a sweep --
|
||||
-- query `user_to_group_permissions` by `group_id` to see what it holds.
|
||||
DELETE FROM `group`
|
||||
WHERE json_extract(`extra`, '$.name') IN ('freeai', 'experimental', 'dangerous')
|
||||
AND `id` NOT IN (SELECT `group_id` FROM `user_to_group_permissions`)
|
||||
AND `id` NOT IN (SELECT `group_id` FROM `jct_user_group`);
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- "Shared by me" pages by keyset: issuer equality, then `id` as the cursor.
|
||||
-- The issuer foreign key created no index here (unlike mysql, where the FK's
|
||||
-- index implicitly ends in the primary key), so without this the issued half
|
||||
-- of that listing walks the whole table.
|
||||
CREATE INDEX IF NOT EXISTS `idx_share_issuer`
|
||||
ON `share` (`issuer_user_id`, `id`);
|
||||
@@ -1,104 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Scope tuple for `notification`. See mysql/mysql_mig_26.sql for the rationale.
|
||||
--
|
||||
-- Done as the standard 12-step rebuild rather than three ALTERs, because the
|
||||
-- same table is missing two things mysql and postgres have had since the v1
|
||||
-- schema: an index on `user_id`, which every `listByUserId` scans without, and
|
||||
-- the cascade that retires a deleted user's rows. A column add cannot
|
||||
-- introduce a foreign key here.
|
||||
--
|
||||
-- Rows whose user is already gone are dropped rather than carried over: the
|
||||
-- cascade this migration adds is what would have removed them.
|
||||
--
|
||||
-- Wrapped in one transaction: without it, a crash between the `DROP` and the
|
||||
-- `RENAME` below would lose the table outright rather than leave it
|
||||
-- unmigrated. `IF EXISTS`/`IF NOT EXISTS` guard a stale `notification_new`
|
||||
-- from a previous interrupted attempt.
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS `notification_new`;
|
||||
|
||||
CREATE TABLE `notification_new` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"uid" TEXT NOT NULL UNIQUE,
|
||||
"value" JSON NOT NULL,
|
||||
"acknowledged" INTEGER DEFAULT NULL,
|
||||
"shown" INTEGER DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"app_uid" TEXT DEFAULT NULL,
|
||||
"audience" TEXT NOT NULL DEFAULT 'account',
|
||||
"type" TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY("user_id") REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `notification_new` (
|
||||
`id`, `user_id`, `uid`, `value`, `acknowledged`, `shown`, `created_at`
|
||||
)
|
||||
SELECT
|
||||
`id`, `user_id`, `uid`, `value`, `acknowledged`, `shown`, `created_at`
|
||||
FROM `notification`
|
||||
WHERE `user_id` IN (SELECT `id` FROM `user`);
|
||||
|
||||
DROP TABLE IF EXISTS `notification`;
|
||||
|
||||
ALTER TABLE `notification_new` RENAME TO `notification`;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_user_id`
|
||||
ON `notification` (`user_id`);
|
||||
|
||||
-- Serves both the scoped list and the unacknowledged count.
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_scope`
|
||||
ON `notification` (`user_id`, `audience`, `app_uid`, `acknowledged`);
|
||||
|
||||
-- Backfill from the markers the payload actually carried. `type = ''` is the
|
||||
-- not-yet-classified state, so a re-run matches nothing the first pass
|
||||
-- claimed, and a row matching no marker keeps the defaults (`''` / 'account')
|
||||
-- and reads as legacy. `json_valid` guards rows whose `value` was stored as a
|
||||
-- bare string.
|
||||
|
||||
UPDATE `notification`
|
||||
SET `type` = 'share.received', `audience` = 'account', `app_uid` = NULL
|
||||
WHERE `type` = ''
|
||||
AND json_valid(`value`)
|
||||
AND json_extract(`value`, '$.template') = 'file-shared-with-you';
|
||||
|
||||
UPDATE `notification`
|
||||
SET `type` = 'share.claimed', `audience` = 'account', `app_uid` = NULL
|
||||
WHERE `type` = ''
|
||||
AND json_valid(`value`)
|
||||
AND json_extract(`value`, '$.template') = 'file-shared-before-you-joined';
|
||||
|
||||
-- Worker rows carried only `source: 'worker'` and a title, so the app they
|
||||
-- belong to is unrecoverable and they stay unattributed. The title prefix is
|
||||
-- the sole surviving signal of which way the deploy went.
|
||||
UPDATE `notification`
|
||||
SET `audience` = 'developer',
|
||||
`app_uid` = NULL,
|
||||
`type` = CASE
|
||||
WHEN json_extract(`value`, '$.title') LIKE 'Successfully deployed %'
|
||||
THEN 'app.worker.deployed'
|
||||
ELSE 'app.worker.deployFailed'
|
||||
END
|
||||
WHERE `type` = ''
|
||||
AND json_valid(`value`)
|
||||
AND json_extract(`value`, '$.source') = 'worker';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Index `notification`.`created_at` for the retention sweep. See
|
||||
-- mysql/mysql_mig_27.sql for the rationale. A plain index add, so no table
|
||||
-- rebuild.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_created_at`
|
||||
ON `notification` (`created_at`);
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Home region this account's data belongs in — a node id, not a storage
|
||||
-- region. NULL for every account that predates the column, which falls back to
|
||||
-- `signup_server`. What a name maps to is deployment config, so adding a
|
||||
-- region needs no migration.
|
||||
ALTER TABLE `user` ADD COLUMN `home` VARCHAR(64) DEFAULT NULL;
|
||||
@@ -1,90 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Subscriptions that outlive the connection that made them. Session
|
||||
-- subscriptions never reach this table — they are Redis only — so every row
|
||||
-- here is one somebody has to be able to find and revoke later.
|
||||
--
|
||||
-- - `sub_id` : `<appUid>#<uuid>`, or `user#<uuid>` for a row a user
|
||||
-- session created. Unique table-wide; it is what
|
||||
-- `unsubscribe` names.
|
||||
-- - `owner_user_id` : owner of the anchor node. Dispatch only knows whose
|
||||
-- resource changed, so this is the side a shared-folder
|
||||
-- subscription has to be findable from, and the key a
|
||||
-- region's cache is rebuilt under.
|
||||
-- - `holder_user_id` : who subscribed — the delivery target, whose access is
|
||||
-- re-checked, and who the quota counts against.
|
||||
-- - `app_uid` : NULL for a row a user session created. No foreign
|
||||
-- key: a row outlives the app that made it, which is
|
||||
-- what keeps it listable and revocable afterwards.
|
||||
-- - `anchor_*` : the resolved anchor. `match` is a glob relative to
|
||||
-- `anchor_path`, so the path is not decoration — the
|
||||
-- dispatch filter reads it on every event.
|
||||
-- - `ops` : op filter; a comma-separated set, NULL for every op.
|
||||
-- - `targets` : JSON array over socket|worker|push.
|
||||
-- - `context` : plaintext JSON, hard-capped at 4 KB, read only on the
|
||||
-- delivery path and never returned by `list`.
|
||||
-- - `permission` : the ACL mode the subscribe check passed under,
|
||||
-- re-checked per delivery.
|
||||
-- - `suspended_*` : set when a subscription stops delivering without
|
||||
-- being removed; the state machine that drives them
|
||||
-- lands with the delivery classes that need it.
|
||||
--
|
||||
-- `created_at` and the two timestamps are unix seconds, matching
|
||||
-- `app_feedback` and `user_block`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_subscriptions` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"sub_id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"owner_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"holder_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"app_uid" TEXT DEFAULT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"anchor_uid" TEXT NOT NULL,
|
||||
"anchor_path" TEXT NOT NULL,
|
||||
"match" TEXT DEFAULT NULL,
|
||||
"delivery" TEXT NOT NULL,
|
||||
"ops" TEXT DEFAULT NULL,
|
||||
"handler_name" TEXT DEFAULT NULL,
|
||||
"targets" TEXT NOT NULL,
|
||||
"context" TEXT DEFAULT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"suspended_at" INTEGER DEFAULT NULL,
|
||||
"suspended_reason" TEXT DEFAULT NULL,
|
||||
"expires_at" INTEGER DEFAULT NULL,
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_subscriptions_sub_id`
|
||||
ON `event_subscriptions` (`sub_id`);
|
||||
|
||||
-- Dispatch reads the table only when a region's cache is cold; this is the
|
||||
-- lookup it falls back to.
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_token`
|
||||
ON `event_subscriptions` (`token`);
|
||||
|
||||
-- List, revoke and quota all key here — the index is the scope check rather
|
||||
-- than a filter over a wider read.
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_holder`
|
||||
ON `event_subscriptions` (`holder_user_id`, `app_uid`);
|
||||
|
||||
-- Rebuilding one region's cache for one owner, and the sweep that follows it.
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_owner`
|
||||
ON `event_subscriptions` (`owner_user_id`);
|
||||
@@ -1,50 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Named handlers an app deploys once and its users' subscriptions bind to by
|
||||
-- name. A handler is an addressable object with its own lifecycle: nothing
|
||||
-- triggers by name, and a row here runs only when a subscription bound to it
|
||||
-- has a delivery.
|
||||
--
|
||||
-- - `app_uid` : the namespace. Names are unique per app, and there is no
|
||||
-- foreign key for the same reason `event_subscriptions`
|
||||
-- has none — a row has to stay manageable after the app
|
||||
-- row moves.
|
||||
-- - `name` : the identity a subscription binds to, stable across
|
||||
-- source changes.
|
||||
-- - `source` : the serialized function. Read only by the delivery path
|
||||
-- and never returned by `list`.
|
||||
-- - `source_hash` : change detector and idempotency key. A publish carrying
|
||||
-- the same hash is a no-op, and a subscription sending an
|
||||
-- inline hash binds only when it matches this one.
|
||||
--
|
||||
-- `created_at` / `updated_at` are unix seconds, matching `event_subscriptions`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_handlers` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"source_hash" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL, -- unix seconds
|
||||
"updated_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The name is the identity, and it is unique inside one app. This index is
|
||||
-- also the lookup a subscribe binding check runs on.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_handlers_app_name`
|
||||
ON `event_handlers` (`app_uid`, `name`);
|
||||
@@ -1,41 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- A `group` row with `kind = 'team'` owns accounts and pays for them; seeded
|
||||
-- system groups keep `kind` NULL so a team query never returns them.
|
||||
|
||||
ALTER TABLE `group` ADD COLUMN `kind` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `group` ADD COLUMN `name` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `group` ADD COLUMN `handle` TEXT COLLATE NOCASE DEFAULT NULL;
|
||||
ALTER TABLE `group` ADD COLUMN `deleted_at` TIMESTAMP DEFAULT NULL;
|
||||
|
||||
-- NOCASE on both column and index, so lookups match mysql's utf8mb4_unicode_ci.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_group_handle`
|
||||
ON `group` (`handle` COLLATE NOCASE);
|
||||
|
||||
-- mysql and postgres already index this column; sqlite does not.
|
||||
CREATE INDEX IF NOT EXISTS `idx_group_owner` ON `group` (`owner_user_id`);
|
||||
|
||||
-- 1 = team-created, 0 = the team owner. Decides who pays, not who may read.
|
||||
ALTER TABLE `jct_user_group` ADD COLUMN `org_owned` INTEGER DEFAULT NULL;
|
||||
|
||||
-- Covers "list this team's members"; the unique pair index lands in 0072.
|
||||
CREATE INDEX IF NOT EXISTS `idx_jct_user_group_group`
|
||||
ON `jct_user_group` (`group_id`, `user_id`);
|
||||
|
||||
-- Fourth `requires_*` flag; assertVerifiedAccount will enforce it in phase 5.
|
||||
ALTER TABLE `user` ADD COLUMN `requires_password_change` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `GroupStore.addUsers` had no conflict clause, so a repeat call duplicated the pair.
|
||||
-- `readUserGroupPerms` joins this table on group_id alone, so each duplicate reported
|
||||
-- every group permission an extra time. The delete clears what accumulated.
|
||||
-- Dropping the higher id is lossless: `addUsers` writes only the two id columns, and
|
||||
-- nothing references `jct_user_group.id`.
|
||||
|
||||
DELETE FROM `jct_user_group`
|
||||
WHERE `id` NOT IN (
|
||||
SELECT MIN(`id`) FROM `jct_user_group` GROUP BY `user_id`, `group_id`
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_jct_user_group_pair`
|
||||
ON `jct_user_group` (`user_id`, `group_id`);
|
||||
@@ -1,66 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Insert-only record of what an admin did to an account, plus `share.holder_group_id`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `audit_team_membership` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Nullable FK beside a NOT NULL `_keep`, as `audit_user_to_group_permissions` since 0019.
|
||||
"group_id" INTEGER DEFAULT NULL,
|
||||
"group_id_keep" INTEGER NOT NULL,
|
||||
"user_id" INTEGER DEFAULT NULL,
|
||||
"user_id_keep" INTEGER NOT NULL,
|
||||
"actor_user_id" INTEGER DEFAULT NULL,
|
||||
|
||||
"action" TEXT NOT NULL,
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
FOREIGN KEY("group_id") REFERENCES "group" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
FOREIGN KEY("actor_user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- The admin's view: one team, newest first.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group`
|
||||
ON `audit_team_membership` (`group_id_keep`, `id`);
|
||||
|
||||
-- The member's view; the only place a reset becomes visible to its subject.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_user`
|
||||
ON `audit_team_membership` (`user_id_keep`, `id`);
|
||||
|
||||
-- Without these, SET NULL scans the audit table on every user or group delete.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group_fk`
|
||||
ON `audit_team_membership` (`group_id`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_user_fk`
|
||||
ON `audit_team_membership` (`user_id`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_actor_fk`
|
||||
ON `audit_team_membership` (`actor_user_id`);
|
||||
|
||||
-- Mirrors `holder_user_id` from 0067: a `share` row is a listing entry, not the grant.
|
||||
ALTER TABLE `share` ADD COLUMN `holder_group_id` INTEGER DEFAULT NULL
|
||||
REFERENCES `group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_share_holder_group`
|
||||
ON `share` (`holder_group_id`, `id`);
|
||||
|
||||
-- Team shares leave `holder_user_id` NULL, and NULLs are distinct, so the existing
|
||||
-- unique index binds none of them.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_holder_group_entry_issuer`
|
||||
ON `share` (`holder_group_id`, `fsentry_id`, `issuer_user_id`);
|
||||
@@ -1,59 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- An opaque name for a region of one user's key-value namespace that somebody
|
||||
-- else may watch. Key-value subjects carry no owner component, so without this
|
||||
-- a shared key is unaddressable: the handle is what a filesystem node uid
|
||||
-- already is, an owner-independent name for a shared root.
|
||||
--
|
||||
-- - `handle` : `kvh-<uuid>`, deliberately shaped unlike an app uid so
|
||||
-- the subject parser can tell which slot it is in. It is
|
||||
-- the only form the grantee ever sees, and it names
|
||||
-- neither the owner's username nor their uuid.
|
||||
-- - `permission` : the user-to-user grant this handle mirrors. Stored
|
||||
-- rather than derived: it is the key revocation settling
|
||||
-- matches subscriptions on, and deriving it would tie the
|
||||
-- settle path to a lookup per row.
|
||||
-- - `key_prefix` : the granted root, always ending on the key delimiter,
|
||||
-- so `key_prefix + relative` is a whole key.
|
||||
-- - `revoked_at` : set rather than deleted. A revoked handle stays visible
|
||||
-- to its owner, which is the only record of what was
|
||||
-- shared and when it stopped.
|
||||
--
|
||||
-- `created_at` / `revoked_at` are unix seconds, matching `event_subscriptions`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `kv_share_handles` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"handle" TEXT NOT NULL,
|
||||
"owner_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"grantee_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"key_prefix" TEXT NOT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL, -- unix seconds
|
||||
"revoked_at" INTEGER DEFAULT NULL
|
||||
);
|
||||
|
||||
-- Resolving a subject is a lookup by handle, on the subscribe path.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_kv_share_handles_handle`
|
||||
ON `kv_share_handles` (`handle`);
|
||||
|
||||
-- The owner's audit listing, and the scope check behind revoke.
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner`
|
||||
ON `kv_share_handles` (`owner_user_id`, `id`);
|
||||
@@ -1,35 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `event_subscriptions` grew three hot queries with no index behind them:
|
||||
--
|
||||
-- - `(app_uid, handler_name)` : a handler's own rows (`listByHandler`) —
|
||||
-- suspending/resuming everything one
|
||||
-- publish or removal touches.
|
||||
-- - `(expires_at)` : the expiry reaper's sweep.
|
||||
-- - `(suspended_at, id)` : the suspended reaper's sweep. The range on
|
||||
-- `suspended_at` is the win; `id` only keeps
|
||||
-- the reaper's keyset page off the table.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_app_handler`
|
||||
ON `event_subscriptions` (`app_uid`, `handler_name`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_expires`
|
||||
ON `event_subscriptions` (`expires_at`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_suspended`
|
||||
ON `event_subscriptions` (`suspended_at`, `id`);
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Unix seconds after which an admin-issued temporary password stops
|
||||
-- authenticating, so an unused reset dies instead of becoming a standing
|
||||
-- credential. NULL for every password the account chose itself.
|
||||
ALTER TABLE `user` ADD COLUMN `temp_password_expires_at` INTEGER DEFAULT NULL;
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Whether apps acting for a member may read this team's member list.
|
||||
-- Members can already enumerate each other through `/teams/:uid/members`,
|
||||
-- which requires a user actor; this opens the same names to an app actor,
|
||||
-- so it is off until the team owner turns it on.
|
||||
ALTER TABLE `group` ADD COLUMN `directory_enabled` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -3,19 +3,18 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
@@ -303,35 +302,6 @@ describe('DDBClient — batch writes', () => {
|
||||
const stored = await client.query(TABLE, { pk: 'bulk' });
|
||||
expect(stored.Items).toHaveLength(60);
|
||||
});
|
||||
|
||||
it('reports no consumed capacity for an empty delete batch', async () => {
|
||||
await expect(client.batchDel([])).resolves.toEqual({
|
||||
ConsumedCapacity: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('batch-deletes across chunks, leaving unrelated items alone', async () => {
|
||||
const items = Array.from({ length: 30 }, (_, index) => ({
|
||||
table: TABLE,
|
||||
item: { pk: 'bulk-del', sk: `item-${index}`, index },
|
||||
}));
|
||||
await client.batchPut(items);
|
||||
|
||||
// Delete all but the last item — 29 keys still exercises chunking
|
||||
// paths shared with batchPut while proving deletion is targeted.
|
||||
const result = await client.batchDel(
|
||||
items.slice(0, -1).map(({ table, item }) => ({
|
||||
table,
|
||||
key: { pk: item.pk, sk: item.sk },
|
||||
})),
|
||||
);
|
||||
|
||||
expect(result.ConsumedCapacity).toHaveLength(1);
|
||||
expect(result.ConsumedCapacity[0].TableName).toBe(TABLE);
|
||||
|
||||
const stored = await client.query(TABLE, { pk: 'bulk-del' });
|
||||
expect(stored.Items?.map((item) => item.sk)).toEqual(['item-29']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DDBClient — expired item sweep', () => {
|
||||
|
||||
@@ -45,11 +45,6 @@ import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
clampStoredNumber,
|
||||
isRepairableMarshallError,
|
||||
repairForMarshall,
|
||||
} from './marshallRepair.js';
|
||||
|
||||
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
|
||||
const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
|
||||
@@ -119,49 +114,6 @@ const sleep = async (ms: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
// One caller writing an out-of-range number usually keeps doing it, so the
|
||||
// warning is per-target and throttled rather than one line per write.
|
||||
const REPAIR_WARNING_INTERVAL_MS = 60_000;
|
||||
const lastRepairWarningAt = new Map<string, number>();
|
||||
|
||||
const warnRepaired = (target: string, message: string): void => {
|
||||
const now = Date.now();
|
||||
const lastWarnedAt = lastRepairWarningAt.get(target) ?? 0;
|
||||
if (now - lastWarnedAt < REPAIR_WARNING_INTERVAL_MS) return;
|
||||
|
||||
lastRepairWarningAt.set(target, now);
|
||||
console.warn(
|
||||
`[ddb] clamped an out-of-range value in ${target}: ${message}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a write, and if the payload was rejected while being encoded for a value
|
||||
* the store cannot represent, clamp it and send it once more.
|
||||
*
|
||||
* Nothing is inspected on the way in: the payload of a successful write is
|
||||
* never walked, so this costs one `try` on the hot path. The repair only runs
|
||||
* on the failure, and only retries when it actually changed something —
|
||||
* otherwise the original error stands.
|
||||
*/
|
||||
const sendRepairingValues = async <TPayload, TResult>(
|
||||
payload: TPayload,
|
||||
send: (payload: TPayload) => Promise<TResult>,
|
||||
describe: () => string,
|
||||
): Promise<TResult> => {
|
||||
try {
|
||||
return await send(payload);
|
||||
} catch (error) {
|
||||
if (!isRepairableMarshallError(error)) throw error;
|
||||
|
||||
const repaired = repairForMarshall(payload);
|
||||
if (!repaired.changed) throw error;
|
||||
|
||||
warnRepaired(describe(), (error as Error).message);
|
||||
return send(repaired.value as TPayload);
|
||||
}
|
||||
};
|
||||
|
||||
export class DDBClient extends PuterClient {
|
||||
#documentClient: DynamoDBDocumentClient | null = null;
|
||||
#localInitPromise: Promise<void> | null = null;
|
||||
@@ -219,19 +171,14 @@ export class DDBClient extends PuterClient {
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
const command = new PutCommand({
|
||||
TableName: table,
|
||||
Item: item,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
return sendRepairingValues(
|
||||
item,
|
||||
(itemToWrite) =>
|
||||
client.send(
|
||||
new PutCommand({
|
||||
TableName: table,
|
||||
Item: itemToWrite,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
),
|
||||
() => `a put to ${table}`,
|
||||
);
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
@@ -275,37 +222,6 @@ export class DDBClient extends PuterClient {
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
|
||||
return this.#batchWrite(
|
||||
params.map(({ table, item }) => ({
|
||||
table,
|
||||
request: { PutRequest: { Item: item } },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Span('ddb.batchDel', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchDel(params: { table: string; key: Record<string, unknown> }[]) {
|
||||
return this.#batchWrite(
|
||||
params.map(({ table, key }) => ({
|
||||
table,
|
||||
request: { DeleteRequest: { Key: key } },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Shared BatchWriteItem plumbing for batchPut/batchDel: 25-item chunks,
|
||||
// UnprocessedItems retried with capped exponential backoff, consumed
|
||||
// capacity accumulated per table across every request.
|
||||
async #batchWrite(
|
||||
params: {
|
||||
table: string;
|
||||
request: NonNullable<
|
||||
BatchWriteCommandInput['RequestItems']
|
||||
>[string][number];
|
||||
}[],
|
||||
) {
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
if (params.length === 0) {
|
||||
return { ConsumedCapacity: [] };
|
||||
@@ -342,7 +258,11 @@ export class DDBClient extends PuterClient {
|
||||
let requestItems = chunk.reduce(
|
||||
(acc, curr) => {
|
||||
const tableRequests = acc[curr.table] ?? [];
|
||||
tableRequests.push(curr.request);
|
||||
tableRequests.push({
|
||||
PutRequest: {
|
||||
Item: curr.item,
|
||||
},
|
||||
});
|
||||
acc[curr.table] = tableRequests;
|
||||
return acc;
|
||||
},
|
||||
@@ -358,17 +278,11 @@ export class DDBClient extends PuterClient {
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await sendRepairingValues(
|
||||
requestItems,
|
||||
(itemsToWrite) =>
|
||||
client.send(
|
||||
new BatchWriteCommand({
|
||||
RequestItems: itemsToWrite,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
`a batch write to ${Object.keys(requestItems).join(', ')}`,
|
||||
const response = await client.send(
|
||||
new BatchWriteCommand({
|
||||
RequestItems: requestItems,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
);
|
||||
accumulateConsumedCapacity(
|
||||
response.ConsumedCapacity as
|
||||
@@ -414,18 +328,11 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
|
||||
async del<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
opts?: { returnOld?: boolean },
|
||||
) {
|
||||
async del<T extends Record<string, unknown>>(table: string, key: T) {
|
||||
const command = new DeleteCommand({
|
||||
TableName: table,
|
||||
Key: key,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
// ALL_OLD makes the delete an atomic claim: exactly one caller
|
||||
// gets the attributes back.
|
||||
...(opts?.returnOld ? { ReturnValues: 'ALL_OLD' as const } : {}),
|
||||
});
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
@@ -441,7 +348,6 @@ export class DDBClient extends PuterClient {
|
||||
index = '',
|
||||
consistentRead = false,
|
||||
options?: {
|
||||
scanIndexForward?: boolean;
|
||||
beginsWith?: { key: string; value: string };
|
||||
select?: 'COUNT';
|
||||
filter?: {
|
||||
@@ -498,9 +404,6 @@ export class DDBClient extends PuterClient {
|
||||
? { FilterExpression: options.filter.expression }
|
||||
: {}),
|
||||
...(options?.select ? { Select: options.select } : {}),
|
||||
...(options?.scanIndexForward !== undefined
|
||||
? { ScanIndexForward: options.scanIndexForward }
|
||||
: {}),
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
|
||||
@@ -515,36 +418,25 @@ export class DDBClient extends PuterClient {
|
||||
expression: string,
|
||||
expressionValues?: Record<string, unknown>,
|
||||
expressionNames?: Record<string, string>,
|
||||
options?: { condition?: string },
|
||||
) {
|
||||
const hasValues =
|
||||
!!expressionValues && Object.keys(expressionValues).length > 0;
|
||||
const hasNames =
|
||||
!!expressionNames && Object.keys(expressionNames).length > 0;
|
||||
const command = new UpdateCommand({
|
||||
TableName: table,
|
||||
Key: key,
|
||||
UpdateExpression: expression,
|
||||
...(hasValues
|
||||
? { ExpressionAttributeValues: expressionValues }
|
||||
: {}),
|
||||
...(hasNames ? { ExpressionAttributeNames: expressionNames } : {}),
|
||||
ReturnValues: 'ALL_NEW',
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
return sendRepairingValues(
|
||||
expressionValues,
|
||||
(valuesToWrite) =>
|
||||
client.send(
|
||||
new UpdateCommand({
|
||||
TableName: table,
|
||||
Key: key,
|
||||
UpdateExpression: expression,
|
||||
...(hasValues
|
||||
? { ExpressionAttributeValues: valuesToWrite }
|
||||
: {}),
|
||||
...(hasNames
|
||||
? { ExpressionAttributeNames: expressionNames }
|
||||
: {}),
|
||||
...(options?.condition
|
||||
? { ConditionExpression: options.condition }
|
||||
: {}),
|
||||
ReturnValues: 'ALL_NEW',
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
),
|
||||
() => `an update to ${table}`,
|
||||
);
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
async createTableIfNotExists(
|
||||
@@ -646,9 +538,6 @@ export class DDBClient extends PuterClient {
|
||||
marshallOptions: {
|
||||
removeUndefinedValues: true,
|
||||
},
|
||||
unmarshallOptions: {
|
||||
wrapNumbers: clampStoredNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -676,9 +565,6 @@ export class DDBClient extends PuterClient {
|
||||
marshallOptions: {
|
||||
removeUndefinedValues: true,
|
||||
},
|
||||
unmarshallOptions: {
|
||||
wrapNumbers: clampStoredNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clampStoredNumber,
|
||||
isRepairableMarshallError,
|
||||
MAX_STORED_NUMBER,
|
||||
MIN_STORED_NUMBER,
|
||||
repairForMarshall,
|
||||
} from './marshallRepair.ts';
|
||||
|
||||
describe('isRepairableMarshallError', () => {
|
||||
it('recognizes the out-of-range and special-value failures', () => {
|
||||
for (const message of [
|
||||
'Number 1.6515584833071455e+55 is greater than Number.MAX_SAFE_INTEGER. Use NumberValue from @aws-sdk/lib-dynamodb.',
|
||||
'Number -1e+55 is lesser than Number.MIN_SAFE_INTEGER. Use NumberValue from @aws-sdk/lib-dynamodb.',
|
||||
'Special numeric value NaN is not allowed',
|
||||
'Special numeric value Infinity is not allowed',
|
||||
]) {
|
||||
expect(isRepairableMarshallError(new Error(message))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves every other failure alone', () => {
|
||||
for (const error of [
|
||||
new Error('Unsupported type passed: [object Symbol].'),
|
||||
new Error('ValidationException'),
|
||||
new Error(''),
|
||||
undefined,
|
||||
'not an error',
|
||||
]) {
|
||||
expect(isRepairableMarshallError(error)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairForMarshall', () => {
|
||||
it('clamps a number past the safe range in both directions', () => {
|
||||
expect(repairForMarshall(1.6515584833071455e55)).toEqual({
|
||||
value: MAX_STORED_NUMBER,
|
||||
changed: true,
|
||||
});
|
||||
expect(repairForMarshall(-1e55)).toEqual({
|
||||
value: MIN_STORED_NUMBER,
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps infinities and nulls NaN', () => {
|
||||
expect(repairForMarshall(Infinity).value).toBe(MAX_STORED_NUMBER);
|
||||
expect(repairForMarshall(-Infinity).value).toBe(MIN_STORED_NUMBER);
|
||||
expect(repairForMarshall(NaN)).toEqual({ value: null, changed: true });
|
||||
});
|
||||
|
||||
it('repairs numbers nested in objects and arrays', () => {
|
||||
const repaired = repairForMarshall({
|
||||
profile: { netWorth: 1e55, level: 29 },
|
||||
scores: [1, [2, -1e55]],
|
||||
});
|
||||
|
||||
expect(repaired.changed).toBe(true);
|
||||
expect(repaired.value).toEqual({
|
||||
profile: { netWorth: MAX_STORED_NUMBER, level: 29 },
|
||||
scores: [1, [2, MIN_STORED_NUMBER]],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the original payload untouched when nothing is out of range', () => {
|
||||
const payload = {
|
||||
a: MAX_STORED_NUMBER,
|
||||
b: [1, 2, { c: 'three' }],
|
||||
d: null,
|
||||
};
|
||||
|
||||
const repaired = repairForMarshall(payload);
|
||||
expect(repaired.changed).toBe(false);
|
||||
expect(repaired.value).toBe(payload);
|
||||
});
|
||||
|
||||
it('does not mutate the payload it repairs', () => {
|
||||
const nested = { netWorth: 1e55 };
|
||||
const payload = { nested };
|
||||
|
||||
repairForMarshall(payload);
|
||||
|
||||
expect(nested.netWorth).toBe(1e55);
|
||||
});
|
||||
|
||||
it('keeps a `__proto__` key as data', () => {
|
||||
const payload = JSON.parse('{"__proto__":{"big":1e55}}');
|
||||
|
||||
const repaired = repairForMarshall(payload) as {
|
||||
value: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
expect(repaired.changed).toBe(true);
|
||||
expect(Object.getPrototypeOf(repaired.value)).toBe(Object.prototype);
|
||||
expect(
|
||||
Object.getOwnPropertyDescriptor(repaired.value, '__proto__')?.value,
|
||||
).toEqual({ big: MAX_STORED_NUMBER });
|
||||
});
|
||||
|
||||
it('leaves values it does not own alone', () => {
|
||||
const date = new Date(0);
|
||||
expect(repairForMarshall(date).value).toBe(date);
|
||||
expect(repairForMarshall('str')).toEqual({
|
||||
value: 'str',
|
||||
changed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampStoredNumber', () => {
|
||||
it('decodes a number inside the range unchanged', () => {
|
||||
expect(clampStoredNumber('42')).toBe(42);
|
||||
expect(clampStoredNumber('-0.5')).toBe(-0.5);
|
||||
expect(clampStoredNumber(String(MAX_STORED_NUMBER))).toBe(
|
||||
MAX_STORED_NUMBER,
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps a stored number past the range instead of decoding a BigInt', () => {
|
||||
expect(clampStoredNumber('18014398509481982')).toBe(MAX_STORED_NUMBER);
|
||||
expect(clampStoredNumber('-18014398509481982')).toBe(MIN_STORED_NUMBER);
|
||||
expect(clampStoredNumber('1e400')).toBe(MAX_STORED_NUMBER);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A stored number keeps its exact value only within the IEEE-754 safe integer
|
||||
* range; a magnitude past this cannot round-trip, and the document client
|
||||
* refuses to marshall one rather than write a value that reads back wrong.
|
||||
*/
|
||||
export const MAX_STORED_NUMBER = Number.MAX_SAFE_INTEGER;
|
||||
export const MIN_STORED_NUMBER = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
/**
|
||||
* Marshalling failures a retry can fix by clamping: an out-of-range number, or
|
||||
* a special numeric value (`NaN`, `±Infinity`) that has no representation at
|
||||
* all. Matched on the message because they are plain `Error`s thrown while
|
||||
* encoding the payload — no error name or code separates them from anything
|
||||
* else, and they never reach the wire, so there is no response to inspect.
|
||||
*/
|
||||
const REPAIRABLE_MARSHALL_ERRORS = [
|
||||
/is greater than Number\.MAX_SAFE_INTEGER/,
|
||||
/is lesser than Number\.MIN_SAFE_INTEGER/,
|
||||
/^Special numeric value /,
|
||||
];
|
||||
|
||||
export const isRepairableMarshallError = (error: unknown): boolean => {
|
||||
const message = (error as Error | undefined)?.message;
|
||||
if (typeof message !== 'string') return false;
|
||||
return REPAIRABLE_MARSHALL_ERRORS.some((pattern) => pattern.test(message));
|
||||
};
|
||||
|
||||
/**
|
||||
* The same bound, applied to what comes back. A stored number past the safe
|
||||
* range — left by an older write, or reached by a counter incremented up to it
|
||||
* — is otherwise decoded as a `BigInt`, which no JSON response can carry. This
|
||||
* hands back the same kind of value a write accepts, and does less work per
|
||||
* number than the default decoding it replaces.
|
||||
*/
|
||||
export const clampStoredNumber = (stored: string): number => {
|
||||
const num = Number(stored);
|
||||
if (num > MAX_STORED_NUMBER) return MAX_STORED_NUMBER;
|
||||
if (num < MIN_STORED_NUMBER) return MIN_STORED_NUMBER;
|
||||
return num;
|
||||
};
|
||||
|
||||
export interface MarshallRepair {
|
||||
value: unknown;
|
||||
/** False when nothing in the payload was out of range — nothing to retry. */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
const unchanged = (value: unknown): MarshallRepair => ({
|
||||
value,
|
||||
changed: false,
|
||||
});
|
||||
|
||||
// Matches the shapes the marshaller walks into as a map: object literals and
|
||||
// null-prototype objects, but not class instances (Date, Buffer, Set, …),
|
||||
// which it encodes by their own rules.
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value.constructor === Object || value.constructor === undefined);
|
||||
|
||||
const repairNumber = (num: number): MarshallRepair => {
|
||||
// No representation for these; JSON already turns them into null, so a
|
||||
// caller that got one this far did not send it as JSON.
|
||||
if (Number.isNaN(num)) return { value: null, changed: true };
|
||||
if (num > MAX_STORED_NUMBER)
|
||||
return { value: MAX_STORED_NUMBER, changed: true };
|
||||
if (num < MIN_STORED_NUMBER)
|
||||
return { value: MIN_STORED_NUMBER, changed: true };
|
||||
return unchanged(num);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp every number a payload holds into the range the store accepts,
|
||||
* returning the original object untouched when nothing needed it — callers hold
|
||||
* on to the values they pass (caches, retries), so a repair copies rather than
|
||||
* mutating in place.
|
||||
*
|
||||
* Only run after a marshalling failure. Walking a payload this way costs as
|
||||
* much as marshalling it, which is exactly why writes are not checked up
|
||||
* front.
|
||||
*/
|
||||
export const repairForMarshall = (value: unknown): MarshallRepair => {
|
||||
if (typeof value === 'number') return repairNumber(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const repairedEntries = value.map((entry) => {
|
||||
const repaired = repairForMarshall(entry);
|
||||
changed = changed || repaired.changed;
|
||||
return repaired.value;
|
||||
});
|
||||
return changed ? { value: repairedEntries, changed } : unchanged(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
let changed = false;
|
||||
const repairedEntries = Object.entries(value).map(([key, entry]) => {
|
||||
const repaired = repairForMarshall(entry);
|
||||
changed = changed || repaired.changed;
|
||||
return [key, repaired.value] as const;
|
||||
});
|
||||
// `fromEntries` defines each property rather than assigning it, so a
|
||||
// `__proto__` key in the payload stays data here.
|
||||
return changed
|
||||
? { value: Object.fromEntries(repairedEntries), changed }
|
||||
: unchanged(value);
|
||||
}
|
||||
|
||||
return unchanged(value);
|
||||
};
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { digestLines } from '../../services/share/shareNotifyTitle';
|
||||
import { EmailClient } from './EmailClient';
|
||||
|
||||
const FROM = '"Puter" <[email protected]>';
|
||||
@@ -55,22 +54,14 @@ const sentMessage = (info: unknown) =>
|
||||
};
|
||||
|
||||
describe('EmailClient — transport lifecycle', () => {
|
||||
it('stops sending once the transport is shut down', async () => {
|
||||
it('reports as configured once a transport is wired up', () => {
|
||||
const client = startClient();
|
||||
expect(
|
||||
await client.sendRaw({ to: '[email protected]', subject: 'up', text: 'x' }),
|
||||
).not.toBeNull();
|
||||
|
||||
expect(client.isConfigured).toBe(true);
|
||||
client.onServerShutdown();
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(
|
||||
await client.sendRaw({ to: '[email protected]', subject: 'down', text: 'x' }),
|
||||
).toBeNull();
|
||||
warn.mockRestore();
|
||||
expect(client.isConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it('warns at boot when no transport is set', () => {
|
||||
it('warns and stays unconfigured when no transport is set', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new EmailClient({
|
||||
port: 0,
|
||||
@@ -78,6 +69,7 @@ describe('EmailClient — transport lifecycle', () => {
|
||||
} as unknown as IConfig);
|
||||
client.onServerStart();
|
||||
|
||||
expect(client.isConfigured).toBe(false);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no email transport configured'),
|
||||
);
|
||||
@@ -246,186 +238,6 @@ describe('EmailClient — template rendering', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailClient — share notification templates', () => {
|
||||
/** Captures both parts of a send without touching the transport. */
|
||||
const renderShare = async (
|
||||
template: 'file_shared_with_you' | 'file_shared_invite',
|
||||
values: Record<string, unknown>,
|
||||
) => {
|
||||
const client = startClient();
|
||||
const captured: { html: string; text: string } = { html: '', text: '' };
|
||||
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
|
||||
captured.html = options.html ?? '';
|
||||
captured.text = options.text ?? '';
|
||||
return null;
|
||||
});
|
||||
await client.send('[email protected]', template, values);
|
||||
return captured;
|
||||
};
|
||||
|
||||
// Built by the producer rather than hand-shaped, so a change to the digest
|
||||
// wording can't leave these fixtures describing a shape it no longer emits.
|
||||
const HOLDER = {
|
||||
recipient: 'alice',
|
||||
subject_line: 'bob shared notes.md with you',
|
||||
shares: digestLines([
|
||||
{
|
||||
username: 'bob',
|
||||
count: 1,
|
||||
items: [{ name: 'notes.md', link: 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md' }],
|
||||
},
|
||||
{
|
||||
username: 'carol',
|
||||
count: 3,
|
||||
items: [{ name: 'a.txt' }, { name: 'b.txt' }],
|
||||
},
|
||||
]),
|
||||
link: 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md',
|
||||
origin: 'https://puter.test',
|
||||
unsubscribe_uuid: null,
|
||||
};
|
||||
|
||||
it('sends a plain-text alternative beside the html', async () => {
|
||||
const { html, text } = await renderShare(
|
||||
'file_shared_with_you',
|
||||
HOLDER,
|
||||
);
|
||||
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
// Every sender reaches both parts, so a text-only client loses nothing.
|
||||
for (const part of [html, text]) {
|
||||
expect(part).toContain('bob');
|
||||
expect(part).toContain('notes.md');
|
||||
expect(part).toContain('carol');
|
||||
expect(part).toContain('+1 more');
|
||||
}
|
||||
expect(text).not.toContain('<');
|
||||
});
|
||||
|
||||
it('escapes item names in the html and leaves them raw in the text', async () => {
|
||||
const { html, text } = await renderShare('file_shared_with_you', {
|
||||
...HOLDER,
|
||||
shares: digestLines([
|
||||
{
|
||||
username: 'bob',
|
||||
count: 1,
|
||||
items: [{ name: 'r&d "notes".md' }],
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(html).toContain('r&d "notes".md');
|
||||
expect(text).toContain('r&d "notes".md');
|
||||
});
|
||||
|
||||
// The name links to the item. The URL is ours, built from the origin and one
|
||||
// encoded path, so it stays literal — an entity-escaped `=` would still
|
||||
// resolve, but the text part has no parser to undo it.
|
||||
it('links a named item to itself in both parts', async () => {
|
||||
const link = 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md';
|
||||
const { html, text } = await renderShare('file_shared_with_you', HOLDER);
|
||||
|
||||
expect(html).toContain(`<a href="${link}"`);
|
||||
expect(html).toContain(`>notes.md</a>`);
|
||||
expect(text).toContain(link);
|
||||
expect(html).not.toContain('=');
|
||||
});
|
||||
|
||||
// An item with no link — an invite, with no account to route to yet —
|
||||
// renders as the plain name it always did.
|
||||
it('leaves an unlinked item as plain text', async () => {
|
||||
const { html } = await renderShare('file_shared_with_you', HOLDER);
|
||||
|
||||
expect(html).toContain('a.txt');
|
||||
expect(html).not.toContain('>a.txt</a>');
|
||||
});
|
||||
|
||||
it('renders as a responsive single column with no remote assets', async () => {
|
||||
const { html } = await renderShare('file_shared_with_you', HOLDER);
|
||||
|
||||
expect(html).toContain(
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
);
|
||||
expect(html).toContain('@media only screen and (max-width: 600px)');
|
||||
expect(html).toContain('@media (prefers-color-scheme: dark)');
|
||||
expect(html).toContain('max-width: 600px');
|
||||
// Images are the one thing a client can refuse to load, so the design
|
||||
// does without them — the layout can't depend on a blocked asset.
|
||||
expect(html).not.toContain('<img');
|
||||
// The colors have to survive a client that drops the stylesheet.
|
||||
expect(html).toContain('style="background-color: #ffffff;');
|
||||
});
|
||||
|
||||
it('opens with a preview line that is hidden in the body', async () => {
|
||||
const { html } = await renderShare('file_shared_with_you', HOLDER);
|
||||
|
||||
const preheader = html.indexOf('Waiting for you under Shared with me.');
|
||||
expect(preheader).toBeGreaterThan(-1);
|
||||
expect(html.slice(0, preheader)).toContain('mso-hide: all');
|
||||
expect(preheader).toBeLessThan(html.indexOf('Hi alice,'));
|
||||
});
|
||||
|
||||
// The button carries every item in the mail, so it is a query string with
|
||||
// `&` and `=` in it — which must reach the client as written, in both parts.
|
||||
it('points the call to action at Shared with every item picked out', async () => {
|
||||
const link =
|
||||
'https://puter.test/?shared=%2Fbob%2Fu1%2Fa.txt&shared=%2Fbob%2Fu2%2Fb.txt';
|
||||
const { html, text } = await renderShare('file_shared_with_you', {
|
||||
...HOLDER,
|
||||
link,
|
||||
});
|
||||
|
||||
expect(html).toContain(`href="${link}"`);
|
||||
expect(html).toContain('>Open Puter</a>');
|
||||
expect(text).toContain(`Open Puter: ${link}`);
|
||||
expect(html).not.toContain('=');
|
||||
expect(html).not.toContain('&shared');
|
||||
});
|
||||
|
||||
it('separates senders without a rule above the first', async () => {
|
||||
const { html } = await renderShare('file_shared_with_you', HOLDER);
|
||||
|
||||
// One rule between two senders, none leading the list.
|
||||
expect(html.split('border-top: 1px solid').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('offers the unsubscribe link only to a recipient who has an account', async () => {
|
||||
const withAccount = await renderShare('file_shared_with_you', {
|
||||
...HOLDER,
|
||||
unsubscribe_uuid: 'a-uuid',
|
||||
});
|
||||
expect(withAccount.html).toContain(
|
||||
'href="https://puter.test/unsubscribe?user_uuid=a-uuid"',
|
||||
);
|
||||
expect(withAccount.text).toContain(
|
||||
'https://puter.test/unsubscribe?user_uuid=a-uuid',
|
||||
);
|
||||
|
||||
const anonymous = await renderShare('file_shared_with_you', HOLDER);
|
||||
expect(anonymous.html).not.toContain('/unsubscribe');
|
||||
expect(anonymous.text).not.toContain('/unsubscribe');
|
||||
});
|
||||
|
||||
it('tells an invited address what to do with it', async () => {
|
||||
const { html, text } = await renderShare('file_shared_invite', {
|
||||
email: '[email protected]',
|
||||
subject_line: 'bob shared notes.md with you on Puter',
|
||||
shares: digestLines([
|
||||
{ username: 'bob', count: 1, items: [{ name: 'notes.md' }] },
|
||||
]),
|
||||
link: 'https://puter.test',
|
||||
});
|
||||
|
||||
for (const part of [html, text]) {
|
||||
expect(part).toContain('[email protected]');
|
||||
expect(part).toContain('Create your free account');
|
||||
// An invite has no account to unsubscribe from.
|
||||
expect(part).not.toContain('/unsubscribe');
|
||||
}
|
||||
expect(html).toContain('href="https://puter.test"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailClient.clean', () => {
|
||||
const clean = (email: string) => startClient().clean(email);
|
||||
|
||||
|
||||
@@ -22,21 +22,12 @@ import handlebars, { template } from 'handlebars';
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
EMAIL_TEMPLATES,
|
||||
type EmailTemplate,
|
||||
type EmailTemplateName,
|
||||
} from './templates';
|
||||
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
|
||||
|
||||
/**
|
||||
* Attachment shape passed through to the underlying transport. Give either
|
||||
* `content` (held in memory) or `path` (a local file the transport streams on
|
||||
* its own, re-read for every message that carries it).
|
||||
*/
|
||||
/** Attachment shape passed through to the underlying transport. */
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
content?: Buffer | string;
|
||||
path?: string;
|
||||
content: Buffer | string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
@@ -73,7 +64,6 @@ export type EmailValidator = (email: string) => Promise<boolean> | boolean;
|
||||
interface CompiledTemplate {
|
||||
subject: ReturnType<typeof template>;
|
||||
html: ReturnType<typeof template>;
|
||||
text?: ReturnType<typeof template>;
|
||||
}
|
||||
|
||||
// -- Clean-email rules ------------------------------------------------
|
||||
@@ -166,28 +156,23 @@ export class EmailClient extends PuterClient {
|
||||
* Render a template and send it to `to`. `options.replyTo` sets the
|
||||
* Reply-To header (e.g. so a recipient can respond to the originator of the
|
||||
* message rather than the no-reply From address).
|
||||
*
|
||||
* Returns the transport's send result, or `null` when none is configured —
|
||||
* {@link sendRaw} drops the message instead of throwing. A caller that
|
||||
* treats undelivered mail as a failure must check for `null` too.
|
||||
*/
|
||||
async send<T extends EmailTemplateName>(
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
) {
|
||||
): Promise<void> {
|
||||
const compiled = this.compiledTemplates[template];
|
||||
if (!compiled) {
|
||||
throw new Error(`Unknown email template: ${template}`);
|
||||
}
|
||||
|
||||
return await this.sendRaw({
|
||||
await this.sendRaw({
|
||||
from: this.defaultFrom(),
|
||||
to,
|
||||
subject: compiled.subject(values),
|
||||
html: compiled.html(values),
|
||||
...(compiled.text ? { text: compiled.text(values) } : {}),
|
||||
...(options.replyTo ? { replyTo: options.replyTo } : {}),
|
||||
});
|
||||
}
|
||||
@@ -198,8 +183,7 @@ export class EmailClient extends PuterClient {
|
||||
*
|
||||
* Returns the transport's send result, or `null` when no transport is
|
||||
* configured (the send is a no-op in that case — callers that must not
|
||||
* silently drop mail should check `config.email` before composing, or the
|
||||
* `null` return after).
|
||||
* silently drop mail should check `isConfigured` first).
|
||||
*/
|
||||
async sendRaw(options: SendMailOptions) {
|
||||
if (!this.transport) {
|
||||
@@ -215,6 +199,11 @@ export class EmailClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
|
||||
get isConfigured(): boolean {
|
||||
return this.transport !== null;
|
||||
}
|
||||
|
||||
// -- Public API: clean / validate ---------------------------------
|
||||
|
||||
/**
|
||||
@@ -300,11 +289,7 @@ export class EmailClient extends PuterClient {
|
||||
}
|
||||
|
||||
private compileTemplates(): void {
|
||||
// Widened to the interface: the literal map keeps a distinct type per
|
||||
// template, and only some of them carry a `text` part.
|
||||
const templates: Record<EmailTemplateName, EmailTemplate> =
|
||||
EMAIL_TEMPLATES;
|
||||
for (const [name, template] of Object.entries(templates)) {
|
||||
for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) {
|
||||
this.compiledTemplates[name as EmailTemplateName] = {
|
||||
// Subjects are plain-text headers: HTML-escaping would put
|
||||
// literal entities in front of the recipient (& etc.).
|
||||
@@ -315,15 +300,6 @@ export class EmailClient extends PuterClient {
|
||||
noEscape: true,
|
||||
}),
|
||||
html: handlebars.compile(dedent(template.html)),
|
||||
// Same reasoning as the subject: a text part is not HTML, so
|
||||
// escaping would show entities to the reader.
|
||||
...(template.text
|
||||
? {
|
||||
text: handlebars.compile(dedent(template.text), {
|
||||
noEscape: true,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,195 +29,8 @@
|
||||
export interface EmailTemplate {
|
||||
subject: string;
|
||||
html: string;
|
||||
/**
|
||||
* Optional plain-text alternative. Where a template has one it goes out
|
||||
* beside the HTML as multipart/alternative, which is what plain-text
|
||||
* clients, screen readers and spam scoring all prefer to a machine
|
||||
* down-conversion of the markup.
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// -- Shared layout ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Colors, in one place so the two share templates can't drift apart. Every one
|
||||
* of them is also written inline further down: clients that drop `<style>`
|
||||
* (Gmail reading a non-Gmail account, Outlook.com) would otherwise render an
|
||||
* unstyled page, so the stylesheet only carries what inline CSS can't express —
|
||||
* the small-screen and dark-mode overrides.
|
||||
*
|
||||
* `ACCENT` is the GUI's primary blue darkened until white text on it clears
|
||||
* 4.5:1, since a button label is small text however bold it is.
|
||||
*/
|
||||
const INK = '#101828';
|
||||
const INK_SOFT = '#5b6472';
|
||||
const INK_FAINT = '#667085';
|
||||
const PAGE_BG = '#f2f4f7';
|
||||
const CARD_BG = '#ffffff';
|
||||
const CARD_BORDER = '#e4e7ec';
|
||||
const RULE = '#e6e9ee';
|
||||
const PANEL_BG = '#f8f9fb';
|
||||
const PANEL_BORDER = '#eceff3';
|
||||
const ACCENT = '#0a76cc';
|
||||
|
||||
const FONT =
|
||||
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif";
|
||||
|
||||
/** Pads the preview line so body text can't leak into it. */
|
||||
const PREHEADER_FILL = '͏‌ '.repeat(60);
|
||||
|
||||
/**
|
||||
* The document both share notifications render into: one 600px column that goes
|
||||
* full width on a phone, laid out with tables and `bgcolor` because Outlook
|
||||
* renders through Word and ignores anything more modern.
|
||||
*
|
||||
* `preheader` is the line the inbox shows beside the subject — it should add to
|
||||
* the subject, not repeat it. `content` is the card's rows; `footer` sits below
|
||||
* the card, where the reason-for-receiving and unsubscribe belong.
|
||||
*/
|
||||
const shareEmailLayout = (parts: {
|
||||
preheader: string;
|
||||
content: string;
|
||||
footer: string;
|
||||
}): string => `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<title>{{subject_line}}</title>
|
||||
<!--[if mso]>
|
||||
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
||||
<![endif]-->
|
||||
<style>
|
||||
body { margin: 0; padding: 0; width: 100% !important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table { border-spacing: 0; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
td { mso-line-height-rule: exactly; }
|
||||
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; }
|
||||
@media only screen and (max-width: 600px) {
|
||||
.col { width: 100% !important; }
|
||||
.pad { padding-left: 24px !important; padding-right: 24px !important; }
|
||||
.pad-y { padding-top: 28px !important; padding-bottom: 28px !important; }
|
||||
.h1 { font-size: 22px !important; line-height: 30px !important; }
|
||||
.btn { width: 100% !important; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.page { background-color: #0e1116 !important; }
|
||||
.card { background-color: #171b22 !important; border-color: #272d38 !important; }
|
||||
.ink { color: #e9ebef !important; }
|
||||
.ink-soft { color: #9aa3b2 !important; }
|
||||
.ink-soft a { color: #9aa3b2 !important; }
|
||||
.rule { border-color: #272d38 !important; }
|
||||
.panel { background-color: #1d222a !important; border-color: #2b323d !important; }
|
||||
.mark { color: #4da3f2 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="page" style="margin: 0; padding: 0; width: 100%; background-color: ${PAGE_BG};">
|
||||
<div style="display: none; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all;">${parts.preheader}${PREHEADER_FILL}</div>
|
||||
<div role="article" aria-roledescription="email" lang="en">
|
||||
<table role="presentation" class="page" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%; background-color: ${PAGE_BG};">
|
||||
<tr>
|
||||
<td align="center" style="padding: 32px 12px;">
|
||||
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
|
||||
<table role="presentation" class="col" width="600" cellpadding="0" cellspacing="0" border="0" style="width: 100%; max-width: 600px; text-align: left;">
|
||||
<tr>
|
||||
<td class="pad mark" style="padding: 0 4px 14px; font-family: ${FONT}; font-size: 15px; line-height: 20px; font-weight: 700; letter-spacing: 0.01em; color: ${ACCENT};">Puter</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="card" style="background-color: ${CARD_BG}; border: 1px solid ${CARD_BORDER}; border-radius: 14px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
|
||||
<tr>
|
||||
<td class="pad pad-y" style="padding: 36px 36px 34px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">${parts.content}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad ink-soft" style="padding: 20px 4px 0; font-family: ${FONT}; font-size: 13px; line-height: 20px; color: ${INK_FAINT};">${parts.footer}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
/** The greeting line above the heading. */
|
||||
const greetingRow = (text: string): string => `
|
||||
<tr>
|
||||
<td class="ink-soft" style="padding: 0 0 6px; font-family: ${FONT}; font-size: 15px; line-height: 22px; color: ${INK_SOFT};">${text}</td>
|
||||
</tr>`;
|
||||
|
||||
const headingRow = (text: string): string => `
|
||||
<tr>
|
||||
<td class="ink h1" style="padding: 0 0 18px; font-family: ${FONT}; font-size: 24px; line-height: 32px; font-weight: 700; letter-spacing: -0.01em; color: ${INK};">${text}</td>
|
||||
</tr>`;
|
||||
|
||||
/**
|
||||
* One row per sender, hairline-separated. The wording comes pre-composed from
|
||||
* the digest (`digestLines`), so the list stays a list however many senders and
|
||||
* items fold into it.
|
||||
*
|
||||
* Each named item links to itself where it has one; `lead`/`trail` are the
|
||||
* wording around the names. An unlinked item renders as the plain name.
|
||||
*
|
||||
* `link` is triple-braced because we build it, so `?` and `=` stay literal;
|
||||
* `name` is the owner's text and stays escaped.
|
||||
*/
|
||||
const SHARE_ITEM_LIST = `{{this.lead}}{{#each this.items}}{{#unless @first}}, {{/unless}}{{#if this.link}}<a href="{{{this.link}}}" style="color: ${ACCENT}; text-decoration: underline;">{{this.name}}</a>{{else}}{{this.name}}{{/if}}{{/each}}{{this.trail}}`;
|
||||
|
||||
const SHARE_LIST_ROW = `
|
||||
<tr>
|
||||
<td class="panel" style="padding: 6px 18px; background-color: ${PANEL_BG}; border: 1px solid ${PANEL_BORDER}; border-radius: 10px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
|
||||
{{#each shares}}
|
||||
<tr>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
/** A body paragraph. `padding` lets a caller tune the rhythm around it. */
|
||||
const textRow = (text: string, padding = '18px 0 0'): string => `
|
||||
<tr>
|
||||
<td class="ink-soft" style="padding: ${padding}; font-family: ${FONT}; font-size: 15px; line-height: 23px; color: ${INK_SOFT};">${text}</td>
|
||||
</tr>`;
|
||||
|
||||
/**
|
||||
* The call to action. Padding sits on the cell and the color on `bgcolor` so
|
||||
* Outlook still draws a real button (square-cornered, which is fine); the table
|
||||
* goes full width under 600px so the tap target spans the card.
|
||||
*
|
||||
* `link` is triple-braced for the same reason as an item's: we build it, and a
|
||||
* deep link's `=` and `&` should read the same in the html as in the text.
|
||||
*/
|
||||
const buttonRow = (label: string): string => `
|
||||
<tr>
|
||||
<td style="padding: 26px 0 0;">
|
||||
<table role="presentation" class="btn" cellpadding="0" cellspacing="0" border="0" style="border-collapse: separate;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="${ACCENT}" style="background-color: ${ACCENT}; border-radius: 10px; padding: 14px 26px;">
|
||||
<a href="{{{link}}}" style="display: inline-block; font-family: ${FONT}; font-size: 16px; line-height: 20px; font-weight: 600; color: #ffffff; text-decoration: none;">${label}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export const EMAIL_TEMPLATES = {
|
||||
'approved-for-listing': {
|
||||
subject: '🎉 Your app has been approved for listing!',
|
||||
@@ -344,64 +157,6 @@ [email protected] immediately.
|
||||
<p>A password recovery request was issued for your account, please follow the link below to reset your password:</p>
|
||||
<p><a href="{{link}}">{{link}}</a></p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_account_created: {
|
||||
subject: 'Your {{team_name}} account on Puter',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
|
||||
They will send you a temporary password separately; you will be asked to
|
||||
choose your own the first time you sign in.</p>
|
||||
<p>What this means:</p>
|
||||
<ul>
|
||||
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
|
||||
<li>{{team_name}} <b>cannot</b> see your files, apps or data.</li>
|
||||
<li>{{team_name}} <b>can</b> reset your password, which would let them sign in
|
||||
as you. Choose your own password promptly.</li>
|
||||
</ul>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_account_disabled: {
|
||||
subject: 'Your {{team_name}} account has been disabled',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has disabled your Puter account <b>{{username}}</b>. You can no
|
||||
longer sign in to it.</p>
|
||||
<p>Nothing has been deleted. Your files, apps and data are as you left them, and
|
||||
{{team_name}} can re-enable the account at any time. If you believe this is a
|
||||
mistake, ask them.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_closed: {
|
||||
subject: '{{team_name}} has been closed',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has closed its Puter team, and your account
|
||||
<b>{{username}}</b> has been disabled along with it. You can no longer sign in to
|
||||
it.</p>
|
||||
<p>Nothing has been deleted. Your files and data are still there; closing the
|
||||
team does not destroy the accounts it created, and each one would have to be
|
||||
deleted on its own request.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_password_reset: {
|
||||
subject: 'Your {{team_name}} account password was reset',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>An administrator of {{team_name}} reset the password on your Puter account
|
||||
<b>{{username}}</b>. They will send you a temporary password separately, and you
|
||||
will be asked to choose your own the next time you sign in.</p>
|
||||
<p>If you did not expect this, the record of who reset it and when is in your
|
||||
team activity, along with every sign-in to your account.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
@@ -428,88 +183,32 @@ immediately</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
/**
|
||||
* A digest: shares to one recipient are held briefly and merged, so
|
||||
* `shares` may carry several senders. The subject is composed by the
|
||||
* service (see `digestSubject`), which owns the grouped wording. `link`
|
||||
* opens Shared with every item highlighted; `origin` is the bare site, for
|
||||
* the unsubscribe link.
|
||||
*/
|
||||
file_shared_with_you: {
|
||||
subject: '{{subject_line}}',
|
||||
html: shareEmailLayout({
|
||||
preheader: 'Waiting for you under Shared with me.',
|
||||
content:
|
||||
greetingRow('Hi{{#if recipient}} {{recipient}}{{/if}},') +
|
||||
headingRow('Shared with you') +
|
||||
SHARE_LIST_ROW +
|
||||
buttonRow('Open Puter') +
|
||||
textRow(
|
||||
'Shared items live under <span style="font-weight: 600;">Shared</span> in your files.',
|
||||
'24px 0 0',
|
||||
),
|
||||
footer: `You're receiving this because someone shared with your Puter account.{{#if unsubscribe_uuid}}
|
||||
<br /><a href="{{origin}}/unsubscribe?user_uuid={{unsubscribe_uuid}}" style="color: inherit; text-decoration: underline;">Unsubscribe from notification emails</a>{{/if}}`,
|
||||
}),
|
||||
text: `
|
||||
Hi{{#if recipient}} {{recipient}}{{/if}},
|
||||
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
{{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}}
|
||||
{{/if}}{{/each}}{{/each}}
|
||||
|
||||
Open Puter: {{link}}
|
||||
|
||||
Shared items live under "Shared" in your files.
|
||||
|
||||
--
|
||||
You're receiving this because someone shared with your Puter account.
|
||||
{{#if unsubscribe_uuid}}Unsubscribe from notification emails:
|
||||
{{origin}}/unsubscribe?user_uuid={{unsubscribe_uuid}}{{/if}}
|
||||
share_by_username: {
|
||||
subject: 'Puter share from {{susername}}',
|
||||
html: `
|
||||
<p>Hi there {{rusername}},</p>
|
||||
<p>You've received a share from {{susername}} on Puter.</p>
|
||||
<p>Go to puter.com to check it out.</p>
|
||||
{{#if message}}
|
||||
<p>The following message was included:</p>
|
||||
<blockquote>{{message}}</blockquote>
|
||||
{{/if}}
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
// The only way to reach someone with no account. Same digest shape.
|
||||
file_shared_invite: {
|
||||
subject: '{{subject_line}}',
|
||||
html: shareEmailLayout({
|
||||
preheader: 'Claim it with a free Puter account.',
|
||||
content:
|
||||
greetingRow('Hi there,') +
|
||||
headingRow('Shared with you on Puter') +
|
||||
SHARE_LIST_ROW +
|
||||
textRow(
|
||||
'There\'s no Puter account for <span style="font-weight: 600;">{{email}}</span> yet. Create one with this address, confirm it, and everything above will be waiting for you. It\'s free and takes about a minute.',
|
||||
) +
|
||||
buttonRow('Create your free account') +
|
||||
textRow(
|
||||
'Already on Puter? Add {{email}} to your account and confirm it to get the same access.',
|
||||
'24px 0 0',
|
||||
),
|
||||
footer: `You're receiving this because someone shared with {{email}}. Nothing is shared until the address is confirmed, so you can ignore this email and nothing happens.`,
|
||||
}),
|
||||
text: `
|
||||
Hi there,
|
||||
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
{{/each}}
|
||||
|
||||
There's no Puter account for {{email}} yet. Create one with this
|
||||
address, confirm it, and everything above will be waiting for you.
|
||||
It's free and takes about a minute.
|
||||
|
||||
Create your free account: {{link}}
|
||||
|
||||
Already on Puter? Add {{email}} to your account and confirm it to get
|
||||
the same access.
|
||||
|
||||
--
|
||||
You're receiving this because someone shared with {{email}}. Nothing is
|
||||
shared until the address is confirmed, so you can ignore this email
|
||||
and nothing happens.
|
||||
share_by_email: {
|
||||
subject: 'share by email',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>You've received a share from {{sender_name}} on Puter:</p>
|
||||
<p><a href="{{link}}">{{link}}</a></p>
|
||||
{{#if message}}
|
||||
<p>The following message was included:</p>
|
||||
<blockquote>{{message}}</blockquote>
|
||||
{{/if}}
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
} satisfies Record<string, EmailTemplate>;
|
||||
|
||||
@@ -32,54 +32,6 @@ type GuiEvent<R = Record<string, unknown>> = {
|
||||
response: R;
|
||||
};
|
||||
|
||||
// The entry arrives under several aliases, for handlers of any vintage.
|
||||
type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string };
|
||||
|
||||
/**
|
||||
* Who pays, and for which team. Deliberately no payment identity: the payer's
|
||||
* row is alive at emit time, so a consumer resolves it when it acts -- a
|
||||
* snapshot taken here would be stale, and two events racing on it would each
|
||||
* create their own customer.
|
||||
*/
|
||||
export type TeamBillingContext = {
|
||||
team_uid: string;
|
||||
owner_user_id: number;
|
||||
};
|
||||
|
||||
/** A team event about one seat. */
|
||||
export type TeamBillingEvent = TeamBillingContext & {
|
||||
user_id: number;
|
||||
user_uuid: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
|
||||
* events declare the payload here by declaration merging, so both the emitter
|
||||
* and every listener are typed against the same shape:
|
||||
*
|
||||
* declare module '@heyputer/backend/clients/event/types' {
|
||||
* interface IExtensionEventMap {
|
||||
* 'my.thing.happened': { thingId: string };
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Deliberately member-less and index-signature-free: an index signature here
|
||||
* would widen `keyof EventMap` to `string` and silently disable key checking on
|
||||
* every `emit` in the tree.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface IExtensionEventMap {}
|
||||
|
||||
/**
|
||||
* What a KV mutation did to a key. `expire` is a lifetime change rather than a
|
||||
* value change, which is why it is not folded into `set`.
|
||||
*/
|
||||
export type KvOp = 'set' | 'del' | 'expire';
|
||||
|
||||
/** A flush empties a whole namespace, so it is never reported per key. */
|
||||
export type KvMutation = KvOp | 'flush';
|
||||
|
||||
export type EventMap = {
|
||||
// ---- Server lifecycle ----
|
||||
serverStart: Record<string, never>;
|
||||
@@ -137,8 +89,6 @@ export type EventMap = {
|
||||
};
|
||||
|
||||
// ---- Apps ----
|
||||
/** Awaited before lookup; listeners may mutate or replace appNames. */
|
||||
'app.recommended': { appNames: string[] };
|
||||
'app.changed': {
|
||||
app_uid: string;
|
||||
action: string;
|
||||
@@ -192,19 +142,6 @@ export type EventMap = {
|
||||
'puter.signup.validate': {
|
||||
allow: boolean;
|
||||
email?: string;
|
||||
/**
|
||||
* The address in the same canonical form the `email.validate` hook was
|
||||
* given (`cleanEmail`), so a handler can correlate the two hooks on one
|
||||
* key. Aliases (`[email protected]`, `[email protected]`) differ from
|
||||
* `email` here.
|
||||
*/
|
||||
clean_email?: string;
|
||||
/**
|
||||
* True for a temp (frictionless, no-password) signup. Those carry a
|
||||
* synthetic `<username>@gmail.com` and never reach email validation, so
|
||||
* a handler must not draw conclusions from `email`.
|
||||
*/
|
||||
is_temp?: boolean;
|
||||
ip?: string | null;
|
||||
source?: 'oidc';
|
||||
req?: unknown;
|
||||
@@ -309,14 +246,6 @@ export type EventMap = {
|
||||
publishable_key: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Side-effect-free "is the card gate usable?" probe. The extension stamps
|
||||
// `enabled` from its own config and does nothing else — no provider call,
|
||||
// no user state, nothing billed. It exists so the SMS-to-card fallback can
|
||||
// default on only where a card gate actually works, which means it gets
|
||||
// asked often and must stay cheap.
|
||||
'puter.card-verification.status': {
|
||||
enabled: boolean | null;
|
||||
};
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
@@ -354,23 +283,6 @@ export type EventMap = {
|
||||
stripe_customer_id?: string | null;
|
||||
};
|
||||
|
||||
// ---- Team billing ---- the trigger, never the charge ----
|
||||
'team.account.created': TeamBillingEvent;
|
||||
/** `held_bytes` is what to bill storage on while the seat is off. */
|
||||
'team.account.disabled': TeamBillingEvent & { held_bytes: number };
|
||||
'team.account.enabled': TeamBillingEvent & { held_bytes: number };
|
||||
/** Emitted pre-delete: the membership row cascades away with the user. */
|
||||
'team.account.deleted': TeamBillingEvent;
|
||||
/** Per-seat charges stop; byte charges do not, the accounts remain. */
|
||||
'team.deleted': TeamBillingContext & { account_count: number };
|
||||
/** A budget line was crossed. Emitted on transition only, never per request. */
|
||||
'metering.credit-state': {
|
||||
user_uuid: string;
|
||||
state: 'near-limit' | 'exhausted';
|
||||
allowance_used: number;
|
||||
month_usage_allowance: number;
|
||||
};
|
||||
|
||||
// ---- Filesystem ----
|
||||
'fs.copy.node': {
|
||||
source: unknown;
|
||||
@@ -378,31 +290,9 @@ export type EventMap = {
|
||||
sourceObjectKey: string;
|
||||
copyObjectKey: string;
|
||||
};
|
||||
/**
|
||||
* `fromUserId` is the owner the node had before the move. A move into
|
||||
* someone else's tree re-owns the row, and `node` already carries the new
|
||||
* owner — listeners that care about the hand-over need the old one.
|
||||
*/
|
||||
'fs.move.node': {
|
||||
node: FSEntry;
|
||||
fromPath: string;
|
||||
toPath: string;
|
||||
fromUserId?: number;
|
||||
};
|
||||
'fs.move.node': { node: FSEntry; fromPath: string; toPath: string };
|
||||
'fs.remove.node': { node: FSEntry; entry: FSEntry; target: FSEntry };
|
||||
'fs.write.file': { node: FSEntry; entry: FSEntry; target: FSEntry };
|
||||
/** A new entry, one key per flavor; `fs.write.file` is the overwrite. */
|
||||
'fs.create.file': FsCreateEvent;
|
||||
'fs.create.directory': FsCreateEvent;
|
||||
'fs.create.shortcut': FsCreateEvent;
|
||||
'fs.create.symlink': FsCreateEvent;
|
||||
/** In-place name change. A move emits `fs.move.node` instead. */
|
||||
'fs.rename': FsCreateEvent & {
|
||||
old_name: string;
|
||||
new_name: string;
|
||||
old_path: string;
|
||||
new_path: string;
|
||||
};
|
||||
'fs.storage.upload-progress': {
|
||||
upload_tracker: unknown;
|
||||
context: unknown;
|
||||
@@ -416,57 +306,6 @@ export type EventMap = {
|
||||
};
|
||||
'storage.quota.bonus': { userId: number; extra: number };
|
||||
|
||||
// ---- Key-value ----
|
||||
/**
|
||||
* Keys of one namespace that changed together, announced after the write
|
||||
* commits. `namespace` is `v1:<userUuid>:<appUid>` and `userId` is the same
|
||||
* user by its row id — the only part a listener cannot derive from the
|
||||
* namespace, and the one dispatch keys on.
|
||||
*
|
||||
* Distinct from `outer.kv.cacheInvalidated`, which carries encoded cache
|
||||
* keys and is suppressed entirely when the read cache is off.
|
||||
*/
|
||||
'kv.mutated': {
|
||||
namespace: string;
|
||||
userId: number;
|
||||
keys: string[];
|
||||
op: KvOp;
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
* key enumeration is truncated for a large namespace, so the keys it saw
|
||||
* are not the keys it removed.
|
||||
*/
|
||||
'kv.flushed': { namespace: string; userId: number };
|
||||
|
||||
// ---- Notifications ----
|
||||
/**
|
||||
* A notification row exists. Emitted after the insert commits — nothing
|
||||
* pushes a uid before there is a row to name — and carries the scope tuple
|
||||
* so a listener never has to read the row back.
|
||||
*/
|
||||
'notif.created': {
|
||||
userId: number;
|
||||
userUuid: string;
|
||||
uid: string;
|
||||
type: string;
|
||||
audience: string;
|
||||
appUid: string | null;
|
||||
value: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
};
|
||||
/**
|
||||
* One notification, addressed at whatever sockets each region holds for the
|
||||
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
|
||||
* every region applies it exactly once — which is what keeps a socket from
|
||||
* receiving the same notification twice.
|
||||
*/
|
||||
'outer.notif.delivery': {
|
||||
userId: number;
|
||||
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
|
||||
response: unknown;
|
||||
};
|
||||
|
||||
// ---- Metering ----
|
||||
// Recurring charges are pure mechanism here: the metering service knows
|
||||
// when to ask (once per user per month, the first time that month's usage
|
||||
@@ -499,78 +338,12 @@ export type EventMap = {
|
||||
// is pricing workers that came into existence, not deploys.
|
||||
'worker.create': { actor: Actor; workerName: string };
|
||||
|
||||
// The same announcement for worker code that lives inside a hosted site
|
||||
// and is deployed on demand rather than by an explicit call. Such a worker
|
||||
// has no row of ours to key on, so what stands in for "genuinely new" is
|
||||
// the absence of any prior record that it ran — which is why it is
|
||||
// announced separately, and identified by where its source lives rather
|
||||
// than by a worker name.
|
||||
'worker.dynamic.create': {
|
||||
actor: Actor;
|
||||
subdomain: string;
|
||||
worker: string;
|
||||
};
|
||||
|
||||
// An app's events worker coming into being (its handler count going 0→1)
|
||||
// and going away (1→0, whether by removing the last handler or by the
|
||||
// destroy route). `actor.user` is the app's owner, not the caller — a
|
||||
// developer session publishing for an app it owns is the common case, but
|
||||
// billing follows ownership.
|
||||
'events.worker.create': { actor: Actor; appUid: string };
|
||||
'events.worker.destroy': { actor: Actor; appUid: string };
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
cacheKey: string[];
|
||||
data?: unknown;
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
/**
|
||||
* Permission cache generations were bumped, so peer regions must bump their
|
||||
* own — the counter is per-cluster, so a local bump says nothing to them.
|
||||
* Carries the actors, not the values: the numbers only have to change.
|
||||
*/
|
||||
'outer.permission.generationBumped': { actorUids: string[] };
|
||||
/**
|
||||
* Flat permission entries were deleted. Grant-path flat entries carry no
|
||||
* expiry, so without this a revoke never lands in a peer region whose KV
|
||||
* table isn't replicated. Revoke-only: a grant that fails to replicate just
|
||||
* denies there, which is the safe direction.
|
||||
*/
|
||||
'outer.permission.flatInvalidated': {
|
||||
entries: Array<{ holderUserId: number; permission: string }>;
|
||||
};
|
||||
/**
|
||||
* A user's subscription set changed, so every process must drop its cached
|
||||
* "does this user have any subscriptions" answer. Carries the counter the
|
||||
* bump produced so a listener can order two bumps it sees out of order.
|
||||
*
|
||||
* The dispatch hot path never reads the counter — it is the broadcast that
|
||||
* invalidates, which is what keeps an unsubscribed write at zero Redis
|
||||
* commands. Hence `outer.pubsub.*`: the cache is a per-process map, the
|
||||
* webhook reaches one node per peer region, and only the Redis fan reaches
|
||||
* that node's siblings — a node the bump never reached has nothing that
|
||||
* expires.
|
||||
*/
|
||||
'outer.pubsub.events.generationBumped': {
|
||||
userId: number;
|
||||
generation: number;
|
||||
/** Whether the table changed; only then does a peer need to re-read it. */
|
||||
durable: boolean;
|
||||
};
|
||||
/**
|
||||
* A user's sockets moved between regions, so every node must drop what it
|
||||
* cached about where they are. Bumped on a first connect, a last
|
||||
* disconnect, and every repair — never on a timer, which is what keeps
|
||||
* presence reads proportional to session churn rather than to event volume.
|
||||
* `outer.pubsub.*` for the same reason as the generation bump: a
|
||||
* per-process cache that only the Redis fan reaches on a region's other
|
||||
* nodes.
|
||||
*/
|
||||
'outer.pubsub.events.presenceBumped': {
|
||||
userId: number;
|
||||
generation: number;
|
||||
};
|
||||
'outer.fs.write-hash': { hash: string; uuid: string };
|
||||
/**
|
||||
* Cache keys the KV read cache must stop serving, because the entries
|
||||
@@ -586,18 +359,16 @@ export type EventMap = {
|
||||
'outer.gui.item.moved': GuiEvent;
|
||||
'outer.gui.item.pending': GuiEvent;
|
||||
'outer.gui.item.removed': GuiEvent;
|
||||
'outer.gui.item.renamed': GuiEvent;
|
||||
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
|
||||
'outer.gui.notif.persisted': GuiEvent<{ uid: string }>;
|
||||
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
|
||||
'outer.gui.notif.unreads': GuiEvent<{
|
||||
unreads: { uid: string; notification: unknown }[];
|
||||
}>;
|
||||
|
||||
// ---- Subdomains ----
|
||||
// `uid` is the row uuid — for `delete` it is the only surviving handle a
|
||||
// listener can use to find state keyed to the row after it is gone.
|
||||
'subdomain.delete': { subdomain: string; uid?: string };
|
||||
'subdomain.update': { subdomain: string; uid?: string };
|
||||
'subdomain.delete': { subdomain: string };
|
||||
'subdomain.update': { subdomain: string };
|
||||
'site.htmlServed': {
|
||||
subdomain: string;
|
||||
entry: unknown;
|
||||
@@ -629,30 +400,6 @@ export type EventMap = {
|
||||
'web.socket.connected': { socket: unknown; user: unknown };
|
||||
'web.socket.user-connected': { socket: unknown; user: unknown };
|
||||
|
||||
/**
|
||||
* Sessions were revoked for a user. Anything holding one of them open — a
|
||||
* live socket, say — should drop it: `revoked_at` is otherwise only read on
|
||||
* the next handshake.
|
||||
*/
|
||||
'auth.sessions.revoked': { user_id: number; session_uids: string[] };
|
||||
|
||||
/**
|
||||
* A grant was withdrawn, so whatever was standing on it has to be settled
|
||||
* rather than left to fail its next check.
|
||||
*
|
||||
* Local rather than `outer.*` on purpose: a listener settles shared state,
|
||||
* and one region doing it covers every other. Emitted once per revoke call,
|
||||
* not once per row it affects.
|
||||
*/
|
||||
'permission.revoked': {
|
||||
/** Whose access went. */
|
||||
holderUserId: number;
|
||||
/** The app the grant was to, or `null` for a user-to-user grant. */
|
||||
appUid: string | null;
|
||||
/** The grant string, or `null` when every grant to the app went. */
|
||||
permission: string | null;
|
||||
};
|
||||
|
||||
// ---- Extension hooks / misc ----
|
||||
'puter.gui.addons': {
|
||||
prependHeadContent?: string[];
|
||||
@@ -713,7 +460,7 @@ export type EventMap = {
|
||||
* have all dropped that answer.
|
||||
*/
|
||||
'outer.pubsub.metering.credits-changed': { userUuid: string };
|
||||
} & IExtensionEventMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `DispatcherInvokeTransport` against an injected fetch, so the handled/error
|
||||
* header contract is pinned without a real dispatcher on the other end.
|
||||
*/
|
||||
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
EVENTS_DEPLOYED_HEADER,
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
EVENTS_DISPATCH_PATH,
|
||||
EVENTS_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EventsWorkerInvokerClient,
|
||||
type WorkerInvokeRequest,
|
||||
} from './EventsWorkerInvokerClient.js';
|
||||
|
||||
type FetchImpl = typeof undiciFetch;
|
||||
|
||||
const CALL = {
|
||||
script: 'evw-test',
|
||||
appUid: 'app-test',
|
||||
key: 'k1:test',
|
||||
body: '{}',
|
||||
timeoutMs: 5_000,
|
||||
};
|
||||
|
||||
/** A stub fetch that answers the same way on every call. */
|
||||
const stubFetch = (
|
||||
status: number,
|
||||
headers: Record<string, string> = {},
|
||||
): FetchImpl =>
|
||||
(async () =>
|
||||
new Response(null, { status, headers })) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that never answers until the abort signal fires. */
|
||||
const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('This operation was aborted')),
|
||||
);
|
||||
})) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that records the URL it was called with. */
|
||||
const capturingFetch = (
|
||||
calls: string[],
|
||||
status = 200,
|
||||
): FetchImpl =>
|
||||
(async (url: string) => {
|
||||
calls.push(url);
|
||||
return new Response(null, { status });
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
describe('DispatcherInvokeTransport', () => {
|
||||
it('reports a handled 400 with no error — the handler`s own refusal', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(400, { [EVENTS_HANDLED_HEADER]: '1' }),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 400,
|
||||
handled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an unmarked 404 with no dispatch-error header as unhandled', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(404),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 404,
|
||||
handled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('turns a dispatch-error header into a null status with the reason', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(502, { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-failed' }),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: null,
|
||||
error: 'dispatcher: deploy-failed (502)',
|
||||
dispatchReason: 'deploy-failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the deployed header when the call says so', async () => {
|
||||
const headers: Array<Record<string, string>> = [];
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
headers.push(init?.headers as Record<string, string>);
|
||||
return new Response(null, { status: 200 });
|
||||
}) as unknown as FetchImpl;
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await transport.send(CALL);
|
||||
await transport.send({ ...CALL, deployed: true });
|
||||
|
||||
expect(headers[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
||||
expect(headers[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
||||
});
|
||||
|
||||
it('reports a handled 200 as settled with no error', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(200, { [EVENTS_HANDLED_HEADER]: '1' }),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 200,
|
||||
handled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a 429 through as-is', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(429),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 429,
|
||||
handled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers null with an error when the request times out', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: hangingFetch,
|
||||
});
|
||||
const result = await transport.send({ ...CALL, timeoutMs: 10 });
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.error).toMatch(/abort/i);
|
||||
});
|
||||
|
||||
it('surfaces the runtime`s own error header alongside its status', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(500, {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'handler-threw',
|
||||
}),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 500,
|
||||
handled: true,
|
||||
error: 'handler-threw',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a path prefix on the dispatcher URL', async () => {
|
||||
const calls: string[] = [];
|
||||
const transport = new DispatcherInvokeTransport(
|
||||
'http://dispatcher/prefix/',
|
||||
's',
|
||||
{ fetchImpl: capturingFetch(calls) },
|
||||
);
|
||||
await transport.send(CALL);
|
||||
expect(calls).toEqual([`http://dispatcher/prefix${EVENTS_DISPATCH_PATH}`]);
|
||||
});
|
||||
});
|
||||
|
||||
const REQUEST: WorkerInvokeRequest = {
|
||||
script: 'evw-test',
|
||||
appUid: 'app-test',
|
||||
handler: 'h',
|
||||
token: 't',
|
||||
key: 'k1:test',
|
||||
event: {},
|
||||
ctx: {},
|
||||
};
|
||||
|
||||
const makeClient = () => new EventsWorkerInvokerClient({} as unknown as IConfig);
|
||||
|
||||
/** Answers `missing` until it sees the deployed header, then settles. */
|
||||
const missUntilDeployedFetch = (
|
||||
headersSeen: Array<Record<string, string>>,
|
||||
): FetchImpl =>
|
||||
(async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
headersSeen.push(headers);
|
||||
if (headers[EVENTS_DEPLOYED_HEADER] === '1')
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
});
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
||||
});
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
/** Answers `missing` no matter what, deployed header included. */
|
||||
const alwaysMissingFetch = (calls: unknown[]): FetchImpl =>
|
||||
(async () => {
|
||||
calls.push(null);
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
||||
});
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
/**
|
||||
* Answers `deploy-timeout` once, then 200 once it sees the deployed header —
|
||||
* the shape of a script that finished propagating between the two calls.
|
||||
*/
|
||||
const deployTimeoutThenOkFetch = (
|
||||
headersSeen: Array<Record<string, string>>,
|
||||
): FetchImpl =>
|
||||
(async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
headersSeen.push(headers);
|
||||
if (headers[EVENTS_DEPLOYED_HEADER] === '1')
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
});
|
||||
return new Response(null, {
|
||||
status: 503,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-timeout' },
|
||||
});
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
it('deploys the script and retries once with the deployed header', async () => {
|
||||
const headersSeen: Array<Record<string, string>> = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: missUntilDeployedFetch(headersSeen),
|
||||
}),
|
||||
);
|
||||
const missCalls: Array<[string, string]> = [];
|
||||
client.setMissHandler(async (appUid, script) => {
|
||||
missCalls.push([appUid, script]);
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(missCalls).toEqual([[REQUEST.appUid, REQUEST.script]]);
|
||||
expect(headersSeen).toHaveLength(2);
|
||||
expect(headersSeen[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
||||
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
});
|
||||
|
||||
it('does not retry when the miss handler reports the script is stale', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: alwaysMissingFetch(calls),
|
||||
}),
|
||||
);
|
||||
client.setMissHandler(async () => 'stale');
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: 'deploy: stale',
|
||||
});
|
||||
});
|
||||
|
||||
it('never asks the miss handler twice for one invocation', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: alwaysMissingFetch(calls),
|
||||
}),
|
||||
);
|
||||
let handlerCalls = 0;
|
||||
client.setMissHandler(async () => {
|
||||
handlerCalls++;
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(handlerCalls).toBe(1);
|
||||
// The first send plus the one retry — never a third.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(result.outcome).toBe('retriable');
|
||||
expect(result.status).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves behavior unchanged with no miss handler set', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: alwaysMissingFetch(calls),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: 'dispatcher: missing (404)',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trigger the miss handler for a forbidden dispatch reason', async () => {
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(403, {
|
||||
[EVENTS_DISPATCH_ERROR_HEADER]: 'forbidden',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
let handlerCalled = false;
|
||||
client.setMissHandler(async () => {
|
||||
handlerCalled = true;
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(handlerCalled).toBe(false);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: 'dispatcher: forbidden (403)',
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a deploy-timeout with the deployed header instead of redeploying', async () => {
|
||||
const headersSeen: Array<Record<string, string>> = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: deployTimeoutThenOkFetch(headersSeen),
|
||||
}),
|
||||
);
|
||||
const deployCalls: unknown[] = [];
|
||||
client.setMissHandler(async () => {
|
||||
deployCalls.push(null);
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
// The script was already deployed once — the one that led to this
|
||||
// deploy-timeout answer — so this client must not add a second.
|
||||
expect(deployCalls).toHaveLength(0);
|
||||
expect(headersSeen).toHaveLength(2);
|
||||
expect(headersSeen[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
||||
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
});
|
||||
|
||||
it('gives up after one retry when propagation is still not done', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: (async () => {
|
||||
calls.push(null);
|
||||
return new Response(null, {
|
||||
status: 503,
|
||||
headers: {
|
||||
[EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-timeout',
|
||||
},
|
||||
});
|
||||
}) as unknown as FetchImpl,
|
||||
}),
|
||||
);
|
||||
const deployCalls: unknown[] = [];
|
||||
client.setMissHandler(async () => {
|
||||
deployCalls.push(null);
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
// The first send plus the one retry — never a third, and never a
|
||||
// deploy. The delivery comes back on its own backoff instead.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(deployCalls).toHaveLength(0);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: 'dispatcher: deploy-timeout (503)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,399 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Claims the global dispatcher slot for Node's own fetch before npm `undici`
|
||||
// can — see the guard's own comment.
|
||||
import '../../util/nodeFetchDispatcherGuard.js';
|
||||
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { PuterClient } from '../types.js';
|
||||
|
||||
/**
|
||||
* The call that leaves the platform and runs an app's own code.
|
||||
*
|
||||
* Events workers are unreachable from the internet: deployed into their own
|
||||
* dispatch namespace and reached only through the events dispatcher, which
|
||||
* answers on its own hostname behind the internal secret.
|
||||
*
|
||||
* POST <events dispatcher>/invoke
|
||||
* x-puter-internal-auth: <internal secret>
|
||||
* x-puter-events-script: <script the handler set deploys as>
|
||||
* x-puter-events-app: <app uid, for the deploy-on-miss callback>
|
||||
* x-puter-events-key: <key derived for that script>
|
||||
* { handler, event, ctx, token }
|
||||
*
|
||||
* The delivery token rides in the body, not a header, so nothing between here
|
||||
* and the isolate can treat it as its own authorization.
|
||||
*
|
||||
* On a miss, a handler set here may deploy the script itself and retry once
|
||||
* with `x-puter-events-deployed: 1`, skipping the dispatcher's own callback.
|
||||
*/
|
||||
|
||||
/** The one route an events worker answers. */
|
||||
export const EVENTS_INVOKE_PATH = '/__events/invoke';
|
||||
|
||||
/** The route the events dispatcher takes an invocation on. */
|
||||
export const EVENTS_DISPATCH_PATH = '/invoke';
|
||||
|
||||
/**
|
||||
* Header the dispatcher marks its own failures with. Anything carrying it never
|
||||
* reached a handler, so its status says nothing about the delivery.
|
||||
*/
|
||||
export const EVENTS_DISPATCH_ERROR_HEADER = 'x-puter-events-dispatch';
|
||||
|
||||
/**
|
||||
* Header a script's own answer carries, whatever its status — set by the
|
||||
* runtime itself and re-stamped by the dispatcher, so a 4xx with neither this
|
||||
* nor the dispatch-error header came from neither (an edge 404, a WAF) and is
|
||||
* retriable rather than a handler's refusal.
|
||||
*/
|
||||
export const EVENTS_HANDLED_HEADER = 'x-puter-events-handled';
|
||||
|
||||
/** Machine-readable reason on the runtime's own failure answers. */
|
||||
export const EVENTS_ERROR_HEADER = 'x-puter-events-error';
|
||||
|
||||
/**
|
||||
* Set on the retry after this backend deploys a missing script itself, so the
|
||||
* dispatcher skips its own deploy-on-miss callback and negative cache and goes
|
||||
* straight to retrying the namespace.
|
||||
*/
|
||||
export const EVENTS_DEPLOYED_HEADER = 'x-puter-events-deployed';
|
||||
|
||||
/**
|
||||
* Dispatch-error reasons this backend can resolve itself by deploying, rather
|
||||
* than wait on the rehydrate callback finding a backend behind the dispatcher's
|
||||
* own hostname. `deploy-timeout` is not one of them: it is only ever answered
|
||||
* after a deploy that succeeded, so the script exists and another upload of it
|
||||
* cannot make the namespace see it any sooner.
|
||||
*/
|
||||
const SELF_DEPLOYABLE_MISS_REASONS = new Set([
|
||||
'missing',
|
||||
'deploy-failed',
|
||||
'deploy-error',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dispatch-error reasons where the deploy already happened and only propagation
|
||||
* is still catching up — one prompt retry with the deployed header is worth it,
|
||||
* without spending another upload on it.
|
||||
*/
|
||||
const ALREADY_DEPLOYED_MISS_REASONS = new Set(['deploy-timeout']);
|
||||
|
||||
/** What a self-deploy attempt did, and to which app/script it applies. */
|
||||
export type WorkerMissHandler = (
|
||||
appUid: string,
|
||||
script: string,
|
||||
) => Promise<'deployed' | string>;
|
||||
|
||||
/** How long a handler has to answer before the attempt is abandoned. */
|
||||
export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Connections held open to the dispatcher, so a busy fleet is not
|
||||
* re-handshaking. The 30s invoke abort starts when a request is queued, not
|
||||
* when it gets a connection, so a saturated pool shows up as timeouts.
|
||||
*/
|
||||
const INVOKE_CONNECTIONS = 256;
|
||||
const INVOKE_KEEP_ALIVE_MS = 30_000;
|
||||
|
||||
/** One invocation, as the events service hands it over. */
|
||||
export interface WorkerInvokeRequest {
|
||||
/** Script the app's current handler set deploys as. */
|
||||
script: string;
|
||||
appUid: string;
|
||||
handler: string;
|
||||
/** The subscriber's app session token. */
|
||||
token: string;
|
||||
/** Value the script compares against its own invoke-key binding. */
|
||||
key: string;
|
||||
event: unknown;
|
||||
ctx: unknown;
|
||||
}
|
||||
|
||||
/** One invocation, as a transport delivers it. */
|
||||
export interface EventsInvokeCall {
|
||||
script: string;
|
||||
appUid: string;
|
||||
/** Value the script compares against its own invoke-key binding. */
|
||||
key: string;
|
||||
/** Serialized `{ handler, event, ctx, token }`. */
|
||||
body: string;
|
||||
timeoutMs: number;
|
||||
/** Set on the retry after this backend deployed the script itself. */
|
||||
deployed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an invocation is delivered.
|
||||
*
|
||||
* `status` is what the handler answered with. A `null` status means nothing
|
||||
* ran: no transport, no script, or a dispatcher that refused us — all of which
|
||||
* are the platform's fault and so retriable. `handled` is set only when the
|
||||
* answer is provably the script's own (the handled header); a 4xx without it
|
||||
* came from something between here and the script, not a handler's refusal.
|
||||
*/
|
||||
export interface EventsInvokeTransport {
|
||||
send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
/** Set only by the dispatcher transport, naming its own failure. */
|
||||
dispatchReason?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the call did.
|
||||
*
|
||||
* - `settled` — the handler took the delivery (2xx).
|
||||
* - `terminal` — it refused it (a marked 4xx). Retrying sends the same body to
|
||||
* the same code, so it is not retried.
|
||||
* - `retriable` — it could not answer: 5xx, 429, a timeout, a transport failure,
|
||||
* an unmarked 4xx (nothing said it was a handler's answer), or no way to
|
||||
* reach the worker at all.
|
||||
*/
|
||||
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
|
||||
|
||||
export interface WorkerInvokeResult {
|
||||
outcome: WorkerInvokeOutcome;
|
||||
/** The status the handler answered with, or `null` when it never did. */
|
||||
status: number | null;
|
||||
/** Why it could not answer, when nothing did. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const NO_TRANSPORT =
|
||||
'no events dispatcher configured (url and internal secret)';
|
||||
|
||||
export class EventsWorkerInvokerClient extends PuterClient {
|
||||
/** Set only where invocations do not leave the process — local development. */
|
||||
#transport: EventsInvokeTransport | null = null;
|
||||
#dispatcher: DispatcherInvokeTransport | null = null;
|
||||
/** Lets this backend resolve a dispatcher miss instead of waiting on it. */
|
||||
#missHandler: WorkerMissHandler | null = null;
|
||||
|
||||
/** Replaces the dispatcher with an in-process runtime. */
|
||||
setTransport(transport: EventsInvokeTransport): void {
|
||||
this.#transport = transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploys a script the dispatcher reports missing, in place of the
|
||||
* rehydrate callback — for the case where that callback lands on a backend
|
||||
* other than this one. Set once; `invoke()` calls it at most once per
|
||||
* delivery and never loops.
|
||||
*/
|
||||
setMissHandler(handler: WorkerMissHandler): void {
|
||||
this.#missHandler = handler;
|
||||
}
|
||||
|
||||
override onServerShutdown(): void {
|
||||
this.#dispatcher?.close();
|
||||
this.#dispatcher = null;
|
||||
}
|
||||
|
||||
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
|
||||
const transport = this.#pickTransport();
|
||||
if (!transport)
|
||||
return { outcome: 'retriable', status: null, error: NO_TRANSPORT };
|
||||
|
||||
const call: EventsInvokeCall = {
|
||||
script: request.script,
|
||||
appUid: request.appUid,
|
||||
key: request.key,
|
||||
body: JSON.stringify({
|
||||
handler: request.handler,
|
||||
event: request.event,
|
||||
ctx: request.ctx,
|
||||
token: request.token,
|
||||
}),
|
||||
timeoutMs: this.#timeoutMs(),
|
||||
};
|
||||
|
||||
let result = await transport.send(call);
|
||||
if (result.status === null && result.dispatchReason) {
|
||||
if (
|
||||
this.#missHandler &&
|
||||
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
|
||||
) {
|
||||
const outcome = await this.#missHandler(
|
||||
request.appUid,
|
||||
request.script,
|
||||
);
|
||||
if (outcome !== 'deployed')
|
||||
return {
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
};
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
} else if (
|
||||
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
|
||||
) {
|
||||
// No deploy to trigger — just give propagation the one retry
|
||||
// it usually needs, without spending an upload on it.
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
}
|
||||
}
|
||||
|
||||
const { status, handled, error } = result;
|
||||
if (status === null)
|
||||
return { outcome: 'retriable', status: null, error };
|
||||
|
||||
const outcome = outcomeForStatus(status, handled === true);
|
||||
// An unmarked 4xx (429 aside — that one is always retriable) is not
|
||||
// the handler's own answer; say so even when the transport did not,
|
||||
// so the reason a delivery kept retrying shows up in the log rather
|
||||
// than reading as an unexplained retry.
|
||||
const unmarkedFourxx =
|
||||
status !== 429 && status >= 400 && status < 500 && !handled;
|
||||
const reportedError =
|
||||
error ?? (unmarkedFourxx ? 'unmarked 4xx' : undefined);
|
||||
return {
|
||||
outcome,
|
||||
status,
|
||||
...(reportedError ? { error: reportedError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
#pickTransport(): EventsInvokeTransport | null {
|
||||
if (this.#transport) return this.#transport;
|
||||
const { dispatcherUrl, internalSecret } = this.config.events ?? {};
|
||||
// Without the secret the dispatcher would refuse every call, which is
|
||||
// a misconfiguration worth reporting once per attempt rather than a
|
||||
// round trip.
|
||||
if (!dispatcherUrl || !internalSecret) return null;
|
||||
this.#dispatcher ??= new DispatcherInvokeTransport(
|
||||
dispatcherUrl,
|
||||
internalSecret,
|
||||
);
|
||||
return this.#dispatcher;
|
||||
}
|
||||
|
||||
#timeoutMs(): number {
|
||||
const configured = this.config.events?.invokeTimeoutMs;
|
||||
return typeof configured === 'number' && configured > 0
|
||||
? configured
|
||||
: EVENTS_INVOKE_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reaches an events worker the only way production can: through the dispatcher. */
|
||||
export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
readonly #url: string;
|
||||
readonly #secret: string;
|
||||
readonly #fetchImpl: typeof undiciFetch;
|
||||
#agent: Agent | null = null;
|
||||
|
||||
constructor(
|
||||
dispatcherUrl: string,
|
||||
secret: string,
|
||||
options: { fetchImpl?: typeof undiciFetch } = {},
|
||||
) {
|
||||
this.#url = dispatcherUrl;
|
||||
this.#secret = secret;
|
||||
this.#fetchImpl = options.fetchImpl ?? undiciFetch;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
void this.#agent?.close().catch(() => {});
|
||||
this.#agent = null;
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
dispatchReason?: string;
|
||||
}> {
|
||||
try {
|
||||
// `new URL(path, base)` resolves an absolute path against the
|
||||
// base's origin, dropping any path prefix `base` already carries.
|
||||
const url = `${this.#url.replace(/\/$/, '')}${EVENTS_DISPATCH_PATH}`;
|
||||
const response = await this.#fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-internal-auth': this.#secret,
|
||||
'x-puter-events-script': call.script,
|
||||
'x-puter-events-app': call.appUid,
|
||||
'x-puter-events-key': call.key,
|
||||
...(call.deployed ? { [EVENTS_DEPLOYED_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: call.body,
|
||||
signal: AbortSignal.timeout(call.timeoutMs),
|
||||
dispatcher: this.#dispatcherFor(),
|
||||
});
|
||||
// The body is not read: a handler answers with a status, and
|
||||
// whatever else it writes is its own business.
|
||||
void response.body?.cancel().catch(() => {});
|
||||
|
||||
// The dispatcher never reached a handler, so its status is not the
|
||||
// delivery's answer.
|
||||
const dispatchError = response.headers.get(
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
);
|
||||
if (dispatchError !== null)
|
||||
return {
|
||||
status: null,
|
||||
error: `dispatcher: ${dispatchError} (${response.status})`,
|
||||
dispatchReason: dispatchError,
|
||||
};
|
||||
|
||||
const handled = response.headers.get(EVENTS_HANDLED_HEADER) === '1';
|
||||
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
|
||||
return {
|
||||
status: response.status,
|
||||
handled,
|
||||
...(errorHeader ? { error: errorHeader } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** One pool per process, built on first use and closed with the server. */
|
||||
#dispatcherFor(): Agent {
|
||||
this.#agent ??= new Agent({
|
||||
connections: INVOKE_CONNECTIONS,
|
||||
keepAliveTimeout: INVOKE_KEEP_ALIVE_MS,
|
||||
keepAliveMaxTimeout: INVOKE_KEEP_ALIVE_MS,
|
||||
});
|
||||
return this.#agent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2xx took it regardless. 429 is always "not now". Any other 4xx is a refusal
|
||||
* only when `handled` says the answer is provably the script's own — an
|
||||
* unmarked 4xx came from something between here and the handler.
|
||||
*/
|
||||
export const outcomeForStatus = (
|
||||
status: number,
|
||||
handled: boolean,
|
||||
): WorkerInvokeOutcome => {
|
||||
if (status >= 200 && status < 300) return 'settled';
|
||||
if (status === 429) return 'retriable';
|
||||
if (status >= 400 && status < 500)
|
||||
return handled ? 'terminal' : 'retriable';
|
||||
return 'retriable';
|
||||
};
|
||||
@@ -21,7 +21,6 @@ import { AlarmClient } from './alarm/AlarmClient';
|
||||
import { DatabaseClientFactory } from './database';
|
||||
import { EmailClient } from './email/EmailClient';
|
||||
import { EventClient } from './event/EventClient';
|
||||
import { EventsWorkerInvokerClient } from './events/EventsWorkerInvokerClient';
|
||||
import { DDBClient } from './dynamodb/DDBClient';
|
||||
import { RedisClient } from './redis/RedisClient';
|
||||
import { S3Client } from './s3/S3Client';
|
||||
@@ -33,7 +32,6 @@ export const puterClients = {
|
||||
db: DatabaseClientFactory,
|
||||
email: EmailClient,
|
||||
event: EventClient,
|
||||
eventsWorkerInvoker: EventsWorkerInvokerClient,
|
||||
dynamo: DDBClient,
|
||||
redis: RedisClient,
|
||||
s3: S3Client,
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('PreludeClient', () => {
|
||||
expect(makeClient().isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
describe('isCountrySupported (€0.20 cap)', () => {
|
||||
describe('isCountrySupported (€0.07 cap)', () => {
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
it('allows revenue markets up to the cap (incl. the priciest)', () => {
|
||||
@@ -40,17 +40,6 @@ describe('PreludeClient', () => {
|
||||
expect(client.isCountrySupported('us')).toBe(true); // case-insensitive
|
||||
});
|
||||
|
||||
it('allows the markets the old €0.07 cap excluded', () => {
|
||||
// Raising the cap to €0.20 brought ~89 countries into the phone
|
||||
// gate. These pin the new range so a future change to the default
|
||||
// has to say so out loud.
|
||||
expect(client.isCountrySupported('UA')).toBe(true); // €0.0940
|
||||
expect(client.isCountrySupported('PH')).toBe(true); // €0.1237
|
||||
expect(client.isCountrySupported('EG')).toBe(true); // €0.1561
|
||||
expect(client.isCountrySupported('NG')).toBe(true); // €0.1980
|
||||
expect(client.isCountrySupported('WS')).toBe(true); // €0.2000, at the cap
|
||||
});
|
||||
|
||||
it('rejects countries above the cap, with no SMS, or unknown', () => {
|
||||
expect(client.isCountrySupported('PK')).toBe(false); // €0.3548
|
||||
expect(client.isCountrySupported('ID')).toBe(false); // €0.2430
|
||||
@@ -74,7 +63,9 @@ describe('PreludeClient', () => {
|
||||
});
|
||||
|
||||
it('createVerification POSTs the phone target + ip signal with bearer auth', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'vrf_1', status: 'success' }));
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({ id: 'vrf_1', status: 'success' }),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123', {
|
||||
@@ -90,11 +81,7 @@ describe('PreludeClient', () => {
|
||||
target: { type: 'phone_number', value: '+14155550123' },
|
||||
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
|
||||
// hardcoded to en-US so the message text is always English.
|
||||
options: {
|
||||
code_size: 6,
|
||||
preferred_channel: 'rcs',
|
||||
locale: 'en-US',
|
||||
},
|
||||
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
|
||||
signals: { ip: '203.0.113.7' },
|
||||
});
|
||||
});
|
||||
@@ -209,9 +196,9 @@ describe('PreludeClient', () => {
|
||||
|
||||
it('throws (does not call fetch) when not configured', async () => {
|
||||
const client = makeClient();
|
||||
await expect(client.createVerification('+14155550123')).rejects.toThrow(
|
||||
/not configured/i,
|
||||
);
|
||||
await expect(
|
||||
client.createVerification('+14155550123'),
|
||||
).rejects.toThrow(/not configured/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 1020 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user