mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
364
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15134c99bf | ||
|
|
8262e3193e | ||
|
|
98a21c4ac0 | ||
|
|
c52eb1b903 | ||
|
|
d6a71e50bb | ||
|
|
a4b6ee0dd2 | ||
|
|
bb61103080 | ||
|
|
9bbc76b2dd | ||
|
|
918aba9c80 | ||
|
|
3ceba9e3b3 | ||
|
|
a480679a2d | ||
|
|
e99ae40e93 | ||
|
|
3cd50fbaf3 | ||
|
|
60dd1453d4 | ||
|
|
07781cd681 | ||
|
|
2387d8dcc9 | ||
|
|
723608f4db | ||
|
|
bd1dcde7b0 | ||
|
|
5b8e41e66d | ||
|
|
acc4796b99 | ||
|
|
106e85d6ca | ||
|
|
0ffc32f19e | ||
|
|
d897cc5dd0 | ||
|
|
6d2f277ce2 | ||
|
|
86f80b29c0 | ||
|
|
39243735e8 | ||
|
|
b3ff9d74b7 | ||
|
|
7b7604440f | ||
|
|
382d2b3c14 | ||
|
|
eda5379c17 | ||
|
|
d2fee51844 | ||
|
|
8ac4bad196 | ||
|
|
f140d7221c | ||
|
|
631f0bda1b | ||
|
|
1121314582 | ||
|
|
dda6b82f0f | ||
|
|
6c757c3fc8 | ||
|
|
eba194936e | ||
|
|
a7bdac16da | ||
|
|
716a8d06e2 | ||
|
|
e95cf44fec | ||
|
|
8f8efcb349 | ||
|
|
b188942436 | ||
|
|
9b7e9a98c8 | ||
|
|
d6f7fb6155 | ||
|
|
bd91f5e192 | ||
|
|
971a1b5071 | ||
|
|
ac5eecb7f3 | ||
|
|
3ae076b73e | ||
|
|
5394ccc45c | ||
|
|
3649109f40 | ||
|
|
0ff0ea7743 | ||
|
|
8be115096f | ||
|
|
e66fd2373f | ||
|
|
0e2cce152d | ||
|
|
101e0bc930 | ||
|
|
ea078250bf | ||
|
|
db79f8e484 | ||
|
|
6422f2d513 | ||
|
|
c60626f772 | ||
|
|
cafdaf7b50 | ||
|
|
bb0f56d758 | ||
|
|
7438db7a90 | ||
|
|
948f771838 | ||
|
|
82e34f014d | ||
|
|
9e4c3f8ba3 | ||
|
|
e6acd7386a | ||
|
|
b04e8b046a | ||
|
|
3262b984b7 | ||
|
|
71af88c73c | ||
|
|
2275f65169 | ||
|
|
71b7d6367f | ||
|
|
08369d840b | ||
|
|
94236133b5 | ||
|
|
84b48541bd | ||
|
|
a33d02f522 | ||
|
|
f94d4aa7d9 | ||
|
|
89cfb0c634 | ||
|
|
c9096e9eed | ||
|
|
859ca207c5 | ||
|
|
58fc122f0d | ||
|
|
0e990da961 | ||
|
|
db8236cc97 | ||
|
|
d731ccf873 | ||
|
|
6493d66901 | ||
|
|
9e7f38f473 | ||
|
|
a568ca0f99 | ||
|
|
41e7228a9f | ||
|
|
4568639718 | ||
|
|
73ecff0bd7 | ||
|
|
f2f023880f | ||
|
|
f2093e9204 | ||
|
|
23497dd371 | ||
|
|
37619fb2a3 | ||
|
|
f976686eba | ||
|
|
7af3721543 | ||
|
|
93edec0d01 | ||
|
|
54b61f2e24 | ||
|
|
184ed77493 | ||
|
|
8234b72fc3 | ||
|
|
cc9921945e | ||
|
|
ac6f23a41f | ||
|
|
973d046d33 | ||
|
|
dcb50cde8f | ||
|
|
08beb55853 | ||
|
|
bb0220e163 | ||
|
|
f3e4c8c6d0 | ||
|
|
44254671bb | ||
|
|
bcc6cc8f00 | ||
|
|
84549a1c31 | ||
|
|
0276420213 | ||
|
|
6353a08fd5 | ||
|
|
c265fa308a | ||
|
|
d3f72bc33a | ||
|
|
700fc4d27e | ||
|
|
9c7f30a150 | ||
|
|
85e45b30f5 | ||
|
|
5483e32eab | ||
|
|
f63c7d8142 | ||
|
|
5a2071f852 | ||
|
|
238ed12e28 | ||
|
|
d1afe6c3cf | ||
|
|
e58dd48e80 | ||
|
|
69f87d4dcd | ||
|
|
7b0dd11f7f | ||
|
|
620934fbf4 | ||
|
|
182fc723f1 | ||
|
|
5d3c5b6873 | ||
|
|
f20445bab7 | ||
|
|
8ad1a58f98 | ||
|
|
17e475236a | ||
|
|
b948eaaaa6 | ||
|
|
9b0c034247 | ||
|
|
b0dc9c7f1b | ||
|
|
e79abd43e3 | ||
|
|
36c9c478ec | ||
|
|
90d01b87ea | ||
|
|
b0823449bc | ||
|
|
760d5d346d | ||
|
|
4e11d1dd0f | ||
|
|
5e8c66142d | ||
|
|
f80016e4e6 | ||
|
|
c8fdbdf8c9 | ||
|
|
a04267a4e3 | ||
|
|
80a081d62a | ||
|
|
bb131c6690 | ||
|
|
4a4a131226 | ||
|
|
26cff23af4 | ||
|
|
189dae25ef | ||
|
|
b2f4a86224 | ||
|
|
122811eb7a | ||
|
|
bc98dacf65 | ||
|
|
f00445254d | ||
|
|
6686e3a0d7 | ||
|
|
5bcb425926 | ||
|
|
b4e9fa7688 | ||
|
|
594cbe03ef | ||
|
|
ec04afe0ad | ||
|
|
539c928920 | ||
|
|
a470857d4e | ||
|
|
dea40ca8be | ||
|
|
3d593f7cd7 | ||
|
|
1d36811dd6 | ||
|
|
4d53faf5b6 | ||
|
|
c101fe74ab | ||
|
|
b4fad80a8c | ||
|
|
41e0049045 | ||
|
|
4aaa09bb04 | ||
|
|
3f2c45be26 | ||
|
|
f7202955ef | ||
|
|
07432b0967 | ||
|
|
065eeda72a | ||
|
|
65ce9a67ef | ||
|
|
4996d7f37f | ||
|
|
86adf4ebc0 | ||
|
|
9a4a0ac9f1 | ||
|
|
2543179ae7 | ||
|
|
07a049c8ac | ||
|
|
0990fc2667 | ||
|
|
98bb203c84 | ||
|
|
0e82a0aed6 | ||
|
|
3dc9d521b4 | ||
|
|
4dbbc7c7c9 | ||
|
|
31f22bc339 | ||
|
|
87f0294f73 | ||
|
|
df9c55daf9 | ||
|
|
9246aff4e4 | ||
|
|
21c37e6d35 | ||
|
|
cbdad4a65e | ||
|
|
91ecf54746 | ||
|
|
4bcfe6c7f4 | ||
|
|
3ada6f1569 | ||
|
|
64402b01db | ||
|
|
6226db73d4 | ||
|
|
6ab2a2a6cc | ||
|
|
ba03fd7965 | ||
|
|
1c188d6325 | ||
|
|
8b1226aa1b | ||
|
|
2c1d320bba | ||
|
|
b6414442dd | ||
|
|
f5470f123f | ||
|
|
ea9a44c9ad | ||
|
|
881ee90e76 | ||
|
|
fbb570fb36 | ||
|
|
e781b60bcb | ||
|
|
78a071ae54 | ||
|
|
9bcd77c85b | ||
|
|
960bbb6965 | ||
|
|
46a56e474b | ||
|
|
666b60bd29 | ||
|
|
63f128457e | ||
|
|
5e8e9a852d | ||
|
|
3166ad933c | ||
|
|
7e5f64e04b | ||
|
|
908c02fd99 | ||
|
|
38fcc6d6bd | ||
|
|
16652347c6 | ||
|
|
5f28b988ec | ||
|
|
160631b158 | ||
|
|
3ab9923f0c | ||
|
|
cc799e069f | ||
|
|
9a7d3bac5f | ||
|
|
0a9a4f629d | ||
|
|
32c2c0c1a5 | ||
|
|
508d652194 | ||
|
|
471ba94ba2 | ||
|
|
66e24a07d4 | ||
|
|
5ffac54c86 | ||
|
|
c5b12b61d9 | ||
|
|
8d5e9ef9cf | ||
|
|
25fe9a3cdf | ||
|
|
05425d20a7 | ||
|
|
ff10e47d67 | ||
|
|
6c19dfbafd | ||
|
|
360cbf48af | ||
|
|
bbe93defe9 | ||
|
|
5e01c13b31 | ||
|
|
4b37f35803 | ||
|
|
775c9c49e2 | ||
|
|
e35260924e | ||
|
|
90aad8918c | ||
|
|
46974beba1 | ||
|
|
5e9dc33be1 | ||
|
|
1e271f188e | ||
|
|
f333597754 | ||
|
|
3b33d16356 | ||
|
|
2289d61d3c | ||
|
|
8e02f38bad | ||
|
|
5b65a6bc78 | ||
|
|
872d2c01d4 | ||
|
|
b7f199ddbc | ||
|
|
906c00af97 | ||
|
|
f00d52f879 | ||
|
|
7a1a885e89 | ||
|
|
ff8c275317 | ||
|
|
b3bf979cac | ||
|
|
eb9e316ed5 | ||
|
|
224ea5f7f8 | ||
|
|
c1dacab4c2 | ||
|
|
8d0b8d4905 | ||
|
|
e7190f442e | ||
|
|
95e1b7d171 | ||
|
|
f9ac017faf | ||
|
|
4252b7f76f | ||
|
|
58a7459e45 | ||
|
|
5dd601a90b | ||
|
|
50fe867e0d | ||
|
|
98bdaf0c42 | ||
|
|
1f1149e32e | ||
|
|
51b81c82bf | ||
|
|
15201b7a3c | ||
|
|
ad3f0f6c2e | ||
|
|
838d6a7178 | ||
|
|
a5cb484e52 | ||
|
|
3a252fb948 | ||
|
|
69365cbae4 | ||
|
|
2f1b9aad06 | ||
|
|
9e8371bde6 | ||
|
|
704fcecbdf | ||
|
|
785d8b0cc5 | ||
|
|
eaa68c243a | ||
|
|
a24246bfbc | ||
|
|
50679e6a01 | ||
|
|
d8cc884f01 | ||
|
|
6a534b8479 | ||
|
|
4048dfbaf1 | ||
|
|
2de8807942 | ||
|
|
8642a5b58c | ||
|
|
36de2b9aa4 | ||
|
|
c6bacca7f6 | ||
|
|
da0255a805 | ||
|
|
dee193502f | ||
|
|
b9458497fb | ||
|
|
52d2c8574f | ||
|
|
3da16e98e3 | ||
|
|
85336f3c0e | ||
|
|
9720516185 | ||
|
|
2a0efcff87 | ||
|
|
f9143a05c2 | ||
|
|
bd30cda4a9 | ||
|
|
33d5c0737e | ||
|
|
cb27fd1f98 | ||
|
|
d4ef956660 | ||
|
|
789c62ef71 | ||
|
|
b2a70a6a38 | ||
|
|
dd9607b54e | ||
|
|
14cde26291 | ||
|
|
388577a799 | ||
|
|
d0310ac211 | ||
|
|
99babf3879 | ||
|
|
ffae2a1d0a | ||
|
|
ce69bc14a8 | ||
|
|
4e94cb07be | ||
|
|
4395c8bea5 | ||
|
|
5b35a5db69 | ||
|
|
c1ff8a3a9a | ||
|
|
c352dfe284 | ||
|
|
9f2ec29586 | ||
|
|
63be2308d3 | ||
|
|
e509f8ab46 | ||
|
|
b481753526 | ||
|
|
1796fdb37b | ||
|
|
25f02378cb | ||
|
|
e95e2f5187 | ||
|
|
3f8365ad7e | ||
|
|
8bd0d7c8c9 | ||
|
|
2c261fc6c0 | ||
|
|
0ffb7d3d1e | ||
|
|
1305bf16e8 | ||
|
|
d9cbdf3394 | ||
|
|
a3b11de1a2 | ||
|
|
cf3f943177 | ||
|
|
267f464232 | ||
|
|
b70aabdeae | ||
|
|
8936f79716 | ||
|
|
968452a45e | ||
|
|
05c4b40497 | ||
|
|
60ea48dbaa | ||
|
|
284ca86b7c | ||
|
|
7cd762afa2 | ||
|
|
1b39bd2a4a | ||
|
|
e8fbb88102 | ||
|
|
f6e1f90225 | ||
|
|
0e53edafa3 | ||
|
|
4a10124574 | ||
|
|
d2555835c7 | ||
|
|
73d26e02b3 | ||
|
|
48a85998ad | ||
|
|
1a6b02999b | ||
|
|
976f1de806 | ||
|
|
1546e0e02f | ||
|
|
ce18b914b4 | ||
|
|
a54d6ceee5 | ||
|
|
a32ac4e672 | ||
|
|
93ed2068de | ||
|
|
6b8152d3bc | ||
|
|
eaa3d8c83c | ||
|
|
d772b557a0 | ||
|
|
867d967b37 | ||
|
|
56eef339a8 | ||
|
|
15b93abe04 | ||
|
|
208abda841 | ||
|
|
d4d78ac7db | ||
|
|
b593eb5630 |
No files matched your search
@@ -1,4 +1,40 @@
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
||||
# Dev / build artifacts (recreated inside the build stage)
|
||||
node_modules
|
||||
dist
|
||||
src/gui/dist
|
||||
src/puter-js/dist
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local runtime data
|
||||
volatile
|
||||
config.json
|
||||
config.dev.json
|
||||
/puter
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Git / CI
|
||||
.git
|
||||
.github
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.npm
|
||||
|
||||
# Tests / coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
creds*
|
||||
*.pem
|
||||
+18
-1
@@ -1 +1,18 @@
|
||||
PORT=4000
|
||||
# Copy this file to `.env`, fill in the secrets, and `docker compose -f
|
||||
# docker-compose.full.yml up -d`. None of the defaults below are safe for
|
||||
# anything beyond a local laptop test.
|
||||
|
||||
# ── Public-facing ports (nginx) ---------------------------------------
|
||||
HTTP_PORT=80
|
||||
# HTTPS_PORT=443 # uncomment after you enable TLS in nginx/nginx.conf
|
||||
|
||||
# ── MariaDB ------------------------------------------------------------
|
||||
MARIADB_ROOT_PASSWORD=replace-with-strong-password
|
||||
MARIADB_DATABASE=puter
|
||||
MARIADB_USER=puter
|
||||
MARIADB_PASSWORD=replace-with-strong-password
|
||||
|
||||
# ── S3 (RustFS) --------------------------------------------------------
|
||||
S3_ACCESS_KEY=puter
|
||||
S3_SECRET_KEY=replace-with-strong-secret
|
||||
S3_BUCKET=puter-local
|
||||
@@ -0,0 +1,49 @@
|
||||
name: AI Provider Integration Tests
|
||||
|
||||
# Hits real provider APIs with cheap models, so this workflow is
|
||||
# manually triggered only. Each provider's test reads its credential
|
||||
# from PUTER_TEST_AI_<PROVIDER>_API_KEY and skips itself silently if
|
||||
# the secret is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ai-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run AI provider integration tests
|
||||
# Vitest treats positional args as filename substring filters.
|
||||
# The pattern matches every `*.integration.test.ts` file and
|
||||
# nothing else — the regular backend test suite stays out.
|
||||
run: npm run test:backend -- integration.test
|
||||
env:
|
||||
CI: 'true'
|
||||
PUTER_TEST_AI_CLAUDE_API_KEY: ${{ secrets.PUTER_TEST_AI_CLAUDE_API_KEY }}
|
||||
PUTER_TEST_AI_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENAI_API_KEY }}
|
||||
PUTER_TEST_AI_GEMINI_API_KEY: ${{ secrets.PUTER_TEST_AI_GEMINI_API_KEY }}
|
||||
PUTER_TEST_AI_GROQ_API_KEY: ${{ secrets.PUTER_TEST_AI_GROQ_API_KEY }}
|
||||
PUTER_TEST_AI_MISTRAL_API_KEY: ${{ secrets.PUTER_TEST_AI_MISTRAL_API_KEY }}
|
||||
PUTER_TEST_AI_DEEPSEEK_API_KEY: ${{ secrets.PUTER_TEST_AI_DEEPSEEK_API_KEY }}
|
||||
PUTER_TEST_AI_XAI_API_KEY: ${{ secrets.PUTER_TEST_AI_XAI_API_KEY }}
|
||||
PUTER_TEST_AI_OPENROUTER_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENROUTER_API_KEY }}
|
||||
PUTER_TEST_AI_TOGETHER_API_KEY: ${{ secrets.PUTER_TEST_AI_TOGETHER_API_KEY }}
|
||||
PUTER_TEST_AI_MOONSHOT_API_KEY: ${{ secrets.PUTER_TEST_AI_MOONSHOT_API_KEY }}
|
||||
PUTER_TEST_AI_ZAI_API_KEY: ${{ secrets.PUTER_TEST_AI_ZAI_API_KEY }}
|
||||
PUTER_TEST_AI_ELEVENLABS_API_KEY: ${{ secrets.PUTER_TEST_AI_ELEVENLABS_API_KEY }}
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Backend Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- ref: ${{ github.base_ref }}
|
||||
artifact: base
|
||||
- ref: ${{ github.head_ref }}
|
||||
artifact: pr
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests with coverage
|
||||
run: npm run test:backend -- --coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-${{ matrix.artifact }}
|
||||
path: src/backend/coverage/
|
||||
retention-days: 14
|
||||
|
||||
report-coverage:
|
||||
needs: test
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-pr
|
||||
path: src/backend/coverage
|
||||
|
||||
- name: Download base coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
- name: Report coverage on PR
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
json-summary-path: src/backend/coverage/coverage-summary.json
|
||||
json-final-path: src/backend/coverage/coverage-final.json
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
@@ -1,84 +1,101 @@
|
||||
#
|
||||
name: Docker Image CI
|
||||
|
||||
# Configures this workflow to run every time a change is pushed to the
|
||||
# branch called `main`.
|
||||
# Two ways in:
|
||||
# - push of a calver tag (YY.MM or YY.MM.p) → publishes <version>, latest, main
|
||||
# - workflow_dispatch → publishes latest + main from whichever ref the user
|
||||
# picks in the Actions UI. Used to fast-forward :latest/:main when the
|
||||
# branch has moved ahead of the most recent release tag.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
branches:
|
||||
- 'main'
|
||||
push:
|
||||
tags:
|
||||
- '[0-9][0-9].[0-9][0-9]'
|
||||
- '[0-9][0-9].[0-9][0-9].[0-9]*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_latest:
|
||||
description: 'Push :latest tag'
|
||||
type: boolean
|
||||
default: true
|
||||
push_main:
|
||||
description: 'Push :main tag'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# Defines two custom environment variables for the workflow. These are used
|
||||
# for the Container registry domain, and a name for the Docker image that
|
||||
# this workflow builds.
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
# There is a single job in this workflow. It's configured to run on the
|
||||
# latest available version of Ubuntu.
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions
|
||||
# in this job.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
steps:
|
||||
- name: Validate calver tag
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
if ! [[ "$REF_NAME" =~ ^[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]]; then
|
||||
echo "Tag '$REF_NAME' does not match YY.MM or YY.MM.p calver format."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Uses the `docker/login-action` action to log in to the Container
|
||||
# registry using the account and password that will publish the packages.
|
||||
# Once published, the packages are scoped to the account defined here.
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Log in to GitHub Package Container registry
|
||||
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@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about)
|
||||
# to extract tags and labels that will be applied to the specified image.
|
||||
# The `id` "meta" allows the output of this step to be referenced in
|
||||
# a subsequent step. The `images` value provides the base name for the
|
||||
# tags and labels.
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=ref,event=branch
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# This step uses the `docker/build-push-action` action to build the
|
||||
# image, based on your repository's `Dockerfile`. If the build succeeds,
|
||||
# it pushes the image to GitHub Packages.
|
||||
# It uses the `context` parameter to define the build's context as the
|
||||
# set of files located in the specified path. For more information, see
|
||||
# "[Usage](https://github.com/docker/build-push-action#usage)" in the
|
||||
# README of the `docker/build-push-action` repository.
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image
|
||||
# with the output from the "meta" step.
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
heyputer/puter
|
||||
# type=ref,event=tag only emits on tag pushes, so manual runs skip
|
||||
# the calver tag and just publish whichever of latest / main the
|
||||
# dispatch inputs asked for. On a tag push, both default to true.
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'push' || inputs.push_latest }}
|
||||
type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sync README to Docker Hub
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: heyputer/puter
|
||||
readme-filepath: ./README.md
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Tag Alias
|
||||
|
||||
# Retags an existing image in GHCR under a custom name, without rebuilding.
|
||||
# Uses `docker buildx imagetools create` so the multi-arch manifest is preserved.
|
||||
# Re-running with the same name updates the alias to point at a different source tag.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: "Alias name (e.g., potato)"
|
||||
required: true
|
||||
source_tag:
|
||||
description: "Source tag to alias from"
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
alias:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Validate alias name
|
||||
env:
|
||||
NAME: ${{ inputs.name }}
|
||||
run: |
|
||||
if ! [[ "$NAME" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
|
||||
echo "Invalid alias name '$NAME' (allowed: [A-Za-z0-9_.-], must start with [A-Za-z0-9_], max 128 chars)."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$NAME" == "latest" || "$NAME" == "main" ]]; then
|
||||
echo "Alias name '$NAME' is reserved by the build workflow."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$NAME" =~ ^[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]]; then
|
||||
echo "Alias name '$NAME' looks like a calver tag — pick a non-version name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Log in to GitHub Package Container registry
|
||||
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@v3
|
||||
|
||||
- name: Create alias tag
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
NAME: ${{ inputs.name }}
|
||||
SOURCE_TAG: ${{ inputs.source_tag }}
|
||||
run: |
|
||||
SRC="${IMAGE}:${SOURCE_TAG}"
|
||||
DST="${IMAGE}:${NAME}"
|
||||
echo "Aliasing $DST -> $SRC"
|
||||
docker buildx imagetools create -t "$DST" "$SRC"
|
||||
{
|
||||
echo "### 🏷️ Tag Alias"
|
||||
echo ""
|
||||
echo "- Source: \`$SRC\`"
|
||||
echo "- Alias: \`$DST\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,94 +0,0 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths-ignore:
|
||||
- "src/docs/**"
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths-ignore:
|
||||
- "src/docs/**"
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Backend Tests (with coverage)
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm run test:backend -- --coverage --coverage.reporter=json --coverage.reporter=json-summary --coverage.reporter=lcov
|
||||
|
||||
- name: Upload backend coverage report
|
||||
if: ${{ always() && hashFiles('coverage/**/coverage-summary.json') != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-${{ matrix.node-version }}
|
||||
path: coverage
|
||||
retention-days: 5
|
||||
|
||||
api-test:
|
||||
name: API tests (node env, api-test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: API Test
|
||||
run: |
|
||||
pip install -r ./tests/ci/requirements.txt
|
||||
./tests/ci/api-test.py
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: (api-test) server-logs
|
||||
path: /tmp/backend.log
|
||||
retention-days: 3
|
||||
|
||||
vitest:
|
||||
name: puterjs (node env, vitest)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Vitest Test
|
||||
run: |
|
||||
pip install -r ./tests/ci/requirements.txt
|
||||
./tests/ci/vitest.py
|
||||
@@ -22,6 +22,11 @@ dist/
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
# Per-developer / deployment config override. `config.default.json` ships in
|
||||
# the repo and is the authoritative source; `config.json` (if present) wins
|
||||
# at runtime for the local machine only.
|
||||
config.json
|
||||
|
||||
# this is for jetbrain IDEs
|
||||
.idea/
|
||||
/puter
|
||||
@@ -63,3 +68,7 @@ AGENTS.md
|
||||
coverage/
|
||||
*.log
|
||||
undefined
|
||||
servers.json
|
||||
config.*.json
|
||||
|
||||
volatile/
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
[submodule "submodules/v86"]
|
||||
path = submodules/v86
|
||||
url = [email protected]:HeyPuter/v86.git
|
||||
[submodule "submodules/twisp"]
|
||||
path = submodules/twisp
|
||||
url = [email protected]:MercuryWorkshop/twisp.git
|
||||
[submodule "submodules/epoxy-tls"]
|
||||
path = submodules/epoxy-tls
|
||||
url = [email protected]:MercuryWorkshop/epoxy-tls.git
|
||||
[submodule "submodules/wiki"]
|
||||
path = submodules/wiki
|
||||
url = https://github.com/HeyPuter/puter.wiki.git
|
||||
Regular → Executable
+10
-8
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
tmpfile="$(mktemp)"
|
||||
git diff --cached --name-only -z --diff-filter=ACMR -- \
|
||||
'*.js' '*.mjs' '*.cjs' '*.jsx' '*.ts' '*.tsx' '*.vue' \
|
||||
> "$tmpfile"
|
||||
files=$(git diff --cached --name-only --diff-filter=ACMR | \
|
||||
grep -E '^(src/backend|extensions)/.*\.(js|mjs|cjs|ts)$' || true)
|
||||
|
||||
if [ -s "$tmpfile" ]; then
|
||||
xargs -0 eslint --fix < "$tmpfile" || true
|
||||
xargs -0 git add < "$tmpfile"
|
||||
if [ -z "$files" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm -f "$tmpfile"
|
||||
echo "$files" | xargs npx eslint --fix --no-warn-ignored
|
||||
status=$?
|
||||
|
||||
echo "$files" | xargs git add
|
||||
|
||||
exit $status
|
||||
@@ -1,57 +0,0 @@
|
||||
# To learn more about how to use Nix to configure your environment
|
||||
# see: https://developers.google.com/idx/guides/customize-idx-env
|
||||
{ pkgs, ... }: {
|
||||
# Which nixpkgs channel to use.
|
||||
channel = "stable-25.05"; # or "unstable"
|
||||
|
||||
# Use https://search.nixos.org/packages to find packages
|
||||
packages = [
|
||||
pkgs.python3
|
||||
pkgs.nodejs_24
|
||||
];
|
||||
|
||||
# Sets environment variables in the workspace
|
||||
env = {};
|
||||
idx = {
|
||||
# Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
|
||||
extensions = [
|
||||
# "vscodevim.vim"
|
||||
];
|
||||
|
||||
# Enable previews and customize configuration
|
||||
previews = {
|
||||
# Currently disabled because the preview system wasn't working
|
||||
enable = false;
|
||||
previews = {
|
||||
web = {
|
||||
command = [
|
||||
"npm"
|
||||
"run"
|
||||
"start"
|
||||
"--"
|
||||
"--port"
|
||||
"$PORT"
|
||||
"--host"
|
||||
"0.0.0.0"
|
||||
"--disable-host-check"
|
||||
];
|
||||
manager = "web";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Workspace lifecycle hooks
|
||||
workspace = {
|
||||
# Runs when a workspace is first created
|
||||
onCreate = {
|
||||
# npm-install = "npm install";
|
||||
# Currently disabled because the preview system wasn't working
|
||||
};
|
||||
# Runs when the workspace is (re)started
|
||||
onStart = {
|
||||
# npm-install = "npm install";
|
||||
# Currently disabled because the preview system wasn't working
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
Whitespace-only changes.
+2
-1
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
build
|
||||
**/*.dbmig.js
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding agents working in this repository. Read this before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think the guidance is wrong, raise an issue or flag a maintainer — don't just do what you think is right. This is the source of truth for how we write code, tests, and docs in this repo.
|
||||
|
||||
## Documentation Index
|
||||
|
||||
Use these as the source of truth before exploring further:
|
||||
|
||||
- [README.md](README.md) — project overview and quickstart.
|
||||
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), and extensions.
|
||||
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — testing, security, AI-assisted code, PR conventions, Boy Scout Rule.
|
||||
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
|
||||
- [BUG-BOUNTY.md](BUG-BOUNTY.md) — bounty program scope.
|
||||
- [TRADEMARK.md](TRADEMARK.md) — trademark usage.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
The backend is organized as a layered stack inspired by Controller–Service–Repository with dependency injection. Every layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together.
|
||||
|
||||
### Layers (top → bottom)
|
||||
|
||||
| Layer | Lives in | Responsibility |
|
||||
| --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers via `RouteOptions`), call services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional. RPC-style handlers exposed over `/drivers/*`. Thin shells that validate RPC inputs and call into services/stores. |
|
||||
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
|
||||
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
|
||||
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamo, email, event bus). Knows protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
|
||||
|
||||
Each layer receives the layers beneath it through its constructor. Dependencies are explicit and traceable from `PuterServer`.
|
||||
|
||||
### Cross-layer rules
|
||||
|
||||
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you want to, the abstraction is wrong — fix the abstraction.
|
||||
- **Don't call sideways within a layer for code reuse.** If two services need the same logic, lift it into a util/helper. Services should not depend on other services for shared code.
|
||||
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only when the value is genuinely request-scoped and would otherwise thread through many layers. Today it's used sparingly — mostly for `actor` and `req`.
|
||||
|
||||
### Extensions
|
||||
|
||||
Extensions live in [extensions/](extensions/) and parallel the layered stack. They are for **non-crucial parts of the system** — things Puter still works without if removed. If a feature is load-bearing for clients, it belongs in core, not in an extension (see [whoami](extensions/whoami.ts) as the cautionary example).
|
||||
|
||||
The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
|
||||
|
||||
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
|
||||
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
|
||||
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects. `extension.config` exposes live config.
|
||||
|
||||
### Language & file conventions
|
||||
|
||||
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
|
||||
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
|
||||
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
|
||||
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
|
||||
### Comments
|
||||
|
||||
Keep comments light. Prefer self-documenting code — clear names, small functions, obvious flow. Add a comment **only** when:
|
||||
|
||||
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
|
||||
- A non-trivial usage detail would otherwise trip the next reader.
|
||||
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR — those rot.
|
||||
|
||||
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the *why* in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
|
||||
|
||||
**Use plain ASCII `-` in comment section dividers, not box-drawing characters.** Write `// -- Section name --`, not `// ── Section name ──`. The `─` (U+2500) character renders inconsistently in some editors and diff tools and adds nothing over `-`.
|
||||
|
||||
### Tests
|
||||
|
||||
When adding new behavior (function, endpoint, driver method, branch of logic), add a test for it.
|
||||
|
||||
- Use Vitest for unit and integration tests. Test files go next to the code they test, named `*.test.ts` or `*.test.js`.
|
||||
- **Mock data, not methods.** Stub the inputs (fixtures, fake rows, sample payloads) rather than mocking the function under test or the layer beneath it. Over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service), not within the same layer you're testing.
|
||||
- Prefer test server over mocking deps. We have existing test utilities for running full test server to boot deps based on config. (see [src/backend/testUtil.ts](src/backend/testUtil.ts)).
|
||||
- **Hit a real database / real client where reasonable.** Integration shapes catch the things unit tests with mocks miss.
|
||||
- **Regression tests for bug fixes.** A test that fails before your fix and passes after is the cheapest insurance against the bug coming back.
|
||||
- If something is genuinely hard to test (UI animation, third-party glue), skip it but say so in the PR.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
Before opening a PR, scan the diff for:
|
||||
|
||||
- Logs, error messages, or responses leaking internal paths, secrets, tokens, env vars, or other users' data.
|
||||
- Debug routes, test credentials, commented-out auth checks.
|
||||
- Endpoints returning more than the caller actually needs.
|
||||
|
||||
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
|
||||
|
||||
### Working rules of thumb
|
||||
|
||||
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
|
||||
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you genuinely think the existing pattern is wrong, raise it — don't quietly diverge.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo, dead import, or missing typehint in files you're already touching. Don't ride a refactor along with a bug fix — that just makes review harder.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
|
||||
+35
-2
@@ -6,7 +6,7 @@ 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](https://github.com/HeyPuter/puter))
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
* **`puter.com`**
|
||||
* **`api.puter.com`**
|
||||
|
||||
@@ -17,18 +17,51 @@ Out-of-scope:
|
||||
* 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).
|
||||
|
||||
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.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
To participate, you must:
|
||||
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code or screenshots where applicable.
|
||||
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.
|
||||
5. **Act in good faith**: Make every effort to avoid privacy violations, destruction of data, and interruption or degradation of services.
|
||||
6. **Check the Known Non-Issues list**: Reports matching an item in the "Known Non-Issues" section above are not eligible and will be closed.
|
||||
|
||||
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:
|
||||
|
||||
* 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 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 be declined; send each confirmed issue separately.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
|
||||
|
||||
+27
-55
@@ -1,78 +1,50 @@
|
||||
# Contributing to Puter
|
||||
# Contributing to Puter Backend
|
||||
|
||||
Welcome to Puter, the open-source distributed internet operating system. We're excited to have you contribute to our project, whether you're reporting bugs, suggesting new features, or contributing code. This guide will help you get started with contributing to Puter in different ways.
|
||||
Thanks for contributing. These rules aren't strictly enforced — but following them makes every PR easier. If anything's unclear, ping a core maintainer or open the PR and ask.
|
||||
|
||||
<br>
|
||||
New to the backend? Start with [doc/architecture.md](doc/architecture.md).
|
||||
|
||||
# Report bugs
|
||||
---
|
||||
|
||||
Before reporting a bug, please check [the issues on our GitHub repository](https://github.com/HeyPuter/puter/issues) to see if the bug has already been reported. If it has, you can add a comment to the existing issue with any additional information you have.
|
||||
## 1. Test it. Run it.
|
||||
|
||||
If you find a new bug in Puter, please [open an issue on our GitHub repository](https://github.com/HeyPuter/puter/issues/new). We'll do our best to address the issue as soon as possible. When reporting a bug, please include as much information as possible, including:
|
||||
Run the affected code path end-to-end before opening a PR. "It builds" is not "it works."
|
||||
|
||||
- A clear and descriptive title
|
||||
- A description of the issue
|
||||
- Steps to reproduce the bug
|
||||
- Expected behavior
|
||||
- Actual behavior
|
||||
- Screenshots, if applicable
|
||||
- Your host operating system and browser
|
||||
- Your Puter version, location, ...
|
||||
Add tests for new behavior, endpoints, or bug fixes. If something's genuinely hard to test, say so in the PR.
|
||||
|
||||
Please open a separate issue for each bug you find.
|
||||
## 2. Follow existing patterns
|
||||
|
||||
Maintainers will apply the appropriate labels to your issue.
|
||||
Match the shape of similar code already in the repo. [doc/architecture.md](doc/architecture.md) is the source of truth for layers, wiring, and naming. If you think a pattern is wrong, raise it — don't quietly diverge.
|
||||
|
||||
<br>
|
||||
## 3. Don't expose system or user information
|
||||
|
||||
# Suggest new features
|
||||
Scan your diff for stray logs, debug routes, internal paths, secrets, tokens, or user data in errors/responses. When in doubt, return less. Flag any auth, permission, or data-export changes in the PR description.
|
||||
|
||||
If you have an idea for a new feature in Puter, please open a new discussion thread on our [GitHub repository](https://github.com/HeyPuter/puter/discussions) to discuss your idea with the community. We'll do our best to respond to your suggestion as soon as possible.
|
||||
For private security reports, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
When suggesting a new feature, please include as much information as possible, including:
|
||||
## 4. AI-assisted code is fine — understood code is required
|
||||
|
||||
- A clear and descriptive title
|
||||
- A description of the feature
|
||||
- The problem the feature will solve
|
||||
- Any relevant screenshots or mockups
|
||||
- Any relevant links or resources
|
||||
Don't commit code you couldn't have written, debugged, or defended yourself. Read the diff, run it, and be ready to explain it in review.
|
||||
|
||||
<br>
|
||||
## 5. Update docs for API changes
|
||||
|
||||
# Contribute code
|
||||
If you change puter-js APIs (drivers or endpoints used by puter-js), update [developer docs](src/docs/). puter-js itself may also need updating.
|
||||
|
||||
If you'd like to contribute code to Puter, you need to fork the project and submit a pull request. If this is your first time contributing to an open-source project, we recommend reading this short guide by GitHub on [how to contribute to a project](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project).
|
||||
## 6. Boy Scout Rule — leave it 1% better
|
||||
|
||||
We'll review your pull request and work with you to get your changes merged into the project.
|
||||

|
||||
|
||||
<br>
|
||||
Fix the typo, the dead import, the missing test, the bit you had to read twice. Keep cleanup proportional to the change — no refactors riding along on bug fixes.
|
||||
|
||||
## PR Standards
|
||||
---
|
||||
|
||||
We expect the following from pull requests (it makes things easier):
|
||||
- If you're closing an issue, please reference that issue in the PR description
|
||||
- Avoid whitespace changes
|
||||
- No regressions for "appspace" (Puter apps)
|
||||
## Opening a PR
|
||||
|
||||
<br>
|
||||
- One thing per PR where possible.
|
||||
- Describe **what** and **why**; the diff shows how.
|
||||
- Mention how you tested user-visible changes.
|
||||
- Drafts welcome.
|
||||
|
||||
## Code Review
|
||||
---
|
||||
|
||||
Once you've submitted your pull request, the project maintainers will review your changes. We may suggest some changes or improvements. This is a normal part of the process, and your contributions are greatly appreciated!
|
||||
|
||||
<br>
|
||||
|
||||
## Contribution License Agreement (CLA)
|
||||
|
||||
Like many open source projects, we require contributors to sign a Contribution License Agreement (CLA) before we can accept your code. When you open a pull request for the first time, a bot will automatically add a comment with a link to the CLA. You can sign the CLA electronically by following the link and filling out the form.
|
||||
|
||||
<br>
|
||||
|
||||
# Getting Help
|
||||
|
||||
If you have any questions about Puter, please feel free to reach out to us through the following channels:
|
||||
|
||||
- [Discord](https://discord.com/invite/PQcx7Teh8u)
|
||||
- [Reddit](https://www.reddit.com/r/Puter/)
|
||||
- [Twitter](https://twitter.com/HeyPuter)
|
||||
- [Email](mailto:[email protected])
|
||||
Questions? Message a core maintainer. Welcome aboard.
|
||||
+67
-69
@@ -1,90 +1,88 @@
|
||||
# /!\ NOTICE /!\
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# OSS Puter image — multi-arch (linux/amd64, linux/arm64).
|
||||
#
|
||||
# Build & push:
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
# -t ghcr.io/heyputer/puter:latest --push .
|
||||
#
|
||||
# Local single-arch build:
|
||||
# docker build -t puter .
|
||||
#
|
||||
# Self-hosters inject configuration by mounting a config.json at
|
||||
# /etc/puter/config.json. It is deep-merged over the bundled
|
||||
# config.default.json, so partial overrides work. Absent file = defaults.
|
||||
|
||||
# Many of the developers DO NOT USE the Dockerfile or image.
|
||||
# While we do test new changes to Docker configuration, it's
|
||||
# possible that future changes to the repo might break it.
|
||||
# When changing this file, please try to make it as resiliant
|
||||
# to such changes as possible; developers shouldn't need to
|
||||
# worry about Docker unless the build/run process changes.
|
||||
# ---- Build stage ----
|
||||
FROM node:24-slim AS build
|
||||
|
||||
# Build stage
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /opt/puter
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git python3 make g++ \
|
||||
&& ln -sf /usr/bin/python3 /usr/bin/python
|
||||
# Build toolchain needed for native deps (bcrypt, sharp, better-sqlite3, …).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 make g++ git && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up working directory
|
||||
WORKDIR /app
|
||||
ENV HUSKY=0
|
||||
ENV npm_config_fund=false
|
||||
ENV npm_config_audit=false
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
# ---- Dependency layer ---------------------------------------------------
|
||||
# Copy ONLY package manifests + lockfile first so the npm-install layer
|
||||
# stays cached when only source files change.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY src/backend/package.json src/backend/
|
||||
COPY src/gui/package.json src/gui/
|
||||
COPY src/puter-js/package.json src/puter-js/package-lock.json src/puter-js/
|
||||
COPY src/worker/package.json src/worker/
|
||||
COPY src/docs/package.json src/docs/
|
||||
|
||||
# Fail early if lockfile or manifest is missing
|
||||
RUN test -f package.json && test -f package-lock.json
|
||||
# extensionSetup.mjs runs as the postinstall hook during npm ci. (No-ops
|
||||
# unless any packages/puter/extensions/* gain a package.json.)
|
||||
COPY tools/extensionSetup.mjs tools/extensionSetup.mjs
|
||||
|
||||
# Copy the source files
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
|
||||
# ---- Source layer -------------------------------------------------------
|
||||
COPY . .
|
||||
|
||||
# Install mocha
|
||||
RUN npm i -g npm@latest
|
||||
RUN npm install -g mocha
|
||||
# Compile backend TS, then build GUI + puter-js webpack bundles in
|
||||
# parallel. The GUI/puter-js bundles are how /dist/bundle.min.{js,css}
|
||||
# and /sdk/puter.js fall back to local assets when the kernel-config
|
||||
# CDN keys are unset.
|
||||
RUN npm run build:ts
|
||||
RUN set -e; \
|
||||
(cd src/gui && node ./build.js) & gui_pid=$!; \
|
||||
(cd src/puter-js && npm run build) & pjs_pid=$!; \
|
||||
wait $gui_pid; \
|
||||
wait $pjs_pid
|
||||
|
||||
# Install node modules
|
||||
RUN npm cache clean --force && \
|
||||
for i in 1 2 3; do \
|
||||
npm ci && break || \
|
||||
if [ $i -lt 3 ]; then \
|
||||
sleep 15; \
|
||||
else \
|
||||
LOG_DIR="$(npm config get cache | tr -d '\"')/_logs"; \
|
||||
echo "npm install failed; dumping logs from $LOG_DIR"; \
|
||||
if [ -d "$LOG_DIR" ]; then \
|
||||
ls -al "$LOG_DIR" || true; \
|
||||
cat "$LOG_DIR"/* || true; \
|
||||
else \
|
||||
echo "Log directory not found (npm cache: $(npm config get cache))"; \
|
||||
fi; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
done
|
||||
# ---- Runtime stage (slim — no build tools) ----
|
||||
FROM node:24-slim
|
||||
|
||||
# Run the build command if necessary
|
||||
RUN cd src/gui && npm run build && cd -
|
||||
WORKDIR /opt/puter
|
||||
|
||||
# Production stage
|
||||
FROM node:24-alpine
|
||||
# git: runtime version probe. wget: HEALTHCHECK.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends git wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set labels
|
||||
LABEL repo="https://github.com/HeyPuter/puter"
|
||||
LABEL license="AGPL-3.0,https://github.com/HeyPuter/puter/blob/master/LICENSE.txt"
|
||||
LABEL version="1.2.46-beta-1"
|
||||
COPY --from=build --chown=node:node /opt/puter .
|
||||
|
||||
# Install git (required by Puter to check version)
|
||||
RUN apk add --no-cache git
|
||||
RUN mkdir -p /etc/puter /var/puter && \
|
||||
chown -R node:node /etc/puter /var/puter
|
||||
|
||||
# Set up working directory
|
||||
RUN mkdir -p /opt/puter/app
|
||||
WORKDIR /opt/puter/app
|
||||
|
||||
# Copy built artifacts and necessary files from the build stage
|
||||
COPY --from=build /app/src/gui/dist ./dist
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R node:node /opt/puter/app
|
||||
USER node
|
||||
# Self-hosters mount their override at this exact path. The v2 loader
|
||||
# deep-merges it over config.default.json (see backend/index.ts).
|
||||
ENV PUTER_CONFIG_PATH=/etc/puter/config.json
|
||||
ENV NODE_OPTIONS=--enable-source-maps
|
||||
|
||||
EXPOSE 4100
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
USER node
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
|
||||
ENV NO_VAR_RUNTUME=1
|
||||
|
||||
# Attempt to fix `[email protected]` missing after build stage
|
||||
# by doing a redundant `npm install` at this stage
|
||||
RUN npm install
|
||||
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/index.js"]
|
||||
@@ -1,6 +1,6 @@
|
||||
<h3 align="center"><img width="80" alt="Puter.com, The Personal Cloud Computer: All your files, apps, and games in one place accessible from anywhere at any time." src="https://assets.puter.site/puter-logo.png"></h3>
|
||||
|
||||
<h3 align="center">The Internet OS! Free, Open-Source, and Self-Hostable.</h3>
|
||||
<h3 align="center">The Open-Source Internet Computer!</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://puter.com/?ref=github.com"><strong>« LIVE DEMO »</strong></a>
|
||||
@@ -12,8 +12,6 @@
|
||||
·
|
||||
<a href="https://developer.puter.com" target="_blank">Developers</a>
|
||||
·
|
||||
<a href="https://github.com/heyputer/puter-cli" target="_blank">CLI</a>
|
||||
·
|
||||
<a href="https://discord.com/invite/PQcx7Teh8u">Discord</a>
|
||||
·
|
||||
<a href="https://reddit.com/r/puter">Reddit</a>
|
||||
@@ -21,19 +19,21 @@
|
||||
<a href="https://twitter.com/HeyPuter">X</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center"><img width="800" style="border-radius:5px;" alt="screenshot" src="https://assets.puter.site/puter.com-screenshot-3.webp"></h3>
|
||||
<h3 align="center"><img width="800" style="border-radius:5px;" alt="screenshot" src="https://assets.puter.site/puter.com-screenshot.webp"></h3>
|
||||
|
||||
<br/>
|
||||
|
||||
## Puter
|
||||
|
||||
Puter is an advanced, open-source internet operating system designed to be feature-rich, fast, and highly extensible. Puter can be used as:
|
||||
Puter is an advanced, open-source, self-hostable internet computer designed to be feature-rich, fast, and highly extensible.
|
||||
|
||||
- A privacy-first personal cloud to keep all your files, apps, and games in one secure place, accessible from anywhere at any time.
|
||||
- A platform for building and publishing websites, web apps, and games.
|
||||
- An alternative to Dropbox, Google Drive, OneDrive, etc. with a fresh interface and powerful features.
|
||||
- A remote desktop environment for servers and workstations.
|
||||
- A friendly, open-source project and community to learn about web development, cloud computing, distributed systems, and much more!
|
||||
### For Users
|
||||
Puter's goal is to provide you with every app and feature you need to work, create, and play under one roof. From a simple [Notepad](https://online-notepad.com) and [Voice Recorder](https://voice-recorder.com) to [Spreadsheet](https://apps.puter.com/app/spreadsheet) and [Camera](https://online-camera.com), Puter wants to be the all-in-one solution for your digital life.
|
||||
|
||||
|
||||
### For Developers
|
||||
|
||||
Puter provides everything you need to build and publish web apps and games. From [AI](https://developer.puter.com/ai/) to [Cloud Storage](https://developer.puter.com/object-storage/) and [Database](https://developer.puter.com/key-value-database/) to [Serverless Workers](https://developer.puter.com/serverless-workers/), Puter has you covered. Puter also helps you get users! Once you build your app, you can publish it on our [App Store](https://apps.puter.com/) to reach and monetize users.
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -48,59 +48,26 @@ cd puter
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
**→** This should launch Puter at
|
||||
<font color="red"> http://puter.localhost:4100 (or the next available port). </font>
|
||||
**→** This should launch Puter at http://puter.localhost:4100
|
||||
|
||||
|
||||
|
||||
If this does not work, see [First Run Issues](./doc/self-hosters/first-run-issues.md) for
|
||||
troubleshooting steps.
|
||||
|
||||
<br/>
|
||||
|
||||
### 🐳 Docker
|
||||
|
||||
```bash
|
||||
mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter
|
||||
```
|
||||
**→** This should launch Puter at
|
||||
<font color="red"> http://puter.localhost:4100 (or the next available port). </font>
|
||||
|
||||
<br/>
|
||||
|
||||
### 🐙 Docker Compose
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
mkdir -p puter/config puter/data
|
||||
sudo chown -R 1000:1000 puter
|
||||
wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml
|
||||
docker compose up
|
||||
```
|
||||
**→** This should be available at
|
||||
<font color="red"> http://puter.localhost:4100 (or the next available port). </font>
|
||||
|
||||
<br/>
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
mkdir -p puter
|
||||
cd puter
|
||||
New-Item -Path "puter\config" -ItemType Directory -Force
|
||||
New-Item -Path "puter\data" -ItemType Directory -Force
|
||||
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml"
|
||||
docker compose up
|
||||
```
|
||||
**→** This should launch Puter at
|
||||
<font color="red"> http://puter.localhost:4100 (or the next available port). </font>
|
||||
|
||||
<br/>
|
||||
|
||||
### 🚀 Self-Hosting
|
||||
|
||||
For detailed guides on self-hosting Puter, including configuration options and best practices, see our [Self-Hosting Documentation](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md).
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://puter.com/selfhost | sh
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
irm https://puter.com/selfhost?os=windows | iex
|
||||
```
|
||||
|
||||
**→** For more details, see [Self-Hosting Puter](./doc/self-hosting.md).
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -110,16 +77,6 @@ Puter is available as a hosted service at [**puter.com**](https://puter.com).
|
||||
|
||||
<br/>
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Operating Systems:** Linux, macOS, Windows
|
||||
- **RAM:** 2GB minimum (4GB recommended)
|
||||
- **Disk Space:** 1GB free space
|
||||
- **Node.js:** Version 24+
|
||||
- **npm:** Latest stable version
|
||||
|
||||
<br/>
|
||||
|
||||
## Support
|
||||
|
||||
Connect with the maintainers and community through these channels:
|
||||
@@ -129,7 +86,7 @@ Connect with the maintainers and community through these channels:
|
||||
- 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? [[email protected]](mailto:[email protected])
|
||||
- Security issues or abuse reports? [[email protected]](mailto:[email protected])
|
||||
- Email maintainers at [[email protected]](mailto:[email protected])
|
||||
|
||||
We are always happy to help you with any questions you may have. Don't hesitate to ask!
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"config_name": "oss-default",
|
||||
"env": "dev",
|
||||
"port": 4100,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"jwt_secret": "dev-jwt-secret-change-me",
|
||||
"jwt_secret_v2": "dev-jwt-secret-v2-change-me",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "dev-url-signature-secret-change-me",
|
||||
"allow_all_host_values": true,
|
||||
"allow_no_host_header": true,
|
||||
"no_devwatch": false,
|
||||
"enable_public_folders": true,
|
||||
"is_storage_limited": false,
|
||||
"min_pass_length": 6,
|
||||
"static_hosting_domain": "site.puter.localhost",
|
||||
"static_hosting_domain_alt": "host.puter.localhost",
|
||||
"private_app_hosting_domain": "app.puter.localhost",
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
"captcha": { "enabled": false },
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
"builtin_apps": {
|
||||
"dev-center": "./src/dev-center"
|
||||
},
|
||||
"extensions": [
|
||||
"./extensions"
|
||||
],
|
||||
"database": {
|
||||
"engine": "sqlite",
|
||||
"path": "volatile/runtime/puter-database.sqlite"
|
||||
},
|
||||
"s3": {
|
||||
"localConfig": {
|
||||
"dataDir": "volatile/runtime/fauxqs-data",
|
||||
"s3StorageDir": "volatile/runtime/fauxqs-s3-data"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
{
|
||||
// Comprehensive template — every key the OSS backend or shipped (non-prod)
|
||||
// extensions read. Copy to `config.json` and trim what you don't need;
|
||||
// unset keys fall back to documented defaults. See
|
||||
// `src/backend/types.ts` for the per-field source of truth.
|
||||
//
|
||||
// Each setting lives at exactly one canonical key — there are no fallback
|
||||
// aliases. Values shown are illustrative, not production secrets.
|
||||
//
|
||||
// Keys consumed only by closed-source / hosted-prod extensions (clickhouse,
|
||||
// cacheUpdateHandler, pages, prodMeteringAndBilling, …) are intentionally
|
||||
// omitted.
|
||||
|
||||
// ── Environment / identity ──────────────────────────────────────────
|
||||
"config_name": "template",
|
||||
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
|
||||
// dev-time webpack watcher; `prod` serves pre-built bundles.
|
||||
"env": "dev",
|
||||
"version": "0.0.0",
|
||||
// Stable identity for this server node — used by pager alerts and
|
||||
// graceful-shutdown coordination.
|
||||
"serverId": "node-1",
|
||||
|
||||
// ── Networking / URLs ───────────────────────────────────────────────
|
||||
// Port Puter listens on internally.
|
||||
"port": 4100,
|
||||
// Externally-visible port (set this when behind a reverse proxy on 80/443).
|
||||
"pub_port": 4100,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
// pub_port if unset.
|
||||
"origin": "http://puter.localhost:4100",
|
||||
// Public base URL for the API subdomain (used to build signed URLs and
|
||||
// surfaced to the client by the `installedApps` and `whoami` extensions).
|
||||
"api_base_url": "http://api.puter.localhost:4100",
|
||||
// Subdomains Puter routes on. Wildcard DNS (`*.<domain>`) must point at
|
||||
// this server for site/app hosting to work.
|
||||
"static_hosting_domain": "site.puter.localhost",
|
||||
"static_hosting_domain_alt": "host.puter.localhost",
|
||||
"private_app_hosting_domain": "app.puter.localhost",
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
// Host-header / domain handling. Defaults below are the dev-friendly
|
||||
// settings; tighten for any public install.
|
||||
"allow_all_host_values": true,
|
||||
"allow_no_host_header": true,
|
||||
"allow_nipio_domains": false,
|
||||
"custom_domains_enabled": false,
|
||||
"enable_ip_validation": false,
|
||||
// Express `trust proxy` setting — set to the number of reverse-proxy
|
||||
// hops in front of the server (1 = nginx/Cloudflare, 2 = CF→ALB→app),
|
||||
// or to a CIDR / IP / list. `false` (safe default) makes `req.ip` the
|
||||
// direct socket peer. NEVER set to `true` in prod — it trusts every hop
|
||||
// and makes X-Forwarded-For forgeable.
|
||||
"trust_proxy": false,
|
||||
"no_browser_launch": false,
|
||||
|
||||
// ── Dev watcher (devWatcher extension) ──────────────────────────────
|
||||
// Rebuilds GUI + puter.js on file changes when running from source.
|
||||
// Ignored when `env: "prod"` unless `devwatch.enabled: true`.
|
||||
"no_devwatch": false,
|
||||
"devwatch": {
|
||||
// Delay after watcher startup before boot continues. Lets webpack
|
||||
// emit its first build so the homepage doesn't 404 on bundle.min.js.
|
||||
"ready_delay_ms": 5000
|
||||
},
|
||||
|
||||
// ── Auth / session ──────────────────────────────────────────────────
|
||||
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
|
||||
// `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`.
|
||||
"jwt_secret": "change-me",
|
||||
"jwt_secret_v2": "change-me",
|
||||
// Set false to retire v1 tokens entirely (ROLLOUT-1).
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "change-me",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"min_pass_length": 6,
|
||||
"allow_system_login": false,
|
||||
"strict_email_verification_required": false,
|
||||
"captcha": {
|
||||
"enabled": false,
|
||||
"difficulty": "medium"
|
||||
},
|
||||
"oidc": {
|
||||
"providers": {
|
||||
// Google uses OIDC discovery — only ids are required.
|
||||
"google": {
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
"scopes": "openid email profile"
|
||||
},
|
||||
// Custom OIDC providers must also supply the three endpoints.
|
||||
"custom-oidc": {
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
"authorization_endpoint": "",
|
||||
"token_endpoint": "",
|
||||
"userinfo_endpoint": "",
|
||||
"scopes": "openid email profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Groups / provisioning ───────────────────────────────────────────
|
||||
// UIDs of the persistent groups new users are auto-enrolled in.
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
// When true, ACL grants read/list on `/<user>/Public` to any actor.
|
||||
"enable_public_folders": true,
|
||||
|
||||
// ── Storage / S3 ────────────────────────────────────────────────────
|
||||
"s3": {
|
||||
// Local fauxqs (in-process S3-compatible) — used in dev and the
|
||||
// bundled-defaults Docker mode. Files land under `dataDir`.
|
||||
"localConfig": {
|
||||
"inMemory": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 4566,
|
||||
"dataDir": "volatile/runtime/fauxqs-data",
|
||||
"s3StorageDir": "volatile/runtime/fauxqs-s3-data"
|
||||
},
|
||||
// For real / external S3, replace `localConfig` above with `s3Config`.
|
||||
"_remote_example": {
|
||||
"s3Config": {
|
||||
"useCredentialChain": false,
|
||||
"endpoint": "https://s3.example.com",
|
||||
// Endpoint used in presigned URLs handed to the browser. Set
|
||||
// this when the server-side endpoint isn't reachable from the
|
||||
// browser (e.g. docker-internal `http://s3:9000`).
|
||||
"publicEndpoint": "",
|
||||
"accessKeyId": "",
|
||||
"secretAccessKey": "",
|
||||
"region": "us-west-2",
|
||||
// Set true for RustFS / MinIO / fauxqs (path-style URLs).
|
||||
// Real AWS S3 wants virtual-hosted — leave unset / false.
|
||||
"forcePathStyle": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"s3_bucket": "puter-local",
|
||||
"s3_region": "us-west-2",
|
||||
"region": "us-west-2",
|
||||
// Default per-user storage cap (bytes). 100 MB.
|
||||
"storage_capacity": 104857600,
|
||||
"is_storage_limited": false,
|
||||
"available_device_storage": 0,
|
||||
// ── Thumbnails (thumbnails extension) ───────────────────────────────
|
||||
// Optional dedicated S3-compatible bucket for generated thumbnails.
|
||||
// When unset (or `endpoint` empty), the extension falls back to the
|
||||
// main S3 client / bucket above.
|
||||
"thumbnailStore": {
|
||||
"name": "puter-local",
|
||||
"endpoint": "",
|
||||
"credentials": {
|
||||
"accessKeyId": "",
|
||||
"secretAccessKey": ""
|
||||
}
|
||||
},
|
||||
|
||||
// ── Database ────────────────────────────────────────────────────────
|
||||
"database": {
|
||||
// `sqlite` for single-node/dev, `mysql` for MariaDB/MySQL,
|
||||
// or `postgres` for PostgreSQL.
|
||||
//
|
||||
// NOTE: `postgres` support is a community contribution and is not
|
||||
// exercised by Puter.com production. It boots, passes its own
|
||||
// integration tests against pgmock, and runs the common user/app/
|
||||
// fsentry/session/permission/OIDC flows — but less-traveled code
|
||||
// paths may surface MySQL/SQLite-isms that haven't been ported yet.
|
||||
// Expect rough edges and please file issues if you hit one. For
|
||||
// production self-hosting today, `mysql` (MariaDB) and `sqlite` are
|
||||
// the supported defaults.
|
||||
"engine": "sqlite",
|
||||
// sqlite — file path on disk
|
||||
"path": "volatile/runtime/puter-database.sqlite",
|
||||
"targetVersion": 0,
|
||||
// mysql/postgres — connection details. PostgreSQL defaults to port
|
||||
// 5432 when `engine` is `postgres`; MySQL/MariaDB normally use 3306.
|
||||
"host": "",
|
||||
"port": 3306,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"database": "",
|
||||
// postgres may also use a URL instead of host/user/password fields:
|
||||
// "connectionString": "postgres://puter:secret@localhost:5432/puter",
|
||||
// Optional read-replica pool. Reads route here when populated.
|
||||
"replica": {
|
||||
"host": "",
|
||||
"port": 3306,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"database": ""
|
||||
}
|
||||
// mysql/postgres self-host bootstrap: set `migrationPaths` to apply the
|
||||
// bundled schema on first boot. Idempotent — safe to leave on.
|
||||
// "migrationPaths": ["./src/backend/clients/database/migrations/mysql"]
|
||||
// "migrationPaths": ["./src/backend/clients/database/migrations/postgres"]
|
||||
},
|
||||
|
||||
// ── DynamoDB (KV store) ─────────────────────────────────────────────
|
||||
"dynamo": {
|
||||
// Local emulator (dynamodb-local) endpoint. Drop this field for real
|
||||
// AWS DynamoDB.
|
||||
"endpoint": "http://localhost:8000",
|
||||
// Set true when pointing at a local emulator so Puter creates the KV
|
||||
// table on boot. NEVER set against real AWS — provision via IaC.
|
||||
// "bootstrapTables": true,
|
||||
"path": "",
|
||||
// Credentials. NOTE: snake_case here, unlike `s3.s3Config` below.
|
||||
// For dynamodb-local, any non-empty values work.
|
||||
"aws": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
"region": "us-west-2"
|
||||
}
|
||||
},
|
||||
|
||||
// ── Redis / Valkey (cache + cross-node rate limit) ──────────────────
|
||||
"redis": {
|
||||
// True → in-process redis-mock (dev / single-node).
|
||||
"useMock": true,
|
||||
// Cluster nodes for ioredis. For a single Valkey/Redis container,
|
||||
// run it in cluster mode (one node, all slots).
|
||||
"startupNodes": [
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 7000
|
||||
}
|
||||
]
|
||||
// Defaults to true (matches prod ElastiCache). Set false for plain-TCP
|
||||
// self-host Valkey/Redis.
|
||||
// "tls": false
|
||||
},
|
||||
|
||||
// ── Email (transactional) ───────────────────────────────────────────
|
||||
// Nodemailer transport — used for password resets, email confirmation, etc.
|
||||
"email": {
|
||||
"from": "\"Puter\" <[email protected]>",
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"service": "",
|
||||
"auth": {
|
||||
"user": "",
|
||||
"pass": ""
|
||||
}
|
||||
},
|
||||
|
||||
// ── Rate limiting ───────────────────────────────────────────────────
|
||||
// `memory` for single-node, `redis` for multi-node (default), `kv` for
|
||||
// dynamo-backed counters.
|
||||
"rate_limit": {
|
||||
"backend": "redis"
|
||||
},
|
||||
|
||||
// ── AI / integration providers ──────────────────────────────────────
|
||||
// All AI drivers (chat, image, video, TTS, OCR, STT) read from here.
|
||||
// Provider id == driver-side identifier. Leave empty / omit to disable.
|
||||
"providers": {
|
||||
// ─ Chat / completion ─
|
||||
"claude": { "apiKey": "" },
|
||||
"openai-completion": { "apiKey": "" },
|
||||
"gemini": { "apiKey": "" },
|
||||
"groq": { "apiKey": "" },
|
||||
"deepseek": { "apiKey": "" },
|
||||
"mistral": { "apiKey": "" },
|
||||
"xai": { "apiKey": "" },
|
||||
"moonshot": { "apiKey": "" },
|
||||
"minimax": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.minimax.io/v1"
|
||||
},
|
||||
"openrouter": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://openrouter.ai/api/v1"
|
||||
},
|
||||
"together-ai": { "apiKey": "" },
|
||||
// Local Ollama. `enabled: false` skips the auto-probe at startup
|
||||
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
|
||||
// is running).
|
||||
"ollama": {
|
||||
"enabled": false,
|
||||
"apiBaseUrl": "http://localhost:11434"
|
||||
},
|
||||
|
||||
// ─ Image generation ─
|
||||
"openai-image-generation": { "apiKey": "" },
|
||||
"gemini-image-generation": { "apiKey": "" },
|
||||
"together-image-generation": { "apiKey": "" },
|
||||
"cloudflare-image-generation": {
|
||||
"apiToken": "",
|
||||
"accountId": "",
|
||||
"apiBaseUrl": "https://api.cloudflare.com/client/v4"
|
||||
},
|
||||
"xai-image-generation": { "apiKey": "" },
|
||||
|
||||
// ─ Video generation ─
|
||||
"openai-video-generation": { "apiKey": "" },
|
||||
"together-video-generation": { "apiKey": "" },
|
||||
"gemini-video-generation": { "apiKey": "" },
|
||||
|
||||
// ─ Speech / OCR ─
|
||||
"openai": { "apiKey": "" },
|
||||
"elevenlabs": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.elevenlabs.io",
|
||||
"defaultVoiceId": "",
|
||||
"speechToSpeechModelId": ""
|
||||
},
|
||||
"aws-polly": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
"region": "us-west-2"
|
||||
},
|
||||
"aws-textract": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
"region": "us-west-2"
|
||||
},
|
||||
"mistral-ocr": { "apiKey": "" }
|
||||
},
|
||||
|
||||
// ── GUI / static mounts ─────────────────────────────────────────────
|
||||
"gui_assets_root": "./src/gui",
|
||||
"gui_profile": "development",
|
||||
"builtin_apps": {
|
||||
"dev-center": "./src/dev-center"
|
||||
},
|
||||
// Force the bundled GUI even in dev — set true when running from a
|
||||
// pre-built tree without webpack-dev-server.
|
||||
"use_bundled_gui": false,
|
||||
"gui_bundle": "/dist/bundle.min.js",
|
||||
"gui_css": "/dist/bundle.min.css",
|
||||
"gui_puterjs_bundle": "https://js.puter.com/v2/",
|
||||
"gui_params": {
|
||||
"title": "Puter",
|
||||
"short_description": "Your personal cloud computer",
|
||||
"social_media_image": ""
|
||||
},
|
||||
// Optional roots for native app bundles and custom puter.js builds.
|
||||
"native_apps_root": "",
|
||||
"client_libs_root": "",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
// ── Feature flags (whoami extension) ────────────────────────────────
|
||||
// Flat `{ flag_name: boolean }` bag. Server-only by default — flags are
|
||||
// only surfaced to the client if their key is on the allowlist in
|
||||
// `extensions/whoami.ts` (CLIENT_VISIBLE_FEATURE_FLAGS).
|
||||
"feature_flags": {
|
||||
"example_flag": false
|
||||
},
|
||||
|
||||
// ── Misc / safety ───────────────────────────────────────────────────
|
||||
// TLDs / domains rejected at signup (prod only).
|
||||
"blockedEmailDomains": [],
|
||||
"support_email": "[email protected]",
|
||||
// Worker / subdomain names users can't claim.
|
||||
"reserved_words": [],
|
||||
"max_subdomains_per_user": 10,
|
||||
"server_health": {
|
||||
"db_liveness_latency_fail_ms": 1500,
|
||||
"stale_health_loop_fail_ms": 0
|
||||
},
|
||||
|
||||
// ── Extensions ──────────────────────────────────────────────────────
|
||||
// Directories scanned for extension entrypoints (`*.ts` / subdirs).
|
||||
"extensions": [
|
||||
"./extensions"
|
||||
],
|
||||
|
||||
// ── Metering ────────────────────────────────────────────────────────
|
||||
// When true, all metering checks pass — no per-actor limits enforced.
|
||||
"unlimitedMetering": false
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# Documentation for Robots
|
||||
|
||||
Hello, if you're an AI agent then you're reading the correct documentation.
|
||||
Here are a few important notes:
|
||||
- Puter is probably already cloned and configured, so avoid any setup
|
||||
or configuration steps unless explicitly asked to perform them.
|
||||
- Anything under `/src` (relative to the root of the repo) is probably
|
||||
a workspace module. That means different directories might have different
|
||||
code styles or use different import mechanisms (ESM vs CJS). Try to keep
|
||||
changes consistent in the scope of where they are.
|
||||
|
||||
# Backend
|
||||
|
||||
Any file under `src/backend` that extends **BaseService** is called a
|
||||
"backend service". Backend services can implement "traits". That looks
|
||||
like this:
|
||||
|
||||
```javascript
|
||||
class SomeClass extends BaseService {
|
||||
static IMPLEMENTS = {
|
||||
['name-of-interface']: {
|
||||
async some_method_name () {
|
||||
const instance_of_SomeClass = this;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Methods on traits are bound to the same "this" (instance variable) as
|
||||
methods on the class itself. Trait methods cannot be indexed from the
|
||||
instance variable; instead common functionality is usually moved to
|
||||
regular instance methods which typically have an underscore at the end
|
||||
of their name.
|
||||
|
||||
# Furher Documentation
|
||||
|
||||
Proceed to read the README.md document beside this file.
|
||||
@@ -1,214 +0,0 @@
|
||||
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" version="25.0.1">
|
||||
<diagram name="Page-1" id="5LlmtnmR4draSSsTtYyD">
|
||||
<mxGraphModel grid="1" page="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" pageScale="1" pageWidth="1100" pageHeight="850" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-64" value="Legacy Notice" style="rounded=1;whiteSpace=wrap;html=1;align=left;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="555" width="130" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-8" value="only most important directories shown" style="rounded=1;whiteSpace=wrap;html=1;align=right;" vertex="1" parent="1">
|
||||
<mxGeometry x="380" y="50" width="230" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-1" value="src/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="360" y="110" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-2" value="Puter Repository" style="rounded=0;whiteSpace=wrap;html=1;fontSize=18;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="240" y="40" width="160" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-4" value="Every directory under src/ is a node module." style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;size=14;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="470" y="110" width="260" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-5" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-2" target="skmebJAFKBwesmhEX21R-1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="490" y="260" as="sourcePoint" />
|
||||
<mxPoint x="540" y="210" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="320" y="125" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-6" value="backend/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="440" y="190" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-7" value="gui/src/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="440" y="1000" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-10" value="" style="endArrow=none;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-1" target="skmebJAFKBwesmhEX21R-4">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="490" y="260" as="sourcePoint" />
|
||||
<mxPoint x="540" y="210" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-12" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-1" target="skmebJAFKBwesmhEX21R-6">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="490" y="260" as="sourcePoint" />
|
||||
<mxPoint x="540" y="210" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="400" y="205" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-14" value="extensions/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1080" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-15" value="Puter GUI Extensions" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1080" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-16" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-14" target="skmebJAFKBwesmhEX21R-15">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="530" y="980" as="sourcePoint" />
|
||||
<mxPoint x="580" y="930" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-15" target="skmebJAFKBwesmhEX21R-15">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-18" value="UI/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1120" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-19" value="Puter Desktop" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1120" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-20" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-18" target="skmebJAFKBwesmhEX21R-19">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="1098" as="sourcePoint" />
|
||||
<mxPoint x="740" y="1098" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-21" value="services/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1160" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-22" value="Frontend Services" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1160" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-23" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-21" target="skmebJAFKBwesmhEX21R-22">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="1138" as="sourcePoint" />
|
||||
<mxPoint x="740" y="1138" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-24" value="initgui.js" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1200" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-25" value="Launches services and initializes the GUI" style="rounded=1;whiteSpace=wrap;html=1;fontSize=13;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1200" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-26" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-24" target="skmebJAFKBwesmhEX21R-25">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="1185" as="sourcePoint" />
|
||||
<mxPoint x="740" y="1185" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-27" value="IPC.js" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="1240" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-28" value="Manages communication between Desktop and apps" style="rounded=1;whiteSpace=wrap;html=1;fontSize=13;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="1240" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-29" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-27" target="skmebJAFKBwesmhEX21R-28">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="1225" as="sourcePoint" />
|
||||
<mxPoint x="740" y="1225" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-46" value="modules/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="280" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-47" value="New Code goes Here" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="280" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-48" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-46" target="skmebJAFKBwesmhEX21R-47">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="530" y="180" as="sourcePoint" />
|
||||
<mxPoint x="580" y="130" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-47" target="skmebJAFKBwesmhEX21R-47">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-50" value="services/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="320" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-51" value="Core Module services" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="320" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-52" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-50" target="skmebJAFKBwesmhEX21R-51">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="298" as="sourcePoint" />
|
||||
<mxPoint x="740" y="298" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-53" value="routers/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="480" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-54" value="HTTP endpoints" style="rounded=1;whiteSpace=wrap;html=1;fontSize=16;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="480" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-55" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-53" target="skmebJAFKBwesmhEX21R-54">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="670" y="458" as="sourcePoint" />
|
||||
<mxPoint x="740" y="458" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-62" value="<div><span style="background-color: initial;">This is the original directory where routes</span></div><div><span style="background-color: initial;">were placed. New services should use the</span></div><div><span style="background-color: initial;">"Endpoint" class to define routes.</span></div><div><span style="background-color: initial;"><br></span></div><div><span style="background-color: initial;">Examples:</span></div><div><span style="background-color: initial;">- PermissionAPIService.js</span></div><div><span style="background-color: initial;">- NotificationService.js</span><br></div><div>- KernelInfoService.js</div><div><span style="color: rgba(0, 0, 0, 0); font-family: monospace; font-size: 0px; text-wrap: nowrap;">%3CmxGraphModel%3E%3Croot%3E%3CmxCell%20id%3D%220%22%2F%3E%3CmxCell%20id%3D%221%22%20parent%3D%220%22%2F%3E%3CmxCell%20id%3D%222%22%20value%3D%22extensions%2F%22%20style%3D%22rounded%3D0%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23e1d5e7%3BstrokeColor%3D%239673a6%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22520%22%20y%3D%22400%22%20width%3D%22140%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%223%22%20value%3D%22Puter%20GUI%20Extensions%22%20style%3D%22rounded%3D1%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23d5e8d4%3BstrokeColor%3D%2382b366%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22730%22%20y%3D%22400%22%20width%3D%22180%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%224%22%20value%3D%22%22%20style%3D%22endArrow%3Dclassic%3Bhtml%3D1%3Brounded%3D0%3B%22%20edge%3D%221%22%20source%3D%222%22%20target%3D%223%22%20parent%3D%221%22%3E%3CmxGeometry%20width%3D%2250%22%20height%3D%2250%22%20relative%3D%221%22%20as%3D%22geometry%22%3E%3CmxPoint%20x%3D%22530%22%20y%3D%22300%22%20as%3D%22sourcePoint%22%2F%3E%3CmxPoint%20x%3D%22580%22%20y%3D%22250%22%20as%3D%22targetPoint%22%2F%3E%3C%2FmxGeometry%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%225%22%20style%3D%22edgeStyle%3DorthogonalEdgeStyle%3Brounded%3D0%3BorthogonalLoop%3D1%3BjettySize%3Dauto%3Bhtml%3D1%3BexitX%3D0.5%3BexitY%3D1%3BexitDx%3D0%3BexitDy%3D0%3B%22%20edge%3D%221%22%20source%3D%223%22%20target%3D%223%22%20parent%3D%221%22%3E%3CmxGeometry%20relative%3D%221%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%226%22%20value%3D%22UI%2F%22%20style%3D%22rounded%3D0%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23e1d5e7%3BstrokeColor%3D%239673a6%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22520%22%20y%3D%22440%22%20width%3D%22140%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%227%22%20value%3D%22Puter%20Desktop%22%20style%3D%22rounded%3D1%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23d5e8d4%3BstrokeColor%3D%2382b366%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22730%22%20y%3D%22440%22%20width%3D%22180%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%228%22%20value%3D%22%22%20style%3D%22endArrow%3Dclassic%3Bhtml%3D1%3Brounded%3D0%3B%22%20edge%3D%221%22%20source%3D%226%22%20target%3D%227%22%20parent%3D%221%22%3E%3CmxGeometry%20width%3D%2250%22%20height%3D%2250%22%20relative%3D%221%22%20as%3D%22geometry%22%3E%3CmxPoint%20x%3D%22670%22%20y%3D%22418%22%20as%3D%22sourcePoint%22%2F%3E%3CmxPoint%20x%3D%22740%22%20y%3D%22418%22%20as%3D%22targetPoint%22%2F%3E%3C%2FmxGeometry%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%229%22%20value%3D%22services%2F%22%20style%3D%22rounded%3D0%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23e1d5e7%3BstrokeColor%3D%239673a6%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22520%22%20y%3D%22480%22%20width%3D%22140%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%2210%22%20value%3D%22Frontend%20Services%22%20style%3D%22rounded%3D1%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BfontSize%3D16%3BfillColor%3D%23d5e8d4%3BstrokeColor%3D%2382b366%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22730%22%20y%3D%22480%22%20width%3D%22180%22%20height%3D%2230%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3CmxCell%20id%3D%2211%22%20value%3D%22%22%20style%3D%22endArrow%3Dclassic%3Bhtml%3D1%3Brounded%3D0%3B%22%20edge%3D%221%22%20source%3D%229%22%20target%3D%2210%22%20parent%3D%221%22%3E%3CmxGeometry%20width%3D%2250%22%20height%3D%2250%22%20relative%3D%221%22%20as%3D%22geometry%22%3E%3CmxPoint%20x%3D%22670%22%20y%3D%22458%22%20as%3D%22sourcePoint%22%2F%3E%3CmxPoint%20x%3D%22740%22%20y%3D%22458%22%20as%3D%22targetPoint%22%2F%3E%3C%2FmLine truncated
|
||||
<mxGeometry x="645" y="545" width="270" height="135" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-63" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-62" target="skmebJAFKBwesmhEX21R-54">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="730" y="560" as="sourcePoint" />
|
||||
<mxPoint x="780" y="510" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-65" value="structured/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="700" width="140" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-66" value="unstructured/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="720" width="140" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-68" value="<div>These directories mostly contain code for</div><div>the permission system to make it easier to maintain.</div><div><br></div><div>structured/&lt;pattern&gt;/ contains code that is formatted according to "&lt;pattern&gt;"</div><div><br></div><div>unstructured/ contains miscellaneous code used by the permission system.</div>" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;size=18;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=13;fontColor=#333333;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="700" width="270" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-69" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;elbow=vertical;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-68" target="skmebJAFKBwesmhEX21R-65">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="730" y="680" as="sourcePoint" />
|
||||
<mxPoint x="780" y="630" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-70" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;elbow=vertical;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-68" target="skmebJAFKBwesmhEX21R-66">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="740" y="720" as="sourcePoint" />
|
||||
<mxPoint x="670" y="720" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-71" value="these should go to:<div>modules/auth/</div><div>in the future</div>" style="rounded=1;whiteSpace=wrap;html=1;glass=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="760" width="140" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-72" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-71" target="skmebJAFKBwesmhEX21R-66">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="730" y="680" as="sourcePoint" />
|
||||
<mxPoint x="780" y="630" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-73" value="util/" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="520" y="880" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-74" value="<div>Utilities used by various services in Puter's backend.</div><div><br></div><div>These should be incrementally moved to individual `lib` directories in modules/</div>" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;size=18;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=13;fontColor=#333333;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="880" width="270" height="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-75" value="" style="endArrow=classic;html=1;rounded=0;edgeStyle=elbowEdgeStyle;elbow=vertical;" edge="1" parent="1" source="skmebJAFKBwesmhEX21R-74" target="skmebJAFKBwesmhEX21R-73">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="740" y="740" as="sourcePoint" />
|
||||
<mxPoint x="670" y="740" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-76" value="Migration Notice" style="rounded=1;whiteSpace=wrap;html=1;align=left;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="400" width="130" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="skmebJAFKBwesmhEX21R-77" value="<div>Services here should be incrementally</div><div>migrated into the modules/ directory.</div>" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;size=18;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="390" width="270" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
@@ -1,45 +0,0 @@
|
||||
# Puter Documentation
|
||||
|
||||
Hi, you've found Puter's wiki page on GitHub! If you were looking for
|
||||
something else, you might find it in the links below.
|
||||
All of the wiki docs are generated from `doc/` directories in the main
|
||||
repository, so it's best to edit docs there rather than here.
|
||||
|
||||
## Users
|
||||
|
||||
If you have general questions about using [Puter](https://puter.com),
|
||||
our [community Discord](https://discord.gg/PQcx7Teh8u) and
|
||||
[subreddit](https://www.reddit.com/r/puter/) are good places
|
||||
to ask questions.
|
||||
|
||||
## Deployers
|
||||
|
||||
- [Hosting Instructions](./self-hosters/instructions.md)
|
||||
- [Configuration](./self-hosters/config.md)
|
||||
- [Domain Setup](./self-hosters/domains.md)
|
||||
- [Support Levels](./self-hosters/support.md)
|
||||
|
||||
## App Developer Links
|
||||
- [developer.puter.com](https://developer.puter.com)
|
||||
- [docs.puter.com](https://docs.puter.com)
|
||||
- share your apps on [Reddit](https://www.reddit.com/r/puter/) or
|
||||
[Discord](https://discord.gg/PQcx7Teh8u)
|
||||
|
||||
## Contributor Documentation
|
||||
|
||||
### Where to Start
|
||||
|
||||
Start with [Repo Structure and Tooling](./contributors/structure.md).
|
||||
|
||||
### Index
|
||||
|
||||
- **Conventions**
|
||||
- [Repo Structure and Tooling](./contributors/structure.md)
|
||||
- How directories and files are organized in our GitHub repo
|
||||
- What tools are used to build parts of Puter
|
||||
- [Comment Prefixes](./contributors/comment_prefixes.md)
|
||||
- A convention we use for line comments in code
|
||||
|
||||
- [Frontend Documentation](/src/gui/doc)
|
||||
- [Backend Documentation](/src/backend/doc)
|
||||
- [Extensions](./contributors/extensions/)
|
||||
@@ -1,5 +0,0 @@
|
||||
# API Documentation
|
||||
|
||||
Note that this documentation is different from the [puter.js docs](https://docs.puter.com).
|
||||
The scope of the documentation in this directory includes both stable API endpoints that
|
||||
are used by **puter.js**, as well as API endpoints that may be subject to future changes.
|
||||
@@ -1,60 +0,0 @@
|
||||
## Puter Drivers
|
||||
|
||||
### **POST** `/drivers/call`
|
||||
|
||||
#### Notes
|
||||
|
||||
- **HTTP response status** -
|
||||
A successful driver response, even if the response is an error message, will always have HTTP status `200`. Note that sometimes this will include rate limit and usage limit errors as well.
|
||||
|
||||
This endpoint allows you to call a Puter driver. Whether or not the
|
||||
driver call fails, this endpoint will respond with HTTP 200 OK.
|
||||
When a driver call fails, you will get a JSON response from the driver
|
||||
with
|
||||
|
||||
#### Parameters
|
||||
|
||||
Parameters are provided in the request body. The content type of the
|
||||
request should be `application/json`.
|
||||
|
||||
- **interface:** `string`
|
||||
- **description:** The type of driver to call. For example,
|
||||
LLMs use the interface called `puter-chat-completion`.
|
||||
- **service:** `string`
|
||||
- **description:** The name of the service to use. For example, the `claude` service might be used for `puter-chat-completion`.
|
||||
- **method:** `string`
|
||||
- **description:** The name of the method to call. For example, LLMs implement `complete` which does a chat completion, and `list` which lists models.
|
||||
- **args:** `object`
|
||||
- **description:** Parametized arguments for the driver call. For example, `puter-chat-completion`'s `complete` method supports the arguments `messages` and `temperature` (and others), so you might set this to `{ "messages": [...], "temperature": 1.2 }`
|
||||
|
||||
#### Example
|
||||
```json
|
||||
{
|
||||
"interface": "<name of interface>",
|
||||
"service": "<name of service>",
|
||||
"method": "<name of method>",
|
||||
"args": { "parametized": "arguments" }
|
||||
}
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
- **Error Response** - Driver error responses will always have **status 200**, content type `application/json`, and a response body in this format:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "string identifier for the error",
|
||||
"message": "some message about the error",
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Success Response** - The success response is either a JSON response
|
||||
wrapped in `{ "success": true, "result": ___ }`, or a response with a
|
||||
`Content-Type` that is **not** `application/json`.
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {}
|
||||
}
|
||||
```
|
||||
@@ -1,219 +0,0 @@
|
||||
# Group Endpoints
|
||||
|
||||
## POST `/group/create` (auth required)
|
||||
|
||||
### Description
|
||||
|
||||
Creates a group and returns a UID (UUID formatted).
|
||||
Groups do not have names, or any other descriptive attributes.
|
||||
Instead they are always identified with a UUID, and they have
|
||||
a `metadata` property.
|
||||
|
||||
The `metadata` property will always be given back to the client
|
||||
in the same way it was provided. The `extra` property, also an
|
||||
object, may be changed by the backend. The behavior of setting
|
||||
any property on `extra` is currently undefined as all properties
|
||||
are reserved for future use.
|
||||
|
||||
### Parameters
|
||||
|
||||
- **metadata:** _- optional_
|
||||
- **accepts:** `object`
|
||||
- **description:** arbitrary metadata to describe the group
|
||||
- **extra:** _- optional_
|
||||
- **accepts:** `object`
|
||||
- **description:** extra parameters (server may change these)
|
||||
|
||||
### Request Example
|
||||
|
||||
```javascript
|
||||
await fetch(`${window.api_origin}/group/create`, {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
metadata: { title: 'Some Title' }
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
|
||||
// { uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6' }
|
||||
```
|
||||
|
||||
### Response Example
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": "9c644a1c-3e43-4df4-ab67-de5b68b235b6"
|
||||
}
|
||||
```
|
||||
|
||||
## POST `/group/add-users`
|
||||
|
||||
### Description
|
||||
|
||||
Adds one or more users to a group
|
||||
|
||||
### Parameters
|
||||
|
||||
- **uid:** _- required_
|
||||
- **accepts:** `string`
|
||||
UUID of an existing group
|
||||
- **users:** `Array<string>`
|
||||
usernames of users to add to the group
|
||||
|
||||
### Request Example
|
||||
|
||||
```javascript
|
||||
await fetch(`${window.api_origin}/group/add-users`, {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6',
|
||||
users: ['first_user', 'second_user'],
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
## POST `/group/remove-users`
|
||||
|
||||
### Description
|
||||
|
||||
Remove one or more users from a group
|
||||
|
||||
### Parameters
|
||||
|
||||
- **uid:** _- required_
|
||||
- **accepts:** `string`
|
||||
UUID of an existing group
|
||||
- **users:** `Array<string>`
|
||||
usernames of users to remove from the group
|
||||
|
||||
### Request Example
|
||||
|
||||
```javascript
|
||||
await fetch(`${window.api_origin}/group/add-users`, {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6',
|
||||
users: ['first_user', 'second_user'],
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
## GET `/group/list`
|
||||
|
||||
### Description
|
||||
|
||||
List groups associated with the current user
|
||||
|
||||
### Parameters
|
||||
|
||||
_none_
|
||||
|
||||
### Response Example
|
||||
|
||||
```json
|
||||
{
|
||||
"owned_groups": [
|
||||
{
|
||||
"uid": "c3bd4047-fc65-4da8-9363-e52195890de4",
|
||||
"metadata": {},
|
||||
"members": [
|
||||
"default_user"
|
||||
]
|
||||
}
|
||||
],
|
||||
"in_groups": [
|
||||
{
|
||||
"uid": "c3bd4047-fc65-4da8-9363-e52195890de4",
|
||||
"metadata": {},
|
||||
"members": [
|
||||
"default_user"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
# Group Permission Endpoints
|
||||
|
||||
## POST `/grant-user-group`
|
||||
|
||||
Grant permission from the current user to a group.
|
||||
This creates an association between the user and the
|
||||
group for this permission; the group will only have
|
||||
the permission effectively while the user who granted
|
||||
permission has the permission.
|
||||
|
||||
### Parameters
|
||||
|
||||
- **group_uid:** _- required_
|
||||
- **accepts:** `string`
|
||||
UUID of an existing group
|
||||
- **permission:** _- required_
|
||||
- **accepts:** `string`
|
||||
A permission string
|
||||
|
||||
### Request Example
|
||||
|
||||
```javascript
|
||||
await fetch("http://puter.localhost:4100/auth/grant-user-group", {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
group_uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6',
|
||||
permission: 'fs:/someuser/somedir/somefile:read'
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
## POST `/revoke-user-group`
|
||||
|
||||
Revoke permission granted from the current user
|
||||
to a group.
|
||||
|
||||
### Parameters
|
||||
|
||||
- **group_uid:** _- required_
|
||||
- **accepts:** `string`
|
||||
UUID of an existing group
|
||||
- **permission:** _- required_
|
||||
- **accepts:** `string`
|
||||
A permission string
|
||||
|
||||
### Request Example
|
||||
|
||||
```javascript
|
||||
await fetch("http://puter.localhost:4100/auth/grant-user-group", {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
group_uid: '9c644a1c-3e43-4df4-ab67-de5b68b235b6',
|
||||
permission: 'fs:/someuser/somedir/somefile:read'
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
- > **TODO** figure out how to manage documentation that could
|
||||
reasonably show up in two files. For example: this is a group
|
||||
endpoint as well as a permission system endpoint.
|
||||
(architecturally it's a permission system endpoint, and
|
||||
the permissions feature depends on the groups feature;
|
||||
at least until a time when PermissionService is refactored
|
||||
so a service like GroupService can mutate the permission
|
||||
check sequences)
|
||||
@@ -1,112 +0,0 @@
|
||||
# Notification Endpoints
|
||||
|
||||
Endpoints for managing notifications.
|
||||
|
||||
## POST `/notif/mark-ack` (auth required)
|
||||
|
||||
### Description
|
||||
|
||||
The `/notif/mark-ack` endpoint marks the specified notification
|
||||
as "acknowledged". This indicates that the user has chosen to either
|
||||
dismiss or act on this notification.
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | Description | Default Value |
|
||||
| ---- | ----------- | -------- |
|
||||
| uid | UUID associated with the notification | **required** |
|
||||
|
||||
### Response
|
||||
|
||||
This endpoint responds with an empty object (`{}`).
|
||||
|
||||
|
||||
## POST `/notif/mark-read` (auth required)
|
||||
|
||||
### Description
|
||||
|
||||
The `/notif/mark-read` endpoint marks that the specified notification
|
||||
has been shown to the user. It will not "pop up" as a new notification
|
||||
if they load the gui again.
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | Description | Default Value |
|
||||
| ---- | ----------- | -------- |
|
||||
| uid | UUID associated with the notification | **required** |
|
||||
|
||||
### Response
|
||||
|
||||
This endpoint responds with an empty object (`{}`).
|
||||
|
||||
### Request Example
|
||||
|
||||
```javascript
|
||||
await fetch("https://api.puter.local/notif/mark-read", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uid: 'a14ea3d5-828b-42f9-9613-35f43b0a3cb8',
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
```
|
||||
## ENTITY STORAGE `puter-notifications`
|
||||
|
||||
The `puter-notifications` driver is an Entity Storage driver.
|
||||
It is read-only.
|
||||
|
||||
### Request Examples
|
||||
|
||||
#### Select Unread Notifications
|
||||
|
||||
```javascript
|
||||
await fetch("http://api.puter.localhost:4100/drivers/call", {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
interface: 'puter-notifications',
|
||||
method: 'select',
|
||||
args: { predicate: ['unread'] }
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
#### Select First 200 Notifications
|
||||
|
||||
```javascript
|
||||
await fetch("http://api.puter.localhost:4100/drivers/call", {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
interface: 'puter-notifications',
|
||||
method: 'select',
|
||||
args: {}
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
#### Select Next 200 Notifications
|
||||
|
||||
```javascript
|
||||
await fetch("http://api.puter.localhost:4100/drivers/call", {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
interface: 'puter-notifications',
|
||||
method: 'select',
|
||||
args: { offset: 200 }
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
@@ -1,367 +0,0 @@
|
||||
# Share Endpoints
|
||||
|
||||
Share endpoints allow sharing files with other users.
|
||||
|
||||
## POST `/share` (auth required)
|
||||
|
||||
### Description
|
||||
|
||||
The `/share` endpoint shares 1 or more filesystem items
|
||||
with one or more recipients. The recipients will receive
|
||||
some notification about the shared item, making this
|
||||
different from calling `/grant-user-user` with a permission.
|
||||
|
||||
When users are **specified by email** they will receive
|
||||
a [share link](./concepts/share-link.md).
|
||||
|
||||
Each item specified in the `shares` property is a tag-typed
|
||||
object of type `fs-share` or `app-share`.
|
||||
|
||||
#### File Shares (`fs-share`)
|
||||
|
||||
File shares grant permission to a file or directory. By default
|
||||
this is read permission. If `access` is specified as `"write"`,
|
||||
then write permission will be granted.
|
||||
|
||||
#### App Shares (`app-share`)
|
||||
|
||||
App shares grant permission to read a protected app.
|
||||
|
||||
##### subdomain permission
|
||||
If there is a subdomain associated with the app, and the owner
|
||||
of the subdomain is the same as the owner of the app, then
|
||||
permission to access the subdomain will be granted.
|
||||
Note that the subdomain is only associated if the subdomain
|
||||
entry has `associated_app_id` set according to the app's id,
|
||||
and will not be considered "associated" if only the index_url
|
||||
happens to match the subdomain url.
|
||||
|
||||
##### appdata permission
|
||||
If the app has `shared_appdata` set to `true` in its metadata
|
||||
object, the recipient of the share will also get write permission
|
||||
to the app owner's corresponding appdata directory. The appdata
|
||||
directory must exist for this to work as expected
|
||||
(otherwise the permission rewrite rule fails since the uuid
|
||||
can't be determined).
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"recipients": [
|
||||
"user_that_gets_shared_to",
|
||||
"[email protected]"
|
||||
],
|
||||
"shares": [
|
||||
{
|
||||
"$": "app-share",
|
||||
"name": "some-app-name"
|
||||
},
|
||||
{
|
||||
"$": "app-share",
|
||||
"uid": "app-SOME-APP-UID"
|
||||
},
|
||||
{
|
||||
"$": "fs-share",
|
||||
"path": "/some/file/or/directory"
|
||||
},
|
||||
{
|
||||
"$": "fs-share",
|
||||
"path": "SOME-FILE-UUID"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **recipients** _- required_
|
||||
- **accepts:** `string | Array<string>`
|
||||
- **description:**
|
||||
recipients for the filesystem entries being shared.
|
||||
- **notes:**
|
||||
- validation on `string`: email or username
|
||||
- requirement of at least one value
|
||||
- **shares:** _- required_
|
||||
- **accepts:** `object | Array<object>`
|
||||
- object is [type-tagged](./type-tagged.md)
|
||||
- type is either [file-share](./types/file-share.md)
|
||||
or [app-share](./types/app-share.md)
|
||||
- **notes:**
|
||||
- requirement that file/directory or app exists
|
||||
- requirement of at least one entry
|
||||
- **dry_run:** _- optional_
|
||||
- **accepts:** `bool`
|
||||
- **description:**
|
||||
when true, only validation will occur
|
||||
|
||||
### Response
|
||||
|
||||
- **$:** `api:share`
|
||||
- **$version:** `v0.0.0`
|
||||
- **status:** one of: `"success"`, `"mixed"`, `"aborted"`
|
||||
- **recipients:** array of: `api:status-report` or
|
||||
`heyputer:api/APIError`
|
||||
- **paths:** array of: `api:status-report` or
|
||||
`heyputer:api/APIError`
|
||||
- **dry_run:** `true` if present
|
||||
|
||||
### Request Example
|
||||
|
||||
```javascript
|
||||
await fetch("http://puter.localhost:4100/share", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipients: [
|
||||
"user_that_gets_shared_to",
|
||||
"[email protected]"
|
||||
],
|
||||
shares: [
|
||||
{
|
||||
$: "app-share",
|
||||
name: "some-app-name"
|
||||
},
|
||||
{
|
||||
$: "app-share",
|
||||
uid: "app-SOME-APP-UID"
|
||||
},
|
||||
{
|
||||
$: "fs-share",
|
||||
path: "/some/file/or/directory"
|
||||
},
|
||||
{
|
||||
$: "fs-share",
|
||||
path: "SOME-FILE-UUID"
|
||||
}
|
||||
]
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
```
|
||||
|
||||
### Success Response
|
||||
|
||||
```json
|
||||
{
|
||||
"$": "api:share",
|
||||
"$version": "v0.0.0",
|
||||
"status": "success",
|
||||
"recipients": [
|
||||
{
|
||||
"$": "api:status-report",
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"$": "api:status-report",
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"dry_run": true
|
||||
}
|
||||
```
|
||||
|
||||
### Error response (missing file)
|
||||
|
||||
```json
|
||||
{
|
||||
"$": "api:share",
|
||||
"$version": "v0.0.0",
|
||||
"status": "mixed",
|
||||
"recipients": [
|
||||
{
|
||||
"$": "api:status-report",
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"$": "heyputer:api/APIError",
|
||||
"code": "subject_does_not_exist",
|
||||
"message": "File or directory not found.",
|
||||
"status": 404
|
||||
}
|
||||
],
|
||||
"dry_run": true
|
||||
}
|
||||
```
|
||||
|
||||
### Error response (missing user)
|
||||
|
||||
```json
|
||||
{
|
||||
"$": "api:share",
|
||||
"$version": "v0.0.0",
|
||||
"status": "mixed",
|
||||
"recipients": [
|
||||
{
|
||||
"$": "heyputer:api/APIError",
|
||||
"code": "user_does_not_exist",
|
||||
"message": "The user `non_existing_user` does not exist.",
|
||||
"username": "non_existing_user",
|
||||
"status": 422
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"$": "api:status-report",
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"dry_run": true
|
||||
}
|
||||
```
|
||||
|
||||
## POST `/sharelink/check` (no auth)
|
||||
|
||||
### Description
|
||||
|
||||
The `/sharelink/check` endpoint verifies that a token provided
|
||||
by a share link is valid.
|
||||
|
||||
### Example
|
||||
|
||||
```javascript
|
||||
await fetch(`${config.api_origin}/sharelink/check`, {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
token: '...',
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **token:** _- required_
|
||||
- **accepts:** `string`
|
||||
The token from the querystring parameter
|
||||
|
||||
### Response
|
||||
|
||||
A type-tagged object, either of type `api:share` or `api:error`
|
||||
|
||||
### Success Response
|
||||
|
||||
```json
|
||||
{
|
||||
"$": "api:share",
|
||||
"uid": "836671d4-ac5d-4bd3-bc0a-ec357e0d8f02",
|
||||
"email": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"$": "api:error",
|
||||
"message":"Field `token` is required.",
|
||||
"key":"token",
|
||||
"code":"field_missing"
|
||||
}
|
||||
```
|
||||
|
||||
## POST `/sharelink/apply` (no auth)
|
||||
|
||||
### Description
|
||||
|
||||
The `/sharelink/apply` endpoint applies a share to the current
|
||||
user **if and only if** that user's email is confirmed and matches
|
||||
the email associated with the share.
|
||||
|
||||
### Example
|
||||
|
||||
```javascript
|
||||
await fetch(`${config.api_origin}/sharelink/apply`, {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
uid: '836671d4-ac5d-4bd3-bc0a-ec357e0d8f02',
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **uid:** _- required_
|
||||
- **accepts:** `string`
|
||||
The uid of an existing share, received using `/sharelink/check`
|
||||
|
||||
### Response
|
||||
|
||||
A type-tagged object, either of type `api:status-report` or `api:error`
|
||||
|
||||
### Success Response
|
||||
|
||||
```json
|
||||
{"$":"api:status-report","status":"success"}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "This share can not be applied to this user.",
|
||||
"code": "can_not_apply_to_this_user"
|
||||
}
|
||||
```
|
||||
|
||||
## POST `/sharelink/request` (no auth)
|
||||
|
||||
### Description
|
||||
|
||||
The `/sharelink/request` endpoint requests the permissions associated
|
||||
with a share link to the issuer of the share (user that sent the share).
|
||||
This can be used when a user is logged in, but that user's email does
|
||||
not match the email associated with the share.
|
||||
|
||||
### Example
|
||||
|
||||
```javascript
|
||||
await fetch(`${config.api_origin}/sharelink/request`, {
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${puter.authToken}`,
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
uid: '836671d4-ac5d-4bd3-bc0a-ec357e0d8f02',
|
||||
}),
|
||||
"method": "POST",
|
||||
});
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **uid:** _- required_
|
||||
- **accepts:** `string`
|
||||
The uid of an existing share, received using `/sharelink/check`
|
||||
|
||||
### Response
|
||||
|
||||
A type-tagged object, either of type `api:status-report` or `api:error`
|
||||
|
||||
### Success Response
|
||||
|
||||
```json
|
||||
{"$":"api:status-report","status":"success"}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "This share is already valid for this user; POST to /apply for access",
|
||||
"code": "no_need_to_request"
|
||||
}
|
||||
```
|
||||
@@ -1,79 +0,0 @@
|
||||
# Type-Tagged Objects
|
||||
|
||||
```js
|
||||
{
|
||||
"$": "some-type",
|
||||
"$version": "0.0.0",
|
||||
|
||||
"some_property": "some value",
|
||||
}
|
||||
```
|
||||
|
||||
## What's a Type-Tagged Object?
|
||||
|
||||
Type-Tagged objects are a convention understood by Puter's backend
|
||||
to communicate meta information along with a JSON object.
|
||||
The key feature of Type-Tagged Objects is the type key: `"$"`.
|
||||
|
||||
## Why Type-Tagged Objects?
|
||||
|
||||
The primary reason: to have a consistent convention we can use
|
||||
anywhere.
|
||||
|
||||
- Since other services rarely use `$` in their property names,
|
||||
we can safely use this without introducing reserved words and
|
||||
re-mapping property names.
|
||||
- Some places we use this convention might not need it, but
|
||||
staying consistent means API end-users can
|
||||
[do more with less code](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself).
|
||||
|
||||
## Specification
|
||||
|
||||
- The `"$"` key indicates a type (or class) of object
|
||||
- Any other key beginning with `$` is a **meta-key**
|
||||
- Other keys are not allowed to contain `$`
|
||||
- `"$version"` must follow [semver](https://semver.org/)
|
||||
- Keys with multiple `"$"` symbols are reserved for future use
|
||||
|
||||
## Alternative Representations
|
||||
|
||||
Puter's API will always send results in the format described
|
||||
above, which is called the "Standard Representation"
|
||||
|
||||
Any endpoint which accepts a Type-Tagged Object will also
|
||||
accept these alternative representations:
|
||||
|
||||
### Structured Representation
|
||||
|
||||
Depending on the architecture of your client, this format
|
||||
may be more convenient to work with:
|
||||
```json
|
||||
{
|
||||
"$": "$meta-body",
|
||||
"type": "some-type",
|
||||
"meta": { "version": "0.0.0" },
|
||||
"body": { "some_property": "some value" }
|
||||
}
|
||||
```
|
||||
|
||||
### Array Representation
|
||||
|
||||
In the array representation, meta values go at the end.
|
||||
```json
|
||||
["some-type",
|
||||
{ "some_property": "some value" },
|
||||
{ "version": "0.0.0" }
|
||||
]
|
||||
```
|
||||
|
||||
If the second element of the list is not an object, it
|
||||
will implicitly be placed in a property called value.
|
||||
The following are equivalent:
|
||||
|
||||
```json
|
||||
["some-type", "hello"]
|
||||
```
|
||||
|
||||
```json
|
||||
["some-type", { "value": "hello" }]
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
# Backend Architecture
|
||||
|
||||
Loosely inspired by the Controller–Service–Repository pattern with dependency injection. The backend is organized as a stack of layers where each layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](../src/backend/server.ts)) instantiates each layer in order and hands the instances down to the next.
|
||||
|
||||
## Layers
|
||||
|
||||
```mermaid
|
||||
block-beta
|
||||
columns 1
|
||||
REQ["HTTP request"]
|
||||
CTRL["Controllers — route handlers, gates, I/O shaping"]
|
||||
DRV["Drivers (optional) — RPC handlers on /drivers/*"]
|
||||
SVC["Services — business logic, no auth"]
|
||||
STR["Stores — persistence / domain shapes"]
|
||||
CLI["Clients — sql, redis, s3, dynamo, email, …"]
|
||||
CFG["Config — IConfig"]
|
||||
|
||||
REQ --> CTRL
|
||||
CTRL --> DRV
|
||||
DRV --> SVC
|
||||
SVC --> STR
|
||||
STR --> CLI
|
||||
CLI --> CFG
|
||||
|
||||
style REQ fill:#0ea5e9,stroke:#0369a1,color:#fff
|
||||
style CTRL fill:#1d4ed8,stroke:#1e3a8a,color:#fff
|
||||
style DRV fill:#2563eb,stroke:#1e40af,color:#fff
|
||||
style SVC fill:#4f46e5,stroke:#3730a3,color:#fff
|
||||
style STR fill:#7c3aed,stroke:#5b21b6,color:#fff
|
||||
style CLI fill:#9333ea,stroke:#6b21a8,color:#fff
|
||||
style CFG fill:#334155,stroke:#1e293b,color:#fff
|
||||
```
|
||||
|
||||
Each layer only depends on the layers beneath it, and every dependency is injected through the constructor by `PuterServer`. Extensions sit alongside this stack and can register into any layer — see [Extensions](#extensions) below.
|
||||
|
||||
| Layer | Lives in | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| **Controllers** | [src/backend/controllers/](../src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers — see `RouteOptions`), call into services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](../src/backend/drivers/) | Optional. RPC-style handlers exposed over the `/drivers/*` surface (`puter-kvstore`, `puter-chat-completion`, …). A driver is a thin shell that validates RPC inputs and calls into services/stores; controllers can hold a typed reference to drivers when they need the same logic over HTTP. |
|
||||
| **Services** | [src/backend/services/](../src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
|
||||
| **Stores** | [src/backend/stores/](../src/backend/stores/) | Persistence and storage logic. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
|
||||
| **Clients** | [src/backend/clients/](../src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamodb, email, event bus, …). Knows protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | The flat, typed config object every layer receives at construction. |
|
||||
|
||||
Each layer receives the layers beneath it through its constructor, so dependencies are explicit and traceable from `PuterServer`. A controller does not reach into a client directly; if it needs one, the right move is usually a service.
|
||||
|
||||
## Entry point: `PuterServer`
|
||||
|
||||
`PuterServer` is the bootstrap. It:
|
||||
|
||||
1. Loads any configured extension directories (`config.extensions`) so extensions can register before instantiation begins.
|
||||
2. Instantiates each layer in order — clients → stores → services → drivers → controllers — merging in anything extensions have registered for that layer.
|
||||
3. Wires global middleware, mounts controller routes through `PuterRouter` (which translates `RouteOptions` into the gate/parser middleware chain), and mounts extension routes through the same materializer.
|
||||
4. Fires `onServerStart` hooks across every layer once HTTP is listening, and `onServerPrepareShutdown` / `onServerShutdown` on the way down.
|
||||
|
||||
## Context (ALS)
|
||||
|
||||
We use [`Context`](../src/backend/core/context.ts) — backed by `AsyncLocalStorage` — to carry per-request state without threading it through every function signature. It is used **sparingly**, mostly for `actor` and `req`. The request-context middleware opens a scope per request after the auth probe runs; anything inside a request handler can call `Context.get('actor')` / `Context.get('req')` instead of plumbing it as an argument.
|
||||
|
||||
Prefer explicit arguments. Reach for `Context` only when the value is truly request-scoped and would otherwise need to thread through many layers.
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions live alongside core ([packages/puter/extensions/](../extensions/)) and parallel the layered stack. They are meant for **non-crucial parts of the system** — things Puter still works without if removed.
|
||||
|
||||
- **Good extensions**: [thumbnails](../extensions/thumbnails.ts), [serverInfo](../extensions/serverInfo.ts), [devWatcher](../extensions/devWatcher.ts) — opt-in features cleanly bolted on.
|
||||
- **Should probably be core**: [metering](../extensions/metering.ts), [appTelemetry](../extensions/appTelemetry.ts) — clients now expect these to be present, so the "extension" framing is misleading.
|
||||
- **Shouldn't have been an extension**: [whoami](../extensions/whoami.ts) — it's load-bearing for every authenticated client. Keep this one in mind as a cautionary example when deciding whether something belongs in an extension.
|
||||
|
||||
### Extension API
|
||||
|
||||
The `extension` global ([src/backend/extensions.ts](../src/backend/extensions.ts)) exposes:
|
||||
|
||||
- **Layer registration** for first-class additions:
|
||||
- `extension.registerClient(name, ClientClass)`
|
||||
- `extension.registerStore(name, StoreClass)`
|
||||
- `extension.registerService(name, ServiceClass)`
|
||||
- `extension.registerDriver(name, DriverClass)`
|
||||
- `extension.registerController(name, ControllerClass)`
|
||||
- **Lightweight wrappers** for the common case where a full class isn't worth it:
|
||||
- `extension.on(event, handler)` — subscribe to event-bus events.
|
||||
- `extension.get(path, opts?, handler)` / `.post` / `.put` / `.delete` / `.patch` / `.head` / `.options` / `.all` / `.use` — register routes. The `opts` shape is the same `RouteOptions` controllers use, so `subdomain`, `requireAuth`, `adminOnly`, body parsers, etc. all work identically.
|
||||
- **Cross-layer access**: `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects, and `extension.config` exposes the live config.
|
||||
|
||||
```ts
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
|
||||
const services = extension.import('service');
|
||||
|
||||
extension.get('/healthcheck/deep', { subdomain: 'api', adminOnly: true }, async (_req, res) => {
|
||||
res.json({ ok: await services.health.runDeepCheck() });
|
||||
});
|
||||
|
||||
extension.on('user.signup', (_key, data) => {
|
||||
console.log('new user', data.user.username);
|
||||
});
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript preferred** in new code where feasible. Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **`camelCase`** for variable/function names; **`PascalCase`** for classes and for files that contain a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
- **Deduplicate**. If two services need the same logic, lift it into a util/helper rather than calling sideways across the same layer — services should not depend on other services for code reuse.
|
||||
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you find yourself wanting to, that's usually a signal the abstraction is wrong.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Comment Prefixes
|
||||
|
||||
Comments have prefixes using
|
||||
[Conventional: Comments](https://conventionalcomments.org/)
|
||||
as a **loose** guideline, and using this markdown file as a
|
||||
the actual guideline.
|
||||
|
||||
This document will be updated on an _as-needed_ basis.
|
||||
|
||||
## The rules
|
||||
|
||||
- A comment line always looks like this:
|
||||
- A whitespace character
|
||||
- Optional prefix matching `/[a-z-]+\([a-z-]a+\):/`
|
||||
- A whitespace character
|
||||
- The comment
|
||||
- Formalized prefixes must follow the rules below
|
||||
- Any other prefix can be used. After some uses it
|
||||
might be good to formalize it, but that's not a hard rule.
|
||||
|
||||
## Formalized prefixes
|
||||
|
||||
- `todo:` is interchangable with the famous `TODO:`, **except:**
|
||||
when lowercase (`todo:`) it can include a scope: `todo(security):`.
|
||||
- `track:` is used to track common patterns.
|
||||
- Anything written after `track:` must be registered in
|
||||
[track-comments.md](../devmeta/track-comments.md)
|
||||
- `wet:` is usesd to track anything that doesn't adhere
|
||||
to the DRY principle; the following message should describe
|
||||
where similar code is
|
||||
- `compare(<identifier>):` is used to note differences between other
|
||||
implementations of a similar idea
|
||||
- `name:` pedantic commentary on the name of something
|
||||
@@ -1,105 +0,0 @@
|
||||
# Local Email Testing
|
||||
|
||||
This guide describes how to set up and use [MailHog](https://github.com/mailhog/MailHog) for local email testing in Puter development. MailHog provides a local email server that captures outgoing emails for testing purposes without actually sending them to real recipients.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Configure Puter
|
||||
|
||||
Add the following configuration to your `volatile/config/config.json` file:
|
||||
|
||||
```json
|
||||
"email": {
|
||||
"host": "localhost",
|
||||
"port": 1025
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Install MailHog
|
||||
|
||||
Download and run MailHog on your local machine:
|
||||
|
||||
```bash
|
||||
# Install MailHog
|
||||
wget https://github.com/mailhog/MailHog/releases/download/v1.0.1/MailHog_linux_amd64
|
||||
chmod +x MailHog_linux_amd64
|
||||
./MailHog_linux_amd64
|
||||
```
|
||||
|
||||
### 3. Install Nodemailer
|
||||
|
||||
Install Nodemailer to send test emails to the SMTP server:
|
||||
|
||||
```bash
|
||||
npm install nodemailer
|
||||
```
|
||||
|
||||
## Using MailHog
|
||||
|
||||
### Access Web Interface
|
||||
|
||||
Once MailHog is running, access the web interface at:
|
||||
[http://127.0.0.1:8025/](http://127.0.0.1:8025/)
|
||||
|
||||
All captured emails and their recipients will be displayed in this interface.
|
||||
|
||||
### Testing Your MailHog Setup with Nodemailer
|
||||
|
||||
You can verify that your MailHog instance is working correctly by creating a simple test script using Nodemailer. This allows you to send test emails that will be captured by MailHog without actually delivering them to real recipients.
|
||||
|
||||
Here's a sample script you can use to test your MailHog setup:
|
||||
|
||||
```javascript
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
// Configure transporter to use MailHog
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: "localhost", // MailHog SMTP server address
|
||||
port: 1025, // Default MailHog SMTP port
|
||||
secure: false // No SSL/TLS required for MailHog
|
||||
});
|
||||
|
||||
// Define a test email
|
||||
const mailOptions = {
|
||||
from: "[email protected]",
|
||||
to: "[email protected]",
|
||||
subject: "Hello from Nodemailer!",
|
||||
text: "This is a test email sent using Nodemailer."
|
||||
};
|
||||
|
||||
// Send the test email
|
||||
transporter.sendMail(mailOptions)
|
||||
.then(info => console.log("Email sent:", info.response))
|
||||
.catch(error => console.error("Error:", error));
|
||||
```
|
||||
|
||||
After sending an email with this script, you can view it in the MailHog web interface:
|
||||
|
||||
### How Puter Uses Nodemailer
|
||||
|
||||
Puter itself uses Nodemailer for sending emails through its `EmailService` class located in `/src/backend/src/services/EmailService.js`. This service handles various email templates for:
|
||||
|
||||
- Account verification
|
||||
- Password recovery
|
||||
- Two-factor authentication notifications
|
||||
- File sharing notifications
|
||||
- App approval notifications
|
||||
- And more
|
||||
|
||||
The service creates a Nodemailer transport using the configuration from your `config.json` file, which is why setting up MailHog correctly is important for testing Puter's email functionality during development.
|
||||
|
||||
<img src="image.png" alt="Email in MailHog interface" width="300" height="200">
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues with MailHog:
|
||||
|
||||
1. Check if MailHog is running:
|
||||
```bash
|
||||
ps aux | grep MailHog
|
||||
```
|
||||
|
||||
2. Ensure the correct port configurations in both MailHog and your application.
|
||||
|
||||
3. Check for any error messages in the MailHog console output.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Puter Extensions
|
||||
|
||||
## Quickstart
|
||||
|
||||
Create and edit this file: `mods/mods_enabled/hello-puter.js`
|
||||
|
||||
```javascript
|
||||
const { UserActorType, AppUnderUserActorType } = use.core;
|
||||
|
||||
extension.get('/hello-puter', (req, res) => {
|
||||
const actor = req.actor;
|
||||
let who = 'unknown';
|
||||
if ( actor.type instanceof UserActorType ) {
|
||||
who = actor.type.user.username;
|
||||
}
|
||||
if ( actor.type instanceof AppUnderUserActorType ) {
|
||||
who = actor.type.app.name + ' on behalf of ' + actor.type.user.username;
|
||||
}
|
||||
res.send(`Hello, ${who}!`);
|
||||
});
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
//
|
||||
|
||||
This is subject to change as we make efforts to simplify the process.
|
||||
|
||||
### Step 1: Configure a Mod Directory
|
||||
|
||||
Add this to your config:
|
||||
```json
|
||||
"mod_directories": [
|
||||
"{source}/../mods/mods_available"
|
||||
]
|
||||
```
|
||||
|
||||
This adds the `mods/mods_available` directory to this
|
||||
@@ -1,89 +0,0 @@
|
||||
# Puter Extensions
|
||||
|
||||
## Quickstart
|
||||
|
||||
Create and edit this file: `mods/mods_enabled/hello-puter.js`
|
||||
|
||||
```javascript
|
||||
// You can get definitions exposed by Puter via `use`
|
||||
const { UserActorType, AppUnderUserActorType } = use.core;
|
||||
|
||||
// Endpoints can be registered directly on an extension
|
||||
extension.get('/hello-puter', (req, res) => {
|
||||
const actor = req.actor;
|
||||
|
||||
|
||||
// Make a string "who" which says:
|
||||
// "<username>", or:
|
||||
// "<app> acting on behalf of <username>"
|
||||
let who = 'unknown';
|
||||
if ( actor.type instanceof UserActorType ) {
|
||||
who = actor.type.user.username;
|
||||
}
|
||||
if ( actor.type instanceof AppUnderUserActorType ) {
|
||||
who = actor.type.app.name
|
||||
+ ' on behalf of '
|
||||
+ actor.type.user.username;
|
||||
}
|
||||
|
||||
res.send(`Hello, ${who}!`);
|
||||
});
|
||||
|
||||
// Extensions can listen to events and manipulate Puter's behavior
|
||||
extension.on('core.email.validate', event => {
|
||||
if ( event.email.includes('evil') ) {
|
||||
event.allow = false;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Scope of `extension` and `use`
|
||||
|
||||
It is important to know that the `extension` global is temporary and does not
|
||||
exist after your extension is loaded. If you wish to access the extension
|
||||
object within a callback you will need to first bind it to a variable in
|
||||
your extension's scope.
|
||||
|
||||
```javascript
|
||||
const ext = extension;
|
||||
extension.on('some-event', () => {
|
||||
// This would throw an error
|
||||
// extension.something();
|
||||
|
||||
// This works
|
||||
ext.example();
|
||||
})
|
||||
```
|
||||
|
||||
The same is true for `use`. Calls to `use` should happen at the top of
|
||||
the file, just like imports in ES6.
|
||||
|
||||
## Database Access
|
||||
|
||||
A database access object is provided to the extension via `extension.db`.
|
||||
You **must** scope `extension` to another variable (`ext` in this example)
|
||||
in order to access `db` from callbacks.
|
||||
|
||||
```javascript
|
||||
const ext = extension;
|
||||
|
||||
extension.get('/user-count', { noauth: true, mw: [] }, (req, res) => {
|
||||
const [count] = await ext.db.read(
|
||||
'SELECT COUNT(*) as c FROM `user`'
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
The database access object has the following methods:
|
||||
- `read(query, params)` - read from the database using a prepared statement. If read-replicas are enabled, this will use a replica.
|
||||
- `write(query, params)` - write to the database using a prepared statement. If read-replicas are enabled, this will write to the primary.
|
||||
- `pread(query, params)` - read from the database using a prepared statement. If read-replicas are enabled, this will read from the primary.
|
||||
- `requireRead(query, params)` - read from the database using a prepared statement. If read-replicas are enabled, this will try reading from the replica first. If there are no results, a second attempt will be made on the primary.
|
||||
|
||||
## Events
|
||||
|
||||
See [events.md](./events.md)
|
||||
|
||||
## Definitions
|
||||
|
||||
See [definitions.md](./definitions.md)
|
||||
@@ -1,14 +0,0 @@
|
||||
## Definitions
|
||||
|
||||
### `core.config` - Configuration
|
||||
|
||||
Puter's configuration object. This includes values from `config.json` or their
|
||||
defaults, and computed values like `origin` and `api_origin`.
|
||||
|
||||
```javascript
|
||||
const config = use('core.config');
|
||||
|
||||
extension.get('/get-origin', { noauth: true }, (req, res) => {
|
||||
res.send(config.origin);
|
||||
})
|
||||
```
|
||||
@@ -1,816 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
properties: {
|
||||
completionId: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'completionId',
|
||||
notes: [],
|
||||
},
|
||||
allow: {
|
||||
type: 'boolean',
|
||||
mutability: 'mutable',
|
||||
summary: 'whether the operation is allowed',
|
||||
notes: [],
|
||||
},
|
||||
intended_service: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'intended service',
|
||||
notes: [],
|
||||
},
|
||||
parameters: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'parameters',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ai.prompt.complete',
|
||||
description: `
|
||||
This event is emitted for ai prompt complete operations.
|
||||
`,
|
||||
properties: {
|
||||
intended_service: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'intended service',
|
||||
notes: [],
|
||||
},
|
||||
parameters: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'parameters',
|
||||
notes: [],
|
||||
},
|
||||
result: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'result',
|
||||
notes: [],
|
||||
},
|
||||
model_used: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'model used',
|
||||
notes: [],
|
||||
},
|
||||
service_used: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'service used',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ai.prompt.cost-calculated',
|
||||
description: `
|
||||
This event is emitted for ai prompt cost calculated operations.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'ai.prompt.validate',
|
||||
description: `
|
||||
This event is emitted when a validate is being validated.
|
||||
The event can be used to block certain validates from being validated.
|
||||
`,
|
||||
properties: {
|
||||
completionId: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'completionId',
|
||||
notes: [],
|
||||
},
|
||||
allow: {
|
||||
type: 'boolean',
|
||||
mutability: 'mutable',
|
||||
summary: 'whether the operation is allowed',
|
||||
notes: [
|
||||
'If set to false, the ai will be considered invalid.',
|
||||
],
|
||||
},
|
||||
intended_service: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'intended service',
|
||||
notes: [],
|
||||
},
|
||||
parameters: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'parameters',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app.new-icon',
|
||||
description: `
|
||||
This event is emitted for app new icon operations.
|
||||
`,
|
||||
properties: {
|
||||
data_url: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'data url',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app.rename',
|
||||
description: `
|
||||
This event is emitted for app rename operations.
|
||||
`,
|
||||
properties: {
|
||||
data_url: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'data url',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apps.invalidate',
|
||||
description: `
|
||||
This event is emitted when a invalidate is being validated.
|
||||
The event can be used to block certain invalidates from being validated.
|
||||
`,
|
||||
properties: {
|
||||
apps: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'apps',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'captcha.check',
|
||||
description: `
|
||||
This event is emitted for captcha check operations.
|
||||
`,
|
||||
properties: {
|
||||
required: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'required',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.email.validate',
|
||||
description: `
|
||||
This event is emitted when an email is being validated.
|
||||
The event can be used to block certain emails from being validated.
|
||||
`,
|
||||
properties: {
|
||||
email: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'the email being validated',
|
||||
notes: [
|
||||
'The email may have already been cleaned.',
|
||||
],
|
||||
},
|
||||
allow: {
|
||||
type: 'boolean',
|
||||
mutability: 'mutable',
|
||||
summary: 'whether the email is allowed',
|
||||
notes: [
|
||||
'If set to false, the email will be considered invalid.',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.fs.create.directory',
|
||||
description: `
|
||||
This event is emitted when a directory is created.
|
||||
`,
|
||||
properties: {
|
||||
node: {
|
||||
type: 'FSNodeContext',
|
||||
mutability: 'no-effect',
|
||||
summary: 'the directory that was created',
|
||||
},
|
||||
context: {
|
||||
type: 'Context',
|
||||
mutability: 'no-effect',
|
||||
summary: 'current context',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.request.measured',
|
||||
description: `
|
||||
This event is emitted when a requests incoming and outgoing bytes
|
||||
have been measured.
|
||||
`,
|
||||
example: {
|
||||
language: 'javascript',
|
||||
code: /*javascript*/`
|
||||
extension.on('core.request.measured', data => {
|
||||
const measurements = data.measurements;
|
||||
// measurements = { sz_incoming: integer, sz_outgoing: integer }
|
||||
|
||||
const actor = data.actor; // instance of Actor
|
||||
|
||||
console.log('\x1B[36;1m === MEASUREMENT ===\x1B[0m\n', {
|
||||
actor: data.actor.uid,
|
||||
measurements: data.measurements
|
||||
});
|
||||
});
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'credit.check-available',
|
||||
description: `
|
||||
This event is emitted for credit check available operations.
|
||||
`,
|
||||
properties: {
|
||||
available: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'available',
|
||||
notes: [],
|
||||
},
|
||||
cost_uuid: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'cost uuid',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'credit.funding-update',
|
||||
description: `
|
||||
This event is emitted when a funding-update is updated.
|
||||
`,
|
||||
properties: {
|
||||
available: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'available',
|
||||
notes: [],
|
||||
},
|
||||
cost_uuid: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'cost uuid',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'credit.record-cost',
|
||||
description: `
|
||||
This event is emitted for credit record cost operations.
|
||||
`,
|
||||
properties: {
|
||||
available: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'available',
|
||||
notes: [],
|
||||
},
|
||||
cost_uuid: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'cost uuid',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'driver.create-call-context',
|
||||
description: `
|
||||
This event is emitted when a create-call-context is created.
|
||||
`,
|
||||
properties: {
|
||||
usages: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'usages',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'email.validate',
|
||||
description: `
|
||||
This event is emitted when a validate is being validated.
|
||||
The event can be used to block certain validates from being validated.
|
||||
`,
|
||||
properties: {
|
||||
allow: {
|
||||
type: 'boolean',
|
||||
mutability: 'mutable',
|
||||
summary: 'whether the operation is allowed',
|
||||
notes: [
|
||||
'If set to false, the email will be considered invalid.',
|
||||
],
|
||||
},
|
||||
email: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'email',
|
||||
notes: [
|
||||
'The email may have already been cleaned.',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fs.create.directory',
|
||||
description: `
|
||||
This event is emitted when a directory is created.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'fs.create.file',
|
||||
description: `
|
||||
This event is emitted when a file is created.
|
||||
`,
|
||||
properties: {
|
||||
context: {
|
||||
type: 'Context',
|
||||
mutability: 'no-effect',
|
||||
summary: 'current context',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fs.create.shortcut',
|
||||
description: `
|
||||
This event is emitted when a shortcut is created.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'fs.create.symlink',
|
||||
description: `
|
||||
This event is emitted when a symlink is created.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'fs.move.file',
|
||||
description: `
|
||||
This event is emitted for fs move file operations.
|
||||
`,
|
||||
properties: {
|
||||
moved: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'moved',
|
||||
notes: [],
|
||||
},
|
||||
old_path: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'path to the affected resource',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fs.pending.file',
|
||||
description: `
|
||||
This event is emitted for fs pending file operations.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'fs.storage.progress.copy',
|
||||
description: `
|
||||
This event reports progress of a copy operation.
|
||||
`,
|
||||
properties: {
|
||||
context: {
|
||||
type: 'Context',
|
||||
mutability: 'no-effect',
|
||||
summary: 'current context',
|
||||
notes: [],
|
||||
},
|
||||
meta: {
|
||||
type: 'object',
|
||||
mutability: 'no-effect',
|
||||
summary: 'additional metadata for the operation',
|
||||
notes: [],
|
||||
},
|
||||
item_path: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'path to the affected resource',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fs.storage.upload-progress',
|
||||
description: `
|
||||
This event reports progress of a upload-progress operation.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'fs.write.file',
|
||||
description: `
|
||||
This event is emitted when a file is updated.
|
||||
`,
|
||||
properties: {
|
||||
context: {
|
||||
type: 'Context',
|
||||
mutability: 'no-effect',
|
||||
summary: 'current context',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ip.validate',
|
||||
description: `
|
||||
This event is emitted when a validate is being validated.
|
||||
The event can be used to block certain validates from being validated.
|
||||
`,
|
||||
properties: {
|
||||
res: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'res',
|
||||
notes: [],
|
||||
},
|
||||
end_: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'end ',
|
||||
notes: [],
|
||||
},
|
||||
end: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'end',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.fs.write-hash',
|
||||
description: `
|
||||
This event is emitted when a write-hash is updated.
|
||||
`,
|
||||
properties: {
|
||||
uuid: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'uuid',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.item.added',
|
||||
description: `
|
||||
This event is emitted for outer gui item added operations.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.item.moved',
|
||||
description: `
|
||||
This event is emitted for outer gui item moved operations.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.item.pending',
|
||||
description: `
|
||||
This event is emitted for outer gui item pending operations.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.item.updated',
|
||||
description: `
|
||||
This event is emitted when a updated is updated.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.notif.ack',
|
||||
description: `
|
||||
This event is emitted for outer gui notif ack operations.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.notif.message',
|
||||
description: `
|
||||
This event is emitted for outer gui notif message operations.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
notification: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'notification',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.notif.persisted',
|
||||
description: `
|
||||
This event is emitted for outer gui notif persisted operations.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.notif.unreads',
|
||||
description: `
|
||||
This event is emitted for outer gui notif unreads operations.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.submission.done',
|
||||
description: `
|
||||
This event is emitted for outer gui submission done operations.
|
||||
`,
|
||||
properties: {
|
||||
response: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'response',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outer.gui.usage.update',
|
||||
description: `
|
||||
This event is emitted when a update is updated.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'outer.thread.notify-subscribers',
|
||||
description: `
|
||||
This event is emitted for outer thread notify subscribers operations.
|
||||
`,
|
||||
properties: {
|
||||
uid: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'uid',
|
||||
notes: [],
|
||||
},
|
||||
action: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'action',
|
||||
notes: [],
|
||||
},
|
||||
data: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'data',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'puter.signup',
|
||||
description: `
|
||||
This event is emitted for puter signup operations.
|
||||
`,
|
||||
properties: {
|
||||
ip: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'ip',
|
||||
notes: [],
|
||||
},
|
||||
user_agent: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'user agent',
|
||||
notes: [],
|
||||
},
|
||||
body: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'body',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'request.measured',
|
||||
description: `
|
||||
This event is emitted for request measured operations.
|
||||
`,
|
||||
properties: {
|
||||
req: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'req',
|
||||
notes: [],
|
||||
},
|
||||
res: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'res',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'request.will-be-handled',
|
||||
description: `
|
||||
This event is emitted for request will be handled operations.
|
||||
`,
|
||||
properties: {
|
||||
res: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'res',
|
||||
notes: [],
|
||||
},
|
||||
end_: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'end ',
|
||||
notes: [],
|
||||
},
|
||||
end: {
|
||||
type: 'any',
|
||||
mutability: 'mutable',
|
||||
summary: 'end',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'template-service.hello',
|
||||
description: `
|
||||
This event is emitted for template-service hello operations.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'usages.query',
|
||||
description: `
|
||||
This event is emitted for usages query operations.
|
||||
`,
|
||||
properties: {
|
||||
usages: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'usages',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'user.email-changed',
|
||||
description: `
|
||||
This event is emitted for user email changed operations.
|
||||
`,
|
||||
properties: {
|
||||
new_email: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'new email',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'user.email-confirmed',
|
||||
description: `
|
||||
This event is emitted for user email confirmed operations.
|
||||
`,
|
||||
properties: {
|
||||
email: {
|
||||
type: 'any',
|
||||
mutability: 'no-effect',
|
||||
summary: 'email',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'user.save_account',
|
||||
description: `
|
||||
This event is emitted for user save_account operations.
|
||||
`,
|
||||
properties: {
|
||||
user: {
|
||||
type: 'User',
|
||||
mutability: 'no-effect',
|
||||
summary: 'user associated with the operation',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'web.socket.connected',
|
||||
description: `
|
||||
This event is emitted for web socket connected operations.
|
||||
`,
|
||||
properties: {
|
||||
user: {
|
||||
type: 'User',
|
||||
mutability: 'mutable',
|
||||
summary: 'user associated with the operation',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'web.socket.user-connected',
|
||||
description: `
|
||||
This event is emitted for web socket user connected operations.
|
||||
`,
|
||||
properties: {
|
||||
user: {
|
||||
type: 'User',
|
||||
mutability: 'mutable',
|
||||
summary: 'user associated with the operation',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wisp.get-policy',
|
||||
description: `
|
||||
This event is emitted for wisp get policy operations.
|
||||
`,
|
||||
properties: {
|
||||
policy: {
|
||||
type: 'Policy',
|
||||
mutability: 'mutable',
|
||||
summary: 'policy information for the operation',
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,761 +0,0 @@
|
||||
#### Property `completionId`
|
||||
|
||||
completionId
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `allow`
|
||||
|
||||
whether the operation is allowed
|
||||
- **Type**: boolean
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `intended_service`
|
||||
|
||||
intended service
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `parameters`
|
||||
|
||||
parameters
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `ai.prompt.complete`
|
||||
|
||||
This event is emitted for ai prompt complete operations.
|
||||
|
||||
#### Property `intended_service`
|
||||
|
||||
intended service
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `parameters`
|
||||
|
||||
parameters
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `result`
|
||||
|
||||
result
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `model_used`
|
||||
|
||||
model used
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `service_used`
|
||||
|
||||
service used
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `ai.prompt.cost-calculated`
|
||||
|
||||
This event is emitted for ai prompt cost calculated operations.
|
||||
|
||||
|
||||
### `ai.prompt.validate`
|
||||
|
||||
This event is emitted when a validate is being validated.
|
||||
The event can be used to block certain validates from being validated.
|
||||
|
||||
#### Property `completionId`
|
||||
|
||||
completionId
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `allow`
|
||||
|
||||
whether the operation is allowed
|
||||
- **Type**: boolean
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
- If set to false, the ai will be considered invalid.
|
||||
|
||||
#### Property `intended_service`
|
||||
|
||||
intended service
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `parameters`
|
||||
|
||||
parameters
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `app.new-icon`
|
||||
|
||||
This event is emitted for app new icon operations.
|
||||
|
||||
#### Property `data_url`
|
||||
|
||||
data url
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `app.rename`
|
||||
|
||||
This event is emitted for app rename operations.
|
||||
|
||||
#### Property `data_url`
|
||||
|
||||
data url
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `apps.invalidate`
|
||||
|
||||
This event is emitted when a invalidate is being validated.
|
||||
The event can be used to block certain invalidates from being validated.
|
||||
|
||||
#### Property `apps`
|
||||
|
||||
apps
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `captcha.check`
|
||||
|
||||
This event is emitted for captcha check operations.
|
||||
|
||||
#### Property `required`
|
||||
|
||||
required
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `core.email.validate`
|
||||
|
||||
This event is emitted when an email is being validated.
|
||||
The event can be used to block certain emails from being validated.
|
||||
|
||||
#### Property `email`
|
||||
|
||||
the email being validated
|
||||
- **Type**: string
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
- The email may have already been cleaned.
|
||||
|
||||
#### Property `allow`
|
||||
|
||||
whether the email is allowed
|
||||
- **Type**: boolean
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
- If set to false, the email will be considered invalid.
|
||||
|
||||
|
||||
### `core.fs.create.directory`
|
||||
|
||||
This event is emitted when a directory is created.
|
||||
|
||||
#### Property `node`
|
||||
|
||||
the directory that was created
|
||||
- **Type**: FSNodeContext
|
||||
- **Mutability**: no-effect
|
||||
|
||||
#### Property `context`
|
||||
|
||||
current context
|
||||
- **Type**: Context
|
||||
- **Mutability**: no-effect
|
||||
|
||||
|
||||
### `core.request.measured`
|
||||
|
||||
This event is emitted when a requests incoming and outgoing bytes
|
||||
have been measured.
|
||||
|
||||
#### Example
|
||||
|
||||
```javascript
|
||||
extension.on('core.request.measured', data => {
|
||||
const measurements = data.measurements;
|
||||
// measurements = { sz_incoming: integer, sz_outgoing: integer }
|
||||
|
||||
const actor = data.actor; // instance of Actor
|
||||
|
||||
console.log('[36;1m === MEASUREMENT ===[0m
|
||||
', {
|
||||
actor: data.actor.uid,
|
||||
measurements: data.measurements
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### `credit.check-available`
|
||||
|
||||
This event is emitted for credit check available operations.
|
||||
|
||||
#### Property `available`
|
||||
|
||||
available
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `cost_uuid`
|
||||
|
||||
cost uuid
|
||||
- **Type**: string
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `credit.funding-update`
|
||||
|
||||
This event is emitted when a funding-update is updated.
|
||||
|
||||
#### Property `available`
|
||||
|
||||
available
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `cost_uuid`
|
||||
|
||||
cost uuid
|
||||
- **Type**: string
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `credit.record-cost`
|
||||
|
||||
This event is emitted for credit record cost operations.
|
||||
|
||||
#### Property `available`
|
||||
|
||||
available
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `cost_uuid`
|
||||
|
||||
cost uuid
|
||||
- **Type**: string
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `driver.create-call-context`
|
||||
|
||||
This event is emitted when a create-call-context is created.
|
||||
|
||||
#### Property `usages`
|
||||
|
||||
usages
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `email.validate`
|
||||
|
||||
This event is emitted when a validate is being validated.
|
||||
The event can be used to block certain validates from being validated.
|
||||
|
||||
#### Property `allow`
|
||||
|
||||
whether the operation is allowed
|
||||
- **Type**: boolean
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
- If set to false, the email will be considered invalid.
|
||||
|
||||
#### Property `email`
|
||||
|
||||
email
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
- The email may have already been cleaned.
|
||||
|
||||
|
||||
### `fs.create.directory`
|
||||
|
||||
This event is emitted when a directory is created.
|
||||
|
||||
|
||||
### `fs.create.file`
|
||||
|
||||
This event is emitted when a file is created.
|
||||
|
||||
#### Property `context`
|
||||
|
||||
current context
|
||||
- **Type**: Context
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `fs.create.shortcut`
|
||||
|
||||
This event is emitted when a shortcut is created.
|
||||
|
||||
|
||||
### `fs.create.symlink`
|
||||
|
||||
This event is emitted when a symlink is created.
|
||||
|
||||
|
||||
### `fs.move.file`
|
||||
|
||||
This event is emitted for fs move file operations.
|
||||
|
||||
#### Property `moved`
|
||||
|
||||
moved
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `old_path`
|
||||
|
||||
path to the affected resource
|
||||
- **Type**: string
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `fs.pending.file`
|
||||
|
||||
This event is emitted for fs pending file operations.
|
||||
|
||||
|
||||
### `fs.storage.progress.copy`
|
||||
|
||||
This event reports progress of a copy operation.
|
||||
|
||||
#### Property `context`
|
||||
|
||||
current context
|
||||
- **Type**: Context
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `meta`
|
||||
|
||||
additional metadata for the operation
|
||||
- **Type**: object
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `item_path`
|
||||
|
||||
path to the affected resource
|
||||
- **Type**: string
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `fs.storage.upload-progress`
|
||||
|
||||
This event reports progress of a upload-progress operation.
|
||||
|
||||
|
||||
### `fs.write.file`
|
||||
|
||||
This event is emitted when a file is updated.
|
||||
|
||||
#### Property `context`
|
||||
|
||||
current context
|
||||
- **Type**: Context
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `ip.validate`
|
||||
|
||||
This event is emitted when a validate is being validated.
|
||||
The event can be used to block certain validates from being validated.
|
||||
|
||||
#### Property `res`
|
||||
|
||||
res
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `end_`
|
||||
|
||||
end
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `end`
|
||||
|
||||
end
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.fs.write-hash`
|
||||
|
||||
This event is emitted when a write-hash is updated.
|
||||
|
||||
#### Property `uuid`
|
||||
|
||||
uuid
|
||||
- **Type**: string
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.item.added`
|
||||
|
||||
This event is emitted for outer gui item added operations.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.item.moved`
|
||||
|
||||
This event is emitted for outer gui item moved operations.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.item.pending`
|
||||
|
||||
This event is emitted for outer gui item pending operations.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.item.updated`
|
||||
|
||||
This event is emitted when a updated is updated.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.notif.ack`
|
||||
|
||||
This event is emitted for outer gui notif ack operations.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.notif.message`
|
||||
|
||||
This event is emitted for outer gui notif message operations.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `notification`
|
||||
|
||||
notification
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.notif.persisted`
|
||||
|
||||
This event is emitted for outer gui notif persisted operations.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.notif.unreads`
|
||||
|
||||
This event is emitted for outer gui notif unreads operations.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.submission.done`
|
||||
|
||||
This event is emitted for outer gui submission done operations.
|
||||
|
||||
#### Property `response`
|
||||
|
||||
response
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `outer.gui.usage.update`
|
||||
|
||||
This event is emitted when a update is updated.
|
||||
|
||||
|
||||
### `outer.thread.notify-subscribers`
|
||||
|
||||
This event is emitted for outer thread notify subscribers operations.
|
||||
|
||||
#### Property `uid`
|
||||
|
||||
uid
|
||||
- **Type**: string
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `action`
|
||||
|
||||
action
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `data`
|
||||
|
||||
data
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `puter.signup`
|
||||
|
||||
This event is emitted for puter signup operations.
|
||||
|
||||
#### Property `ip`
|
||||
|
||||
ip
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `user_agent`
|
||||
|
||||
user agent
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `body`
|
||||
|
||||
body
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `request.measured`
|
||||
|
||||
This event is emitted for request measured operations.
|
||||
|
||||
#### Property `req`
|
||||
|
||||
req
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
#### Property `res`
|
||||
|
||||
res
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `request.will-be-handled`
|
||||
|
||||
This event is emitted for request will be handled operations.
|
||||
|
||||
#### Property `res`
|
||||
|
||||
res
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `end_`
|
||||
|
||||
end
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
#### Property `end`
|
||||
|
||||
end
|
||||
- **Type**: any
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `sns`
|
||||
|
||||
This event is emitted for sns operations.
|
||||
|
||||
#### Property `message`
|
||||
|
||||
message
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `template-service.hello`
|
||||
|
||||
This event is emitted for template-service hello operations.
|
||||
|
||||
|
||||
### `usages.query`
|
||||
|
||||
This event is emitted for usages query operations.
|
||||
|
||||
#### Property `usages`
|
||||
|
||||
usages
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `user.email-changed`
|
||||
|
||||
This event is emitted for user email changed operations.
|
||||
|
||||
#### Property `new_email`
|
||||
|
||||
new email
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `user.email-confirmed`
|
||||
|
||||
This event is emitted for user email confirmed operations.
|
||||
|
||||
#### Property `email`
|
||||
|
||||
email
|
||||
- **Type**: any
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `user.save_account`
|
||||
|
||||
This event is emitted for user save_account operations.
|
||||
|
||||
#### Property `user`
|
||||
|
||||
user associated with the operation
|
||||
- **Type**: User
|
||||
- **Mutability**: no-effect
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `web.socket.connected`
|
||||
|
||||
This event is emitted for web socket connected operations.
|
||||
|
||||
#### Property `user`
|
||||
|
||||
user associated with the operation
|
||||
- **Type**: User
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `web.socket.user-connected`
|
||||
|
||||
This event is emitted for web socket user connected operations.
|
||||
|
||||
#### Property `user`
|
||||
|
||||
user associated with the operation
|
||||
- **Type**: User
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
### `wisp.get-policy`
|
||||
|
||||
This event is emitted for wisp get policy operations.
|
||||
|
||||
#### Property `policy`
|
||||
|
||||
policy information for the operation
|
||||
- **Type**: Policy
|
||||
- **Mutability**: mutable
|
||||
- **Notes**:
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import dedent from 'dedent';
|
||||
import events from './events.json.js';
|
||||
|
||||
const mdlib = {};
|
||||
mdlib.h = (out, n, str) => {
|
||||
out(`${'#'.repeat(n)} ${str}\n\n`);
|
||||
};
|
||||
|
||||
const N_START = 3;
|
||||
|
||||
const out = str => process.stdout.write(str);
|
||||
for ( const event of events ) {
|
||||
mdlib.h(out, N_START, `\`${event.id}\``);
|
||||
out(`${dedent(event.description) }\n\n`);
|
||||
|
||||
for ( const k in event.properties ) {
|
||||
const prop = event.properties[k];
|
||||
mdlib.h(out, N_START + 1, `Property \`${k}\``);
|
||||
out(`${prop.summary }\n`);
|
||||
out(`- **Type**: ${prop.type}\n`);
|
||||
out(`- **Mutability**: ${prop.mutability}\n`);
|
||||
if ( prop.notes ) {
|
||||
out('- **Notes**:\n');
|
||||
for ( const note of prop.notes ) {
|
||||
out(` - ${note}\n`);
|
||||
}
|
||||
}
|
||||
out('\n');
|
||||
}
|
||||
|
||||
if ( event.example ) {
|
||||
mdlib.h(out, N_START + 1, 'Example');
|
||||
out(`\`\`\`${event.example.language}\n${dedent(event.example.code)}\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
out('\n');
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'core.email.validate',
|
||||
description: `
|
||||
This event is emitted when an email is being validated.
|
||||
The event can be used to block certain emails from being validated.
|
||||
`,
|
||||
properties: {
|
||||
email: {
|
||||
type: 'string',
|
||||
mutability: 'no-effect',
|
||||
summary: 'the email being validated',
|
||||
notes: [
|
||||
'The email may have already been cleaned.',
|
||||
],
|
||||
},
|
||||
allow: {
|
||||
type: 'boolean',
|
||||
mutability: 'mutable',
|
||||
summary: 'whether the email is allowed',
|
||||
notes: [
|
||||
'If set to false, the email will be considered invalid.',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.request.measured',
|
||||
description: `
|
||||
This event is emitted when a requests incoming and outgoing bytes
|
||||
have been measured.
|
||||
`,
|
||||
example: {
|
||||
language: 'javascript',
|
||||
code: /*javascript*/`
|
||||
extension.on('core.request.measured', data => {
|
||||
const measurements = data.measurements;
|
||||
// measurements = { sz_incoming: integer, sz_outgoing: integer }
|
||||
|
||||
const actor = data.actor; // instance of Actor
|
||||
|
||||
console.log('\\x1B[36;1m === MEASUREMENT ===\\x1B[0m\\n', {
|
||||
actor: data.actor.uid,
|
||||
measurements: data.measurements
|
||||
});
|
||||
});
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.fs.create.directory',
|
||||
description: `
|
||||
This event is emitted when a directory is created.
|
||||
`,
|
||||
properties: {
|
||||
node: {
|
||||
type: 'FSNodeContext',
|
||||
mutability: 'no-effect',
|
||||
summary: 'the directory that was created',
|
||||
},
|
||||
context: {
|
||||
type: 'Context',
|
||||
mutability: 'no-effect',
|
||||
summary: 'current context',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
@@ -1,64 +0,0 @@
|
||||
# Repository Structure and Tooling
|
||||
|
||||
Puter has many of its parts in a single [monorepo](https://en.wikipedia.org/wiki/Monorepo),
|
||||
rather than a single repository for each cohesive part.
|
||||
We feel this makes it easier for new contributors to develop Puter since you don't
|
||||
need to figure out how to tie the parts together or how to work with Git submodules.
|
||||
It also makes it easier for us to maintain project-wide conventions and tooling.
|
||||
|
||||
Some tools, like [puter-cli](https://github.com/HeyPuter/puter-cli), exist in separate
|
||||
repositories. The `puter-cli` tool is used externally and can communicate with Puter's
|
||||
API on our production (puter.com) instance or your own instance of Puter, so there's
|
||||
not really any advantage to putting it in the monorepo.
|
||||
|
||||
## Top-Level directories
|
||||
|
||||
### The `doc` directory
|
||||
|
||||
The top-level `doc` directory contains the file you're reading right now.
|
||||
Its scope is documentation for using and contributing to Puter in general,
|
||||
and linking to more specific documentation in other places.
|
||||
|
||||
All `doc` directories will have a `README.md` which should be considered as
|
||||
the index file for the documentation. All documentation under a `doc`
|
||||
directory should be accessible via a path of links starting from `README.md`.
|
||||
|
||||
### The `src` directory
|
||||
|
||||
Every directory under `/tools` is [an npm "workspaces" module](https://docs.npmjs.com/cli/v8/using-npm/workspaces). Every direct child of this directory (generally) has a `package.json` and a `src` directory.
|
||||
|
||||
Some of these modules are core pieces of Puter:
|
||||
- **Puter's backend** is [`/src/backend`](/src/backend)
|
||||
- See [key locations in backend documentation](/src/backend/doc/contributors/structure.md)
|
||||
- **Puter's GUI** is [`/src/gui`](/src/gui)
|
||||
|
||||
Some of these modules are apps:
|
||||
- **Puter's Terminal**: [`/src/terminal`](/src/terminal)
|
||||
- **Puter's Shell**: [`/src/phoenix`](/src/phoenix)
|
||||
|
||||
Some of these modules are libraries:
|
||||
- **common javascript**: [`/src/putility`](/src/putility)
|
||||
- **runtime import mechanism**: [`/src/useapi`](/src/useapi)
|
||||
- **Puter's "puter.js" browser SDK**: [`/src/puter-js`](/src/puter-js)
|
||||
|
||||
### The `volatile` directory
|
||||
|
||||
When you're running Puter with development instructions (i.e. `npm start`),
|
||||
Puter's configuration directory will be `volatile/config` and Puter's
|
||||
runtime directory will be `volatile/runtime`, instead of the standard
|
||||
`/etc/puter` and `/var/puter` directories in production installations.
|
||||
|
||||
We should probably rename this directory, actually, but it would inconvenience
|
||||
a lot of people right now if we did.
|
||||
|
||||
### The `tools` directory
|
||||
|
||||
Every directory under `/tools` is [an npm "workspaces" module](https://docs.npmjs.com/cli/v8/using-npm/workspaces).
|
||||
|
||||
This is where `run-selfhosted.js` is. That's the entrypoint for `npm start`.
|
||||
|
||||
These tools are underdocumented and may not behave well if they're not executed
|
||||
from the correct working directory (which is different for different tools).
|
||||
Consider this a work-in-progress. If you want to use or contribute to anything
|
||||
under this directory, for now you should
|
||||
[tag @KernelDeimos on the community Discord](https://discord.gg/PQcx7Teh8u).
|
||||
@@ -1,2 +0,0 @@
|
||||
### `vscode`
|
||||
- `es6-string-html`
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
## 2024-10-16
|
||||
|
||||
### Considerations for Mountpoints Feature
|
||||
|
||||
- `_storage_upload` takes paramter `uuid` instead of `path`
|
||||
- S3 bucket strategy needs the UUID
|
||||
- If we do hashes, 10MB chunks should be fine
|
||||
- we're already able to smooth out bursty traffic using the
|
||||
EWA algorithm
|
||||
- Use of `systemFSEntryService`
|
||||
- Is that normalized? Does everything go through this interface?
|
||||
- Storage interface has methods like `post_insert`
|
||||
- as far as I can tell this doesn't pose any issue
|
||||
-
|
||||
|
||||
### Brainstorming Migration Strategies
|
||||
|
||||
#### Interface boundary at HL<->LL filesystem methods
|
||||
|
||||
-- **tags:** brainstorming
|
||||
|
||||
From the perspectice of a trait-oriented implementation,
|
||||
which is not how LL/HL filesystem operations are currently implemented,
|
||||
the LL-class operations are implemented in separate traits.
|
||||
|
||||
The composite trait containing all of these traits would be the trait
|
||||
that represents a filesystem implementation itself.
|
||||
|
||||
Other filesystem interfaces that I've seen, such as FUSE and 9p,
|
||||
all usually have a monolithic interface - that is to say, an interface
|
||||
which includes all of the filesystem operations, rather than several
|
||||
interfaces each implementing a single filesystem operaiton.
|
||||
|
||||
Something about the fact that the LL-class operations are in separate
|
||||
classes makes it difficult to reason about how to move.
|
||||
Is it simply that multiple files in a directory is just more
|
||||
annoying to think about? Maybe, but there must be something more.
|
||||
|
||||
Perhaps it's that there are several references. Each implementation
|
||||
(that is, implemenation of a single filesystem operation) could have
|
||||
any number of different references across any number of different files.
|
||||
This would not be the case with a monolithic interface.
|
||||
|
||||
I think the best of both worlds would be to have an interface representing
|
||||
the entire filesystem and, in one place, link of of the individual
|
||||
operation implementations to compose a filesystem implementation
|
||||
|
||||
### Filesystem Brainstorming
|
||||
|
||||
Puter's backend uses a service architecture. Each service is an instance
|
||||
of a class extending "Service". A service can listen to events of the
|
||||
backend's lifecycle, interact with other services, and interact with
|
||||
external interfaces such as APIs and databases.
|
||||
|
||||
Puter's current filesystem, let's call it PuterFSv1, exists as the result
|
||||
of multiple services working together. We have LocalDiskStorageService
|
||||
which mimics an S3 bucket on a local system, and we have
|
||||
DatabaseFSEntryService which manages information about files, directories,
|
||||
and their relationships within the database, and therefore depends on
|
||||
DatabaseAccessService.
|
||||
|
||||
It is now time to introduce a MountpointService. This will allow another
|
||||
service or a user's configuration to assign an instance of a filesystem
|
||||
implementation (such as PuterFSv1) to a specific path.
|
||||
|
||||
The trouble here is that PuterFSv1 is composed of services, and the nature
|
||||
of a service is such that it exists for the lifecycle of the application.
|
||||
The class for a particular service can be re-used and registered with
|
||||
multiple names (creating multiple services with the same implementation
|
||||
but perhaps different configuration), but that's only a clean scenario when
|
||||
there is just one service. PuterFSv1, on the other hand, is like an
|
||||
imaginary service composed of other services.
|
||||
|
||||
The following possibilities then should be discussed:
|
||||
- CompositeService base class for a service that is composed of
|
||||
more than one service.
|
||||
- Refactor filesystem to not use service architecture.
|
||||
- Each filesystem service can manage state and configuration
|
||||
for multiple mountpoints
|
||||
(I don't like this idea; it feels messy. I wonder what software
|
||||
principles this violates)
|
||||
|
||||
We can take advantage of traits/interfaces here.
|
||||
PuterFSv1 depends on two interfaces:
|
||||
- An S3-like data storage implementation
|
||||
- An fsentry storage implementation
|
||||
|
||||
Counterintuitively from what I first thought, "Refactor the filesystem"
|
||||
actually looks like the best solution, and it doens't even look like it
|
||||
will be that difficult. In fact, it'll likely make the filesystem easier
|
||||
to maintain and more robust as a result.
|
||||
|
||||
Additionally, we can introduce PuterFSv2, which will introduce storing
|
||||
data in chunks identified by their hashes, and associated hashes with
|
||||
fsentries.
|
||||
|
||||
PuterFSService will be a new service which registers 'PuterFSv1' with
|
||||
FilesystemService.
|
||||
|
||||
An instance of a filesystem needs to be separate from a mountpoint.
|
||||
For example, PuterFSv1 will usually have only one instance but it may
|
||||
be mounted several different times. `/some-user` on Puter's VFS could
|
||||
be a mountpoint for `/some-user` in the instance of PuterFSv1.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Track Comments
|
||||
|
||||
Comments beginning with `// track:`. See
|
||||
[comment_prefixes.md](../contributors/comment_prefixes.md)
|
||||
|
||||
## Track Comment Registry
|
||||
|
||||
- `track: type check`:
|
||||
A condition that's used to check the type of an imput.
|
||||
- `track: adapt`
|
||||
A value can by adapted from another type at this line.
|
||||
- `track: bounds check`:
|
||||
A condition that's used to check the bounds of an array
|
||||
or other list-like entity.
|
||||
- `track: ruleset`
|
||||
A series of conditions that early-return or `continue`
|
||||
- `track: object description in comment`
|
||||
A comment above the creation of some object which
|
||||
could potentially have a `description` property.
|
||||
This is especially relevant if the object is stored
|
||||
in some kind of registry where multiple objects
|
||||
could be listed in the console.
|
||||
- `track: slice a prefix`
|
||||
A common pattern where a prefix string is "sliced off"
|
||||
of another string to obtain a significant value, such
|
||||
as an indentifier.
|
||||
- `track: actor type`
|
||||
The sub-type of an Actor object is checked.
|
||||
- `track: scoping iife`
|
||||
An immediately-invoked function expression specifically
|
||||
used to reduce scope clutter.
|
||||
- `track: good candidate for sequence`
|
||||
Some code involves a series of similar steps,
|
||||
or there's a common behavior that should happen
|
||||
in between. The Sequence class is good for this so
|
||||
it might be a worthy migration.
|
||||
- `track: opposite condition of sibling`
|
||||
A sibling class, function, method, or other construct of
|
||||
source code has a boolean expression which always evaluates
|
||||
to the opposite of the one below this track comment.
|
||||
- `track: null check before processing`
|
||||
An object could be undefined or null, additional processing
|
||||
occurs after a null check, and the unprocessed object is not
|
||||
relevant to the rest of the code. If the code for obtaining
|
||||
the object and processing it is moved to a function outside,
|
||||
then the null check should result in a early return of null;
|
||||
this code with the track comment may have additional logic
|
||||
for the null/undefined case.
|
||||
- `track: manual safe object`
|
||||
This code manually creates a new "client-safe" version of
|
||||
some object that's in scope. This could be either to pass
|
||||
onto the browser or to pass to something like the
|
||||
notification service.
|
||||
- `track: common operations on multiple items`
|
||||
A patterm which emerges when multiple variables have
|
||||
common operations done upon them in sequence.
|
||||
It may be applicable to write an iterator in the
|
||||
future, or something will come up that require
|
||||
these to be handled with a modular approach instead.
|
||||
- `track: checkpoint`
|
||||
A location where some statement about the state of the
|
||||
software must hold true.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Meta Documentation
|
||||
|
||||
Guidelines for documentation.
|
||||
|
||||
## How documentation is organized
|
||||
|
||||
This documentation exists in the Puter repository.
|
||||
You may be reading this on the GitHub wiki instead, which we generate
|
||||
from the repository docs. These docs are always under a directory
|
||||
named `doc/`.
|
||||
|
||||
From [./contributors/structure.md](./contributors/structure.md):
|
||||
> The top-level `doc` directory contains the file you're reading right now.
|
||||
> Its scope is documentation for using and contributing to Puter in general,
|
||||
> and linking to more specific documentation in other places.
|
||||
>
|
||||
> All `doc` directories will have a `README.md` which should be considered as
|
||||
> the index file for the documentation. All documentation under a `doc`
|
||||
> directory should be accessible via a path of links starting from `README.md`.
|
||||
|
||||
### Documentation Structure
|
||||
|
||||
The top-level `doc` directory contains the following subdirectories:
|
||||
|
||||
- `api/` - API documentation for Puter services
|
||||
- `contributors/` - Documentation for contributors to the Puter project
|
||||
- `devmeta/` - Meta documentation for developers
|
||||
- `i18n/` - Internationalization documentation
|
||||
- `planning/` - Project planning documentation
|
||||
- `self-hosters/` - Documentation for self-hosting Puter
|
||||
- `uncategorized/` - Miscellaneous documentation
|
||||
|
||||
As well as some files:
|
||||
|
||||
- `README.md` - Documentation overview optimized for humans.
|
||||
- `AI.md` - Documentation overview optimized for AI/LLM agents.
|
||||
|
||||
Module-specific documentation follows a similar structure, with each module having its own `doc` directory. For contributor-specific documentation within a module, use a `contributors` subdirectory within the module's `doc` directory.
|
||||
|
||||
## Docs Styleguide
|
||||
|
||||
### "is" and "is not"
|
||||
|
||||
- When "A is B", bold "is": "A **is** B" (`A **is** B`)
|
||||
- When "A is not B", bold "not": "A is **not** B" (`A is **not** B`)
|
||||
@@ -113,7 +113,7 @@ Terhubung dengan maintainer dan komunitas melalui saluran-saluran berikut:
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
|
||||
- Isu keamanan? [[email protected]](mailto:[email protected])
|
||||
- Email maintainers di [[email protected]](mailto:[email protected])
|
||||
- Email pengelola di [[email protected]](mailto:[email protected])
|
||||
|
||||
Kami selalu senang membantu Anda dengan pertanyaan apa pun yang Anda miliki. Jangan ragu untuk bertanya!
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (C) 2024 Puter Technologies Inc.
|
||||
Copyright (C) 2024-present Puter Technologies Inc.
|
||||
|
||||
This file is part of Puter.
|
||||
|
||||
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
# Puter in Production
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Will build Puter in the `dist` directory. Include the generated `./dist/gui.js` file in your HTML page and call `gui()` when the page is loaded:
|
||||
|
||||
```html
|
||||
<script type="text/javascript" src="./dist/gui.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', function() {
|
||||
// Initialize the GUI. All options are optional!
|
||||
gui({
|
||||
// The origin of the app. This is the base URL of the GUI.
|
||||
gui_origin: "https://puter.com",
|
||||
|
||||
// The origin of the API. This is the base URL of the API endpoints that the GUI will call for all its operations.
|
||||
api_origin: "https://api.puter.com",
|
||||
|
||||
// The domain under which user websites are hosted.
|
||||
hosting_domain: "puter.site",
|
||||
|
||||
// The maximum length of file/directory names.
|
||||
max_item_name_length: 500,
|
||||
|
||||
// If GUI has to enforce email verification before allowing user to publish a website.
|
||||
require_email_verification_to_publish_website: true,
|
||||
})
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Full Production Example
|
||||
|
||||
Assuming the following directory structure in production:
|
||||
|
||||
```
|
||||
.
|
||||
├── dist/
|
||||
│ ├── favicons/
|
||||
│ ├── images/
|
||||
│ ├── bundle.min.css
|
||||
│ ├── bundle.min.js
|
||||
│ ├── gui.js
|
||||
│ └── ...
|
||||
└── index.html
|
||||
```
|
||||
|
||||
The `index.html` file below will load Puter and all the necessary meta tags, favicons, and branding assets:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Puter</title>
|
||||
<meta name="author" content="Puter Technologies Inc.">
|
||||
<meta name="description" content="Puter is a privacy-first personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.">
|
||||
<meta name="facebook-domain-verification" content="e29w3hjbnnnypf4kzk2cewcdaxym1y" />
|
||||
<link rel="canonical" href="https://puter.com">
|
||||
|
||||
<!-- Meta meta tags -->
|
||||
<meta property="og:url" content="https://puter.com">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Puter">
|
||||
<meta property="og:description" content="Puter is a privacy-first personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.">
|
||||
<meta property="og:image" content="./dist/images/screenshot.png">
|
||||
|
||||
<!-- Twitter meta tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:domain" content="puter.com">
|
||||
<meta property="twitter:url" content="https://puter.com">
|
||||
<meta name="twitter:title" content="Puter">
|
||||
<meta name="twitter:description" content="Puter is a privacy-first personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.">
|
||||
<meta name="twitter:image" content="./dist/images/screenshot.png">
|
||||
|
||||
<!-- favicons -->
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="./dist/favicons/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="./dist/favicons/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="./dist/favicons/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="./dist/favicons/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="./dist/favicons/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="./dist/favicons/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="./dist/favicons/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="./dist/favicons/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./dist/favicons/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="./dist/favicons/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="./dist/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="./dist/favicons/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="./dist/favicons/favicon-16x16.png">
|
||||
<link rel="manifest" href="./dist/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="./dist/favicons/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<!-- Preload images when applicable -->
|
||||
<link rel="preload" as="image" href="./dist/images/wallpaper.webp">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Load the GUI script -->
|
||||
<script type="text/javascript" src="./dist/gui.js"></script>
|
||||
<!-- Initialize GUI when document is loaded -->
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', function() {
|
||||
gui()
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
```
|
||||
|
||||
### Server settings
|
||||
|
||||
The GUI is a single page application (SPA) and as best practice any route under root (`/*`) should preferably load the `index.html` file. However, there are situations where we want to load a custom page for a specific route: for example, the `/privacy` route may need to load a page that contains your privacy policy and has nothing to do with the GUI application. In these cases it is ok to load a custom page as long as the following essential GUI routes are loaded with the GUI (i.e. `index.html` file):
|
||||
- `/app/*`
|
||||
- `/action/*`
|
||||
|
||||
In other words, consider the routes above as "reserved" for Puter.
|
||||
|
||||
### Publish My Website
|
||||
|
||||
Right-click anywhere on the desktop to display options
|
||||
From the options menu, select "New".
|
||||
Then, choose "Folder".
|
||||
Give the folder a name according to your preference.
|
||||
|
||||
After creating the folder:
|
||||
|
||||
Right-click on the folder.
|
||||
Select the option "Publish as Website".
|
||||
|
||||
### Best Practices
|
||||
|
||||
- The `title` tags and meta tags (`<title></title>`, `<meta property="og:title"`, `<meta name="twitter:title"`, ...) should be dynamically set by the server. For example, if the URL is of an app (e.g. `https://puter.com/app/editor`) the `title` tags and meta tags should contain the app's title rather than the generic Puter title.
|
||||
|
||||
- The `description` meta tags (`<meta name="description"`, `<meta property="og:description"`, `<meta name="twitter:description"`, ...) should be dynamically set by the server. For example, if the URL is of an app (e.g. `https://puter.com/app/editor`) the `description` meta tags should contain the app's description rather than the generic Puter description.
|
||||
|
||||
- Make sure to escape any HTML code that is dynamically added to the HTML page. For example, if the app's description is `Puter is a <b>privacy-first</b> personal cloud to keep all your files, apps, and games in one private and secure place, accessible from anywhere at any time.` the `<b>` tag should be escaped to `<b>` so that the browser doesn't interpret it as an HTML tag.
|
||||
|
||||
- Make sure to replace all new line characters with space when dynamically adding text to the HTML page.
|
||||
|
||||
- Generally, for UX and SEO reasons make sure that the tags are filled with relevant information about the state the URL is representing. E.g. is the user on the desktop or an app?
|
||||
@@ -1,84 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
key: 'domain',
|
||||
description: `
|
||||
Domain name of the Puter instance. This may be used to generate URLs
|
||||
in the UI. If "allow_all_host_values" is false or undefined, the domain
|
||||
will be used to validate the host header of incoming requests.
|
||||
`,
|
||||
example_values: [
|
||||
'example.com',
|
||||
'subdomain.example.com',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'protocol',
|
||||
description: `
|
||||
The protocol to use for URLs. This should be either "http" or "https".
|
||||
`,
|
||||
example_values: [
|
||||
'http',
|
||||
'https',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'static_hosting_domain',
|
||||
description: `
|
||||
This domain name will be used for public site URLs. For example: when
|
||||
you right-click a directory and choose "Publish as Website".
|
||||
This domain should point to the same server. If you have a LAN configuration
|
||||
you could set this to something like
|
||||
\`site.192.168.555.12.nip.io\`, replacing
|
||||
\`192.168.555.12\` with a valid IP address belonging to the server.
|
||||
`,
|
||||
},
|
||||
{
|
||||
key: 'allow_all_host_values',
|
||||
description: `
|
||||
If true, Puter will accept any host header value in incoming requests.
|
||||
This is useful for development, but should be disabled in production.
|
||||
`,
|
||||
},
|
||||
{
|
||||
key: 'allow_nipio_domains',
|
||||
description: `
|
||||
If true, Puter will allow requests with host headers that end in nip.io.
|
||||
This is useful for development, LAN, and VPN configurations.
|
||||
`,
|
||||
},
|
||||
{
|
||||
key: 'http_port',
|
||||
description: `
|
||||
The port to listen on for HTTP requests.
|
||||
`,
|
||||
},
|
||||
{
|
||||
key: 'enable_public_folders',
|
||||
description: `
|
||||
If true, any /username/Public directory will be available to all
|
||||
users, including anonymous users.
|
||||
`,
|
||||
},
|
||||
{
|
||||
key: 'disable_temp_users',
|
||||
description: `
|
||||
If true, new users will see the login/signup page instead of being
|
||||
automatically logged in as a temporary user.
|
||||
`,
|
||||
},
|
||||
{
|
||||
key: 'disable_user_signup',
|
||||
description: `
|
||||
If true, the signup page will be disabled and the backend will not
|
||||
accept new user registrations.
|
||||
`,
|
||||
},
|
||||
{
|
||||
key: 'disable_fallback_mechanisms',
|
||||
description: `
|
||||
A general setting to prevent any fallback behavior that might
|
||||
"hide" errors. It is recommended to set this to true when
|
||||
debugging, testing, or developing new features.
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -1,81 +0,0 @@
|
||||
# Configuring Puter
|
||||
|
||||
## Terminology
|
||||
|
||||
- **root** - the "top level" of configuration; if a key-value pair is in/at "the root"
|
||||
that means it is **not in a nested object**
|
||||
(ex: values under "services" are **not** at the root).
|
||||
|
||||
## Config Locations
|
||||
|
||||
Running the server will generate a configuration file in one of these locations:
|
||||
- `config/config.json` when [Using Docker](#using-docker)
|
||||
- `volatile/config/config.json` in [Local Development](#local-development)
|
||||
- `/etc/puter/config.json` on a server (or within a Docker container)
|
||||
|
||||
## Editing Configuration
|
||||
|
||||
For a list of all possible config values, see [config_values.md](./config_values.md)
|
||||
|
||||
Instead of editing the generated `config.json`, you can make a config file
|
||||
that references it. This makes it easier to maintain if you frequently update
|
||||
Puter, since you can then just delete `config.json` to get new defaults.
|
||||
|
||||
For example, a `local.json` might look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
// Always include this header
|
||||
"$version": "v1.1.0",
|
||||
"$requires": [
|
||||
"config.json"
|
||||
],
|
||||
"config_name": "local",
|
||||
|
||||
// Your custom configuration
|
||||
"domain": "my-puter.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
To use `local.json` instead of `config.json` you will need to set the
|
||||
environment variable `PUTER_CONFIG_PROFILE=local` in the context where
|
||||
you are running Puter.
|
||||
|
||||
## Sample Configuration
|
||||
|
||||
The default configuration generated by Puter will look
|
||||
something like the following (updated 2025-02-26):
|
||||
|
||||
```json
|
||||
{
|
||||
"config_name": "generated default config",
|
||||
"mod_directories": [
|
||||
"{source}/../extensions"
|
||||
],
|
||||
"env": "dev",
|
||||
"nginx_mode": true,
|
||||
"server_id": "localhost",
|
||||
"http_port": "auto",
|
||||
"domain": "puter.localhost",
|
||||
"protocol": "http",
|
||||
"contact_email": "[email protected]",
|
||||
"services": {
|
||||
"database": {
|
||||
"engine": "sqlite",
|
||||
"path": "puter-database.sqlite"
|
||||
},
|
||||
"dynamo" :{"path":"./puter-ddb"}
|
||||
},
|
||||
"cookie_name": "...",
|
||||
"jwt_secret": "...",
|
||||
"url_signature_secret": "...",
|
||||
"private_uid_secret": "...",
|
||||
"private_uid_namespace": "...",
|
||||
"": null
|
||||
}
|
||||
```
|
||||
|
||||
## Root-Level Parameters
|
||||
|
||||
- **domain** - origin for Puter. Do **not** include URL schema (the 'http(s)://' portion)
|
||||
-
|
||||
@@ -1,72 +0,0 @@
|
||||
### `domain`
|
||||
|
||||
Domain name of the Puter instance. This may be used to generate URLs
|
||||
in the UI. If "allow_all_host_values" is false or undefined, the domain
|
||||
will be used to validate the host header of incoming requests.
|
||||
|
||||
#### Examples
|
||||
|
||||
- `"domain": "example.com"`
|
||||
- `"domain": "subdomain.example.com"`
|
||||
|
||||
### `protocol`
|
||||
|
||||
The protocol to use for URLs. This should be either "http" or "https".
|
||||
|
||||
#### Examples
|
||||
|
||||
- `"protocol": "http"`
|
||||
- `"protocol": "https"`
|
||||
|
||||
### `static_hosting_domain`
|
||||
|
||||
This domain name will be used for public site URLs. For example: when
|
||||
you right-click a directory and choose "Publish as Website".
|
||||
This domain should point to the same server. If you have a LAN configuration
|
||||
you could set this to something like
|
||||
`site.192.168.555.12.nip.io`, replacing
|
||||
`192.168.555.12` with a valid IP address belonging to the server.
|
||||
|
||||
|
||||
### `allow_all_host_values`
|
||||
|
||||
If true, Puter will accept any host header value in incoming requests.
|
||||
This is useful for development, but should be disabled in production.
|
||||
|
||||
|
||||
### `allow_nipio_domains`
|
||||
|
||||
If true, Puter will allow requests with host headers that end in nip.io.
|
||||
This is useful for development, LAN, and VPN configurations.
|
||||
|
||||
|
||||
### `http_port`
|
||||
|
||||
The port to listen on for HTTP requests.
|
||||
|
||||
|
||||
### `enable_public_folders`
|
||||
|
||||
If true, any /username/Public directory will be available to all
|
||||
users, including anonymous users.
|
||||
|
||||
|
||||
### `disable_temp_users`
|
||||
|
||||
If true, new users will see the login/signup page instead of being
|
||||
automatically logged in as a temporary user.
|
||||
|
||||
|
||||
### `disable_user_signup`
|
||||
|
||||
If true, the signup page will be disabled and the backend will not
|
||||
accept new user registrations.
|
||||
|
||||
|
||||
### `disable_fallback_mechanisms`
|
||||
|
||||
A general setting to prevent any fallback behavior that might
|
||||
"hide" errors. It is recommended to set this to true when
|
||||
debugging, testing, or developing new features.
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# Configuring Domains for Self-Hosted Puter
|
||||
|
||||
## Local Network Configuration
|
||||
|
||||
### Prerequisite Conditions
|
||||
|
||||
Ensure the hosting device has a static IP address to prevent potential connectivity issues due to IP changes. This setup will enable seamless access to Puter and its services across your local network.
|
||||
|
||||
### Using `nip.io`
|
||||
|
||||
We recommend this configuration for LAN setups. All you need to do is set the following
|
||||
at root level in your configuration file:
|
||||
|
||||
```json
|
||||
"allow_nipio_domains": true
|
||||
```
|
||||
|
||||
Puter requires multiple origins to work correctly. `nip.io` is a wildcard DNS for IP addresses,
|
||||
so Puter can still have multiple subdomains and you don't need to configure your own DNS or
|
||||
hosts file.
|
||||
|
||||
### Using Hosts Files
|
||||
|
||||
The hosts file is a straightforward way to map domain names to IP addresses on individual devices. It's simple to set up but requires manual changes on each device that needs access to the domains.
|
||||
|
||||
#### Windows
|
||||
1. Open Notepad as an administrator.
|
||||
2. Open the file located at `C:\Windows\System32\drivers\etc\hosts`.
|
||||
3. Add lines for your domain and subdomain with the server's IP address, in the
|
||||
following format:
|
||||
```
|
||||
192.168.1.10 puter.local
|
||||
192.168.1.10 api.puter.local
|
||||
```
|
||||
|
||||
#### For macOS and Linux:
|
||||
1. Open a terminal.
|
||||
2. Edit the hosts file with a text editor, e.g., `sudo nano /etc/hosts`.
|
||||
3. Add lines for your domain and subdomain with the server's IP address, in the
|
||||
following format:
|
||||
```
|
||||
192.168.1.10 puter.local
|
||||
192.168.1.10 api.puter.local
|
||||
```
|
||||
4. Save and exit the editor.
|
||||
|
||||
|
||||
### Using Router Configuration
|
||||
|
||||
Some routers allow you to add custom DNS rules, letting you configure domain names network-wide without touching each device.
|
||||
|
||||
1. Access your router’s admin interface (usually through a web browser).
|
||||
2. Look for DNS or DHCP settings.
|
||||
3. Add custom DNS mappings for `puter.local` and `api.puter.local` to the hosting device's IP address.
|
||||
4. Save the changes and reboot the router if necessary.
|
||||
|
||||
This method's availability and steps may vary depending on your router's model and firmware.
|
||||
|
||||
### Using Local DNS
|
||||
|
||||
Setting up a local DNS server on your network allows for flexible and scalable domain name resolution. This method works across all devices automatically once they're configured to use the DNS server.
|
||||
|
||||
#### Options for DNS Software:
|
||||
|
||||
- **Pi-hole**: Acts as both an ad-blocker and a DNS server. Ideal for easy setup and maintenance.
|
||||
- **BIND9**: Offers comprehensive DNS server capabilities for complex setups.
|
||||
- **dnsmasq**: Lightweight and suitable for smaller networks or those new to running a DNS server.
|
||||
|
||||
**contributors note:** feel free to add any software you're aware of
|
||||
which might help with this to the list. Also, feel free to add instructions here for specific software; our goal is for Puter to be easy to setup with tools you're already familiar with.
|
||||
|
||||
#### General Steps:
|
||||
|
||||
1. Choose and install DNS server software on a device within your network.
|
||||
2. Configure the DNS server to resolve `puter.local` and `api.puter.local` to the IP address of your Puter hosting device.
|
||||
3. Update your router's DHCP settings to distribute the DNS server's IP address to all devices on the network.
|
||||
|
||||
By setting up a local DNS server, you gain the most flexibility and control over your network's domain name resolution, ensuring that all devices can access Puter and its API without manual configuration.
|
||||
|
||||
## Production Configuration
|
||||
|
||||
Please note the self-hosting feature is still in alpha and a public production
|
||||
deployment is not recommended at this time. However, if you wish to host
|
||||
publicly you can do so following the same steps you normally would to configure
|
||||
a domain name and ensuring the `api` subdomain points to the server as well.
|
||||
@@ -1,80 +0,0 @@
|
||||
# First Run Issues
|
||||
|
||||
## "Cannot find package '@heyputer/backend'"
|
||||
|
||||
Scenario: You see the following output:
|
||||
|
||||
```
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Cannot find package '@heyputer/backend' ┃
|
||||
┃ 📝 this usually happens if you forget `npm install` ┃
|
||||
┃ Suggestions: ┃
|
||||
┃ - try running `npm install` ┃
|
||||
┃ Technical Notes: ┃
|
||||
┃ - @heyputer/backend is in an npm workspace ┃
|
||||
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
```
|
||||
|
||||
1. Ensure you have run `npm install`.
|
||||
2. [Install build essentials for your distro](#installing-build-essentials),
|
||||
then run `npm install` again.
|
||||
|
||||
## Installing Build Essentials
|
||||
|
||||
### Debian-based distros
|
||||
|
||||
```
|
||||
sudo apt update
|
||||
sudo apt install build-essential
|
||||
```
|
||||
|
||||
### RHEL-family distros (Fedora, Rocky, etc)
|
||||
|
||||
For distros using dnf5 (Fedora 41+):
|
||||
```
|
||||
sudo dnf install @development-tools
|
||||
```
|
||||
|
||||
Otherwise:
|
||||
```
|
||||
sudo dnf groupinstall "Development Tools"
|
||||
```
|
||||
|
||||
### "I use Arch btw"
|
||||
|
||||
```
|
||||
sudo pacman -S base-devel
|
||||
```
|
||||
|
||||
### Alpine
|
||||
|
||||
If you're running in Puter's Alpine image then this is already installed.
|
||||
|
||||
```
|
||||
sudo apk add build-base
|
||||
```
|
||||
|
||||
### Gentoo
|
||||
|
||||
You know what you're doing; you just wanted to see if we mentioned Gentoo.
|
||||
|
||||
## "Could not load the "sharp" module using the freebsd-x64 runtime"
|
||||
|
||||
In order to get it to work on FreeBSD, you will need to build sharp from source and link it to the project.
|
||||
|
||||
```
|
||||
pkg install vips
|
||||
git clone --depth=1 https://github.com/lovell/sharp.git
|
||||
cd sharp
|
||||
yarn install
|
||||
sudo npm link
|
||||
```
|
||||
|
||||
After `npm install` you can link the prebuilt module
|
||||
|
||||
```
|
||||
# cd puter
|
||||
# npm install
|
||||
npm link sharp
|
||||
npm start
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
import dedent from 'dedent';
|
||||
import configVals from './config-vals.json.js';
|
||||
|
||||
const mdlib = {};
|
||||
mdlib.h = (out, n, str) => {
|
||||
out(`${'#'.repeat(n)} ${str}\n\n`);
|
||||
};
|
||||
|
||||
const N_START = 3;
|
||||
|
||||
const out = str => process.stdout.write(str);
|
||||
for ( const configVal of configVals ) {
|
||||
mdlib.h(out, N_START, `\`${configVal.key}\``);
|
||||
out(`${dedent(configVal.description) }\n\n`);
|
||||
|
||||
if ( configVal.example_values ) {
|
||||
mdlib.h(out, N_START + 1, 'Examples');
|
||||
for ( const example of configVal.example_values ) {
|
||||
out(`- \`"${configVal.key}": ${JSON.stringify(example)}\`\n`);
|
||||
}
|
||||
}
|
||||
|
||||
out('\n');
|
||||
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
# Self-Hosting Puter
|
||||
|
||||
> [!WARNING]
|
||||
> The self-hosted version of Puter is currently in alpha stage and should not be used in production yet. It is under active development and may contain bugs, other issues. Please exercise caution and use it for testing and evaluation purposes only.
|
||||
|
||||
### Self-Hosting Differences
|
||||
Currently, the self-hosted version of Puter is different in a few ways from [Puter.com](https://puter.com):
|
||||
- There is no built-in way to access apps from puter.com (see below)
|
||||
- Several "core" apps are missing, such as **Code** or **Draw**
|
||||
- Some assets are different
|
||||
|
||||
Work is ongoing to improve the **App Center** and make it available on self-hosted.
|
||||
Until then, it is still possible to add apps using the **Dev Center** app.
|
||||
|
||||
<br/>
|
||||
|
||||
## Configuration
|
||||
|
||||
Running the server will generate a [configuration file](./config.md) in one of these locations:
|
||||
- `config/config.json` when [Using Docker](#using-docker)
|
||||
- `volatile/config/config.json` in [Local Development](#local-development)
|
||||
- `/etc/puter/config.json` on a server (or within a Docker container)
|
||||
|
||||
### Domain Name
|
||||
|
||||
To access Puter on your device, you can simply go to the address printed in
|
||||
the server console (usually `puter.localhost:4100`).
|
||||
|
||||
To access Puter from another device on LAN, enable the following configuration:
|
||||
```json
|
||||
"allow_nipio_domains": true
|
||||
```
|
||||
|
||||
To access Puter from another device, a domain name must be configured, as well as
|
||||
an `api` subdomain. For example, `example.local` might be the domain name pointing
|
||||
to the IP address of the server running puter, and `api.example.com` must point to
|
||||
this address as well. This domain must be specified in the configuration file
|
||||
(usually `volatile/config/config.json`) as well.
|
||||
|
||||
See [domain configuration](./domains.md) for more information.
|
||||
|
||||
### Configure the Port
|
||||
|
||||
- You can specify a custom port by setting `http_port` to a desired value
|
||||
- If you're using a reverse-proxy such as nginx or cloudflare, you should
|
||||
also set `pub_port` to the public (external) port (usually `443`)
|
||||
- If you have HTTPS enabled on your reverse-proxy, ensure that
|
||||
`protocol` in config.json is set accordingly
|
||||
|
||||
### Default User
|
||||
|
||||
By default, Puter will create a user called `default_user`.
|
||||
This user will have a randomly generated password, which will be printed
|
||||
in the development console.
|
||||
A warning will persist in the dev console until this user's
|
||||
password is changed. Please login to this user and change the password as
|
||||
your first step.
|
||||
|
||||
<br/>
|
||||
@@ -1,39 +0,0 @@
|
||||
## Puter Support Levels for Repository Updates
|
||||
|
||||
This document describes issues requiring repository changes;
|
||||
which issues will be fixed by Puter's core team, and which ones
|
||||
will be fixed if the community makes a contribution.
|
||||
|
||||
This document is not "law". It is provided only as a helpful guide
|
||||
on what to expect.
|
||||
|
||||
### Level Glossary
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ----------- |
|
||||
| Core | Core developers will fix this |
|
||||
| Community | We will accept contributions to fix this |
|
||||
| Mixed | Core developers will fix this if it's currently a priority |
|
||||
|
||||
### Issues and their Levels
|
||||
|
||||
| Issue | Priority |
|
||||
| ----- | -------- |
|
||||
| Security vulnerability | Core |
|
||||
| Breaking change to SDK or API | Core |
|
||||
| Bug in service in CoreModule | Core |
|
||||
| Bug in a built-in app | Core |
|
||||
| Login/init failure in Docker on `release` branch | Core |
|
||||
| Login/init failure in Linux or OSX | Core |
|
||||
| Login/init failure in Docker on `main` branch | Mixed |
|
||||
| Login/init failure with specific configuration | Mixed |
|
||||
| Login/init failure in Windows | Community |
|
||||
|
||||
|
||||
## Puter Support for a Particular Deployment
|
||||
|
||||
If you experience issues on a self-hosted deployment we're here to
|
||||
help. Some issues are related to configuration or environment, so
|
||||
we may only be able to help in a limited capacity. Issues related
|
||||
to data loss, data corruption, or security will have higher priority
|
||||
over other issues with particular deployments.
|
||||
@@ -0,0 +1,453 @@
|
||||
# Self-Hosting Puter
|
||||
|
||||
`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx — wired together. Closest thing to a production deployment you can self-manage on a single host.
|
||||
|
||||
## One-line installer (recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
|
||||
```
|
||||
|
||||
Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker** with the `compose` plugin.
|
||||
- A **domain** with DNS access — you need a wildcard record (`*.your-domain.com` → server IP). Puter routes by subdomain (`api.<domain>`, `site.<domain>`, `app.<domain>`).
|
||||
- Optional: **TLS certs** (or `certbot` to grab them — see Step 3).
|
||||
|
||||
## What's running
|
||||
|
||||
| Container | Image | Role |
|
||||
| --------------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `puter-nginx` | `nginx:1.27-alpine` | Reverse proxy on 80 (and 443 if TLS); forwards to Puter |
|
||||
| `puter` | `ghcr.io/heyputer/puter` | The app |
|
||||
| `puter-mariadb` | `mariadb:11` | SQL database — schema applied automatically on first boot |
|
||||
| `puter-valkey` | `valkey/valkey:8-alpine` | Redis-compatible cache + rate-limiter |
|
||||
| `puter-dynamo` | `amazon/dynamodb-local` | KV store — table auto-created on first boot |
|
||||
| `puter-s3` | `rustfs/rustfs` | S3-compatible object storage (MinIO drop-in noted in file) |
|
||||
| `puter-s3-init` | `amazon/aws-cli` | One-shot — creates the bucket on first boot, then exits |
|
||||
|
||||
Optional services (compose profile `ai`, opt-in):
|
||||
|
||||
| Container | Image | Role |
|
||||
| ------------------- | --------------- | -------------------------------------------------------------- |
|
||||
| `puter-ollama` | `ollama/ollama` | Local LLM provider (CPU; GPU passthrough opt-in) |
|
||||
| `puter-ollama-init` | `ollama/ollama` | One-shot — pulls the default model (`tinyllama`) on first boot |
|
||||
|
||||
State lives under `./puter/data/<service>/`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create `.env` and `puter/config/config.json`
|
||||
|
||||
> ⚠️ **Run this whole block in one shell session.** It generates secrets once and writes them into both `.env` (read by docker compose) and `config.json` (read by Puter). The two files **must** agree on the MariaDB password and the S3 secret — if they drift, MariaDB initialises with one password and Puter tries to log in with another, and you get `ER_ACCESS_DENIED_ERROR`.
|
||||
|
||||
```bash
|
||||
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
|
||||
MARIADB_PASSWORD=$(openssl rand -hex 32)
|
||||
S3_SECRET_KEY=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 64)
|
||||
JWT_SECRET_V2=$(openssl rand -hex 64)
|
||||
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
|
||||
|
||||
cat > .env <<EOF
|
||||
HTTP_PORT=80
|
||||
# HTTPS_PORT=443 # uncomment after enabling TLS in Step 3
|
||||
|
||||
MARIADB_ROOT_PASSWORD=$MARIADB_ROOT_PASSWORD
|
||||
MARIADB_DATABASE=puter
|
||||
MARIADB_USER=puter
|
||||
MARIADB_PASSWORD=$MARIADB_PASSWORD
|
||||
|
||||
S3_ACCESS_KEY=puter
|
||||
S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
S3_BUCKET=puter-local
|
||||
EOF
|
||||
|
||||
mkdir -p puter/config puter/data puter/tls
|
||||
cat > puter/config/config.json <<EOF
|
||||
{
|
||||
"domain": "puter.localhost",
|
||||
"protocol": "http",
|
||||
"pub_port": 80,
|
||||
"env": "prod",
|
||||
|
||||
"static_hosting_domain": "site.puter.localhost",
|
||||
"static_hosting_domain_alt": "host.puter.localhost",
|
||||
"private_app_hosting_domain": "app.puter.localhost",
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
|
||||
"jwt_secret": "$JWT_SECRET",
|
||||
"jwt_secret_v2": "$JWT_SECRET_V2",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "$URL_SIGNATURE_SECRET",
|
||||
|
||||
"database": {
|
||||
"engine": "mysql",
|
||||
"host": "mariadb",
|
||||
"port": 3306,
|
||||
"user": "puter",
|
||||
"password": "$MARIADB_PASSWORD",
|
||||
"database": "puter",
|
||||
"migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/mysql"]
|
||||
},
|
||||
|
||||
"redis": {
|
||||
"startupNodes": [{ "host": "valkey", "port": 6379 }],
|
||||
"tls": false
|
||||
},
|
||||
|
||||
"dynamo": {
|
||||
"endpoint": "http://dynamo:8000",
|
||||
"bootstrapTables": true,
|
||||
"aws": {
|
||||
"access_key": "fake",
|
||||
"secret_key": "fake",
|
||||
"region": "us-east-1"
|
||||
}
|
||||
},
|
||||
|
||||
"s3": {
|
||||
"s3Config": {
|
||||
"endpoint": "http://s3:9000",
|
||||
"publicEndpoint": "http://s3.puter.localhost",
|
||||
"accessKeyId": "puter",
|
||||
"secretAccessKey": "$S3_SECRET_KEY",
|
||||
"region": "us-east-1",
|
||||
"forcePathStyle": true
|
||||
}
|
||||
},
|
||||
"s3_bucket": "puter-local",
|
||||
"s3_region": "us-east-1",
|
||||
|
||||
"providers": {
|
||||
"ollama": { "enabled": false }
|
||||
},
|
||||
|
||||
"trust_proxy": 1
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.puter.localhost` and `app.puter.localhost` with your actual domain (or leave it for a localhost-only trial).
|
||||
|
||||
Why these knobs:
|
||||
|
||||
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `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.
|
||||
- `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. nginx 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.
|
||||
- `trust_proxy: 1` — nginx terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the nginx container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (nginx). Bump to `2` if you put Cloudflare in front of nginx; never set `true` (it trusts every hop and makes XFF forgeable).
|
||||
|
||||
> If you ever change `MARIADB_PASSWORD` after first boot, `.env` alone won't update MariaDB — its credentials are baked into `./puter/data/mariadb/` on first init. Either rotate the password inside MariaDB by hand or `docker compose down && rm -rf ./puter/data/mariadb` to start fresh.
|
||||
|
||||
## Step 2 — Point DNS at the server \[Optional\]
|
||||
|
||||
In your DNS provider, add records for the main domain plus the subdomains Puter and nginx route on (`api.*`, `site.*`, `app.*`, `s3.*`):
|
||||
|
||||
```
|
||||
A puter.localhost → <your server's public IP>
|
||||
A *.puter.localhost → <your server's public IP>
|
||||
A site.puter.localhost → <your server's public IP>
|
||||
A *.site.puter.localhost → <your server's public IP>
|
||||
A host.puter.localhost → <your server's public IP>
|
||||
A *.host.puter.localhost → <your server's public IP>
|
||||
A app.puter.localhost → <your server's public IP>
|
||||
A *.app.puter.localhost → <your server's public IP>
|
||||
A dev.puter.localhost → <your server's public IP>
|
||||
A *.dev.puter.localhost → <your server's public IP>
|
||||
```
|
||||
|
||||
The wildcards are required — Puter routes via subdomains (`api.*`, `app.*`, etc.) and nginx routes browser S3 traffic via `s3.*` to RustFS.
|
||||
|
||||
## Step 3 — TLS (recommended for public installs) \[Optional\]
|
||||
|
||||
Skip this for a quick local demo. Don't skip it for users typing passwords.
|
||||
|
||||
**Get a wildcard cert.** Easiest path with Let's Encrypt + DNS-01 (works for wildcards):
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --manual --preferred-challenges dns \
|
||||
-d puter.localhost -d "*.puter.localhost" \
|
||||
-d site.puter.localhost -d "*.site.puter.localhost" \
|
||||
-d host.puter.localhost -d "*.host.puter.localhost" \
|
||||
-d app.puter.localhost -d "*.app.puter.localhost" \
|
||||
-d dev.puter.localhost -d "*.dev.puter.localhost"
|
||||
```
|
||||
|
||||
The cert needs to cover `*.puter.localhost` so that `s3.puter.localhost` (browser S3 endpoint), plus Puter's own `api.*` / `app.*` subdomains, all validate.
|
||||
|
||||
Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
|
||||
|
||||
**Wire nginx to use them:**
|
||||
|
||||
1. Open [nginx/nginx.conf](../nginx/nginx.conf), uncomment **both** `# server { listen 443 ssl … }` blocks (one for `s3.*`, one for the catch-all).
|
||||
2. (Optional) Replace the body of the port-80 blocks with `return 301 https://$host$request_uri;` to force HTTPS everywhere.
|
||||
3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `nginx` service.
|
||||
4. In `.env`, uncomment `HTTPS_PORT=443`.
|
||||
5. In `config.json`, switch:
|
||||
```json
|
||||
{ "protocol": "https", "pub_port": 443 }
|
||||
```
|
||||
…and update the S3 public endpoint:
|
||||
```json
|
||||
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
|
||||
```
|
||||
|
||||
## Step 4 — Bring it up
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
First boot takes ~30s while MariaDB initialises and Puter applies the schema + default apps. Watch:
|
||||
|
||||
```bash
|
||||
docker compose logs -f puter
|
||||
```
|
||||
|
||||
Healthy startup:
|
||||
|
||||
```
|
||||
[config] override from /etc/puter/config.json
|
||||
[mysql] running migrations from /opt/puter/dist/src/backend/clients/database/migrations/mysql: 2 file(s)
|
||||
[mysql] applied mysql_mig_1.sql (...)
|
||||
[mysql] applied mysql_mig_2.sql (9 statements)
|
||||
```
|
||||
|
||||
Then open **<https://puter.local>** (or `http://` if you skipped TLS). Login is `admin` — the temp password is printed once in the puter container logs on first boot:
|
||||
|
||||
```bash
|
||||
docker compose logs puter | grep tmp_password
|
||||
```
|
||||
|
||||
Change it in Settings after first login.
|
||||
|
||||
## Additional configuration
|
||||
|
||||
All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
|
||||
|
||||
### PostgreSQL database
|
||||
|
||||
> **Community contribution — expect rough edges.** PostgreSQL support was contributed by the community and is not run by Puter.com production. It boots, applies the bundled schema, and exercises the common user/app/fsentry/session/permission/OIDC flows in the integration tests, but less-traveled SQL call sites may still need porting. If you hit a query that doesn't work on Postgres, please open an issue. For production self-hosting today, MariaDB/MySQL and SQLite are the supported defaults.
|
||||
|
||||
The bundled Docker Compose stack still defaults to MariaDB. To use PostgreSQL instead, run a PostgreSQL service yourself, point Puter at it, and use the PostgreSQL migration path:
|
||||
|
||||
```json
|
||||
"database": {
|
||||
"engine": "postgres",
|
||||
"host": "postgres",
|
||||
"port": 5432,
|
||||
"user": "puter",
|
||||
"password": "...",
|
||||
"database": "puter",
|
||||
"migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/postgres"]
|
||||
}
|
||||
```
|
||||
|
||||
You may use `"connectionString": "postgres://puter:...@postgres:5432/puter"` instead of the host/user/password fields. Puter only needs normal PostgreSQL connection details and the migration path; CloudNativePG is one possible Kubernetes operator, but no operator-specific manifests are required by Puter.
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
Used for password resets, email confirmation, and notifications. Without it those flows silently fail.
|
||||
|
||||
```json
|
||||
"email": {
|
||||
"from": "\"Puter\" <[email protected]>",
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"auth": { "user": "...", "pass": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
To require email confirmation before login, also set `"strict_email_verification_required": true`.
|
||||
|
||||
### Sign in with Google (or another OIDC provider)
|
||||
|
||||
```json
|
||||
"oidc": {
|
||||
"providers": {
|
||||
"google": {
|
||||
"client_id": "...apps.googleusercontent.com",
|
||||
"client_secret": "...",
|
||||
"scopes": "openid email profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `https://puter.<your-domain>/auth/oidc/callback/login` to the OAuth client's authorized redirect URIs in the Google Cloud Console. For non-Google providers, replace `google` with a custom id and supply `authorization_endpoint` / `token_endpoint` / `userinfo_endpoint` explicitly.
|
||||
|
||||
### Sign in with Apple
|
||||
|
||||
```json
|
||||
"oidc": {
|
||||
"providers": {
|
||||
"apple": {
|
||||
"client_id": "com.example.your-service-id",
|
||||
"team_id": "YOUR_TEAM_ID",
|
||||
"key_id": "YOUR_KEY_ID",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `client_id` is your Apple Services ID. `team_id`, `key_id`, and `private_key` come from the Apple Developer Portal (Keys section — create a key with "Sign in with Apple" enabled). The `private_key` is the contents of the `.p8` file Apple provides. Add `https://puter.<your-domain>/auth/oidc/callback/login` and `https://puter.<your-domain>/auth/oidc/callback/signup` as return URLs in the Apple Services ID configuration.
|
||||
|
||||
### Sign in with Microsoft
|
||||
|
||||
```json
|
||||
"oidc": {
|
||||
"providers": {
|
||||
"microsoft": {
|
||||
"client_id": "YOUR_APPLICATION_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET_VALUE",
|
||||
"tenant_id": "YOUR_TENANT_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register an app in the Azure Portal (Microsoft Entra ID → App registrations). The `client_id` is the Application (client) ID, `client_secret` is a client secret value, and `tenant_id` is the Directory (tenant) ID. Use `common` as `tenant_id` to allow any Microsoft account (personal and organizational); omit it to default to `common`. Add `https://puter.<your-domain>/auth/oidc/callback/login` and `https://puter.<your-domain>/auth/oidc/callback/signup` as redirect URIs under Authentication in the app registration.
|
||||
|
||||
### AI providers
|
||||
|
||||
Any provider with a key set is auto-enabled. Same shape as `ollama` above:
|
||||
|
||||
```json
|
||||
"providers": {
|
||||
"claude": { "apiKey": "sk-ant-..." },
|
||||
"openai-completion": { "apiKey": "sk-..." },
|
||||
"gemini": { "apiKey": "..." },
|
||||
"openai-image-generation": { "apiKey": "sk-..." }
|
||||
}
|
||||
```
|
||||
|
||||
Full provider list (chat, image, video, TTS, OCR) is in the template.
|
||||
|
||||
### Per-user storage quota
|
||||
|
||||
Default is 100 MB per user.
|
||||
|
||||
```json
|
||||
"storage_capacity": 5368709120, // 5 GB
|
||||
"is_storage_limited": true
|
||||
```
|
||||
|
||||
Set `is_storage_limited: false` for unlimited (bounded by host disk).
|
||||
|
||||
### Captcha on signup / login
|
||||
|
||||
Built-in proof-of-work captcha — no external service needed.
|
||||
|
||||
```json
|
||||
"captcha": { "enabled": true, "difficulty": "medium" }
|
||||
```
|
||||
|
||||
`difficulty` is one of `easy` / `medium` / `hard`.
|
||||
|
||||
### Block disposable email TLDs
|
||||
|
||||
Only enforced when `env: "prod"`.
|
||||
|
||||
```json
|
||||
"blockedEmailDomains": ["mailinator.com", "tempmail.com", "guerrillamail.com"]
|
||||
```
|
||||
|
||||
### Password policy
|
||||
|
||||
```json
|
||||
"min_pass_length": 12
|
||||
```
|
||||
|
||||
### Contact-form recipient
|
||||
|
||||
Where the in-app contact form posts. Defaults to `support@puter.com`.
|
||||
|
||||
```json
|
||||
"support_email": "[email protected]"
|
||||
```
|
||||
|
||||
## Optional: local LLM (Ollama)
|
||||
|
||||
The `ollama` and `ollama-init` services live behind a compose profile so they don't run unless you ask for them. By default, `puter/config/config.json` has `"ollama": { "enabled": false }` — Puter skips the auto-probe entirely. To run a local model:
|
||||
|
||||
1. Flip the config:
|
||||
```json
|
||||
"providers": {
|
||||
"ollama": { "apiBaseUrl": "http://ollama:11434" }
|
||||
}
|
||||
```
|
||||
2. (Optional) Pick a model in `.env`:
|
||||
```bash
|
||||
OLLAMA_DEFAULT_MODEL=tinyllama # default — 1.1B, ~640 MB on disk, ~700 MB RAM
|
||||
# Other tiny picks: qwen2.5:0.5b, llama3.2:1b
|
||||
# Larger / better: phi3.5, llama3.2, mistral
|
||||
```
|
||||
3. Bring up with the `ai` profile:
|
||||
```bash
|
||||
docker compose --profile ai up -d
|
||||
docker compose logs -f ollama-init
|
||||
```
|
||||
`ollama-init` exits 0 once the model is pulled. Subsequent boots find the model already on disk and the pull is a fast no-op.
|
||||
|
||||
Without `--profile ai`, the `ollama` containers stay down and Puter (with `enabled: false`) doesn't try to reach them — the rest of the stack runs identically.
|
||||
|
||||
For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama` service in [docker-compose.yml](../docker-compose.yml). Requires `nvidia-container-toolkit` on the host.
|
||||
|
||||
## Building from source instead of pulling
|
||||
|
||||
If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.yml](../docker-compose.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Managing running backend
|
||||
|
||||
```bash
|
||||
# update
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# logs
|
||||
docker compose logs -f puter
|
||||
|
||||
# stop, keep data
|
||||
docker compose down
|
||||
|
||||
# stop, NUKE all state (irreversible)
|
||||
docker compose down
|
||||
rm -rf puter/data
|
||||
```
|
||||
|
||||
Migrations re-apply idempotently across pulls. Volumes are preserved.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Site loads but I get "Bad Gateway" / nginx errors.**
|
||||
The puter container failed to come up. `docker compose logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
|
||||
|
||||
**Login screen says "admin password not set".**
|
||||
First-boot temp password is logged once. Find it: `docker compose logs puter | grep "tmp_password"`. After login, change it in Settings.
|
||||
|
||||
**Healthcheck reports unhealthy but the site works.**
|
||||
The healthcheck hits `puter.localhost:4100/test` from inside the container. If you changed `domain` or `port`, the check still uses defaults. The site itself is fine.
|
||||
|
||||
**Nothing resolves at `puter.example.com` after DNS changes.**
|
||||
DNS propagates slowly. `dig puter.example.com` and `dig api.puter.example.com` should both return your server IP. If not, give it 5–60 minutes.
|
||||
|
||||
**`docker compose up` hangs at "waiting for service to be healthy".**
|
||||
`docker compose ps` shows which container is unhealthy. MariaDB takes ~20–30s on a cold boot; everything else under 5s. If something stays unhealthy, `logs <service>` will tell you why.
|
||||
|
||||
**`Error: DynamoDB aws config requires both access_key and secret_key`.**
|
||||
You wrote `accessKeyId` / `secretAccessKey` under `dynamo.aws`. That config block uses snake_case (`access_key` / `secret_key`). Only the `s3.s3Config` block uses camelCase.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Testing with Email
|
||||
|
||||
Testing anything involving email is really simple using [mailhog](https://github.com/mailhog/MailHog)
|
||||
|
||||
### Step 1: Configure email service
|
||||
|
||||
In your `config.json` for Puter (`volatile/config/config.json` usually, `/var/puter/config.json` in containers),
|
||||
add this entry to the `"services`" map:
|
||||
|
||||
```javascript
|
||||
"services": {
|
||||
|
||||
// ... there are probably other service configs
|
||||
|
||||
"email": {
|
||||
"host": "localhost",
|
||||
"port": 1025
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Install and run mailhog
|
||||
|
||||
Follow the instructions on [MailHog](https://github.com/mailhog/MailHog)'s
|
||||
repository, or install through your distro's package manager.
|
||||
|
||||
Run the command: `mailhog`.
|
||||
|
||||
You should now have an inbox at [http://127.0.0.1:8025](http://127.0.0.1:8025).
|
||||
|
||||
Every email that Puter sends will show up on this page.
|
||||
+280
-12
@@ -1,25 +1,293 @@
|
||||
---
|
||||
version: "3.8"
|
||||
# Self-hosted Puter — full stack.
|
||||
#
|
||||
# Brings up Puter + every external service it needs:
|
||||
# - nginx : reverse proxy (mirrors prod ALB; handles TLS + Host fan-out)
|
||||
# - valkey : redis-compatible cache / rate-limiter backend
|
||||
# - mariadb : SQL database (Puter applies its schema on first boot)
|
||||
# - dynamo : DynamoDB-local (KV store; Puter creates the table itself)
|
||||
# - s3 : RustFS — S3-compatible object storage
|
||||
# - s3-init : one-shot init container that creates the bucket
|
||||
# - puter : the application
|
||||
#
|
||||
# Quick start:
|
||||
# 1. Copy .env.example to .env (or set the variables in your shell).
|
||||
# 2. Drop a config.json into ./puter/config/ — see selfhosted/full-stack.md
|
||||
# for the example that pairs with this compose.
|
||||
# 3. docker compose up -d
|
||||
#
|
||||
# Easiest path:
|
||||
# curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
|
||||
# grabs this file, generates secrets, writes .env + config.json, and runs
|
||||
# the compose up for you.
|
||||
#
|
||||
# Production:
|
||||
# - Always replace the default passwords / S3 keys / Puter secrets.
|
||||
# - Front Puter with TLS-terminating reverse proxy (Caddy / nginx).
|
||||
# - Move state-bearing volumes to a backed-up location.
|
||||
|
||||
services:
|
||||
puter:
|
||||
container_name: puter
|
||||
image: ghcr.io/heyputer/puter:latest
|
||||
pull_policy: always
|
||||
# build: ./
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: puter-valkey
|
||||
restart: unless-stopped
|
||||
# Run as a single-node cluster so Puter's ioredis Cluster client
|
||||
# (the only mode it speaks) can connect. On first boot we assign all
|
||||
# 16384 slots to ourselves; subsequent boots find them already in
|
||||
# nodes.conf and skip. `cluster-require-full-coverage no` keeps reads
|
||||
# working if we ever land partial slots.
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
valkey-server \
|
||||
--port 6379 \
|
||||
--cluster-enabled yes \
|
||||
--cluster-config-file /data/nodes.conf \
|
||||
--cluster-node-timeout 5000 \
|
||||
--cluster-require-full-coverage no \
|
||||
--cluster-announce-ip valkey \
|
||||
--cluster-announce-port 6379 \
|
||||
--cluster-announce-bus-port 16379 \
|
||||
--appendonly yes \
|
||||
--save "60 1" &
|
||||
SERVER_PID=$$!
|
||||
until valkey-cli -p 6379 PING > /dev/null 2>&1; do sleep 0.5; done
|
||||
if ! valkey-cli -p 6379 CLUSTER NODES | grep -q '0-16383'; then
|
||||
valkey-cli -p 6379 CLUSTER ADDSLOTSRANGE 0 16383
|
||||
fi
|
||||
wait $$SERVER_PID
|
||||
volumes:
|
||||
# `:z` is an SELinux relabel hint for Fedora/RHEL hosts (no-op
|
||||
# everywhere else) — without it those distros deny container
|
||||
# access to the bind mount and the service loops on EACCES.
|
||||
- ./puter/data/valkey:/data:z
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "valkey-cli -p 6379 cluster info | grep -q cluster_state:ok"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
container_name: puter-mariadb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '4100:4100'
|
||||
environment:
|
||||
# TZ: Europe/Paris
|
||||
# CONFIG_PATH: /etc/puter
|
||||
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root-change-me}
|
||||
MARIADB_DATABASE: ${MARIADB_DATABASE:-puter}
|
||||
MARIADB_USER: ${MARIADB_USER:-puter}
|
||||
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-puter-change-me}
|
||||
volumes:
|
||||
- ./puter/data/mariadb:/var/lib/mysql:z
|
||||
healthcheck:
|
||||
# `healthcheck.sh` ships with the mariadb image; --connect verifies
|
||||
# the server is accepting auth, not just listening on the socket.
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 30s
|
||||
|
||||
dynamo:
|
||||
# Puter creates the `store-kv-v1` table itself on startup
|
||||
# (config.dynamo.bootstrapTables = true does the work).
|
||||
image: amazon/dynamodb-local:latest
|
||||
container_name: puter-dynamo
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
working_dir: /home/dynamodblocal
|
||||
command:
|
||||
- "-jar"
|
||||
- "DynamoDBLocal.jar"
|
||||
- "-sharedDb"
|
||||
- "-dbPath"
|
||||
- "/home/dynamodblocal/data"
|
||||
volumes:
|
||||
- ./puter/data/dynamo:/home/dynamodblocal/data:z
|
||||
|
||||
s3:
|
||||
# RustFS — S3-compatible object storage. Drop-in alternative:
|
||||
# MinIO (image: minio/minio, command: ["server", "/data", "--console-address", ":9001"]).
|
||||
image: rustfs/rustfs:latest
|
||||
container_name: puter-s3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-puter}
|
||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
|
||||
volumes:
|
||||
- ./puter/data/s3:/data:z
|
||||
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
|
||||
# which preserves the Host header for S3 signature validation and
|
||||
# rides the same TLS termination as Puter. Uncomment to also expose
|
||||
# 9000 directly on the host for `aws-cli` / debugging.
|
||||
# ports:
|
||||
# - "9000:9000"
|
||||
healthcheck:
|
||||
# RustFS exposes /health on the S3 port. Use wget (curl is not in
|
||||
# the slim image).
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget -qO- --tries=1 --timeout=2 http://localhost:9000/health || exit 1",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
s3-init:
|
||||
# One-shot container that creates the `puter-local` bucket on first
|
||||
# boot. Exits 0 once the bucket exists; stays exited 0 thereafter.
|
||||
image: amazon/aws-cli:latest
|
||||
container_name: puter-s3-init
|
||||
depends_on:
|
||||
s3:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-puter}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
endpoint=http://s3:9000
|
||||
bucket=${S3_BUCKET:-puter-local}
|
||||
if aws --endpoint-url "$$endpoint" s3api head-bucket --bucket "$$bucket" 2>/dev/null; then
|
||||
echo "bucket $$bucket already exists"
|
||||
else
|
||||
echo "creating bucket $$bucket"
|
||||
aws --endpoint-url "$$endpoint" s3 mb "s3://$$bucket"
|
||||
fi
|
||||
restart: "no"
|
||||
|
||||
# ── Optional: local LLM ───────────────────────────────────────────
|
||||
# Behind the `ai` compose profile — only starts when explicitly opted
|
||||
# into. Bring up with:
|
||||
# docker compose --profile ai up -d
|
||||
# When enabled, also set in your `puter/config/config.json`:
|
||||
# "providers": { "ollama": { "apiBaseUrl": "http://ollama:11434" } }
|
||||
# When NOT enabled, set:
|
||||
# "providers": { "ollama": { "enabled": false } }
|
||||
# otherwise Puter spams `ECONNREFUSED 127.0.0.1:11434` on startup.
|
||||
ollama:
|
||||
profiles: ["ai"]
|
||||
# CPU-only out of the box; uncomment the GPU `deploy:` block below
|
||||
# if you've got nvidia-docker for much faster inference. Disk + RAM
|
||||
# scale with the model — `tinyllama` (1.1B, ~640 MB on disk, ~700
|
||||
# MB RAM) is the cheapest sane default. Swap via OLLAMA_DEFAULT_MODEL.
|
||||
image: ollama/ollama:latest
|
||||
container_name: puter-ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./puter/data/ollama:/root/.ollama:z
|
||||
# Uncomment to expose Ollama directly on the host (`localhost:11434`)
|
||||
# for `ollama` CLI / OpenAI-API compatible tools. Internal-only by default.
|
||||
# ports:
|
||||
# - "11434:11434"
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
# GPU passthrough (NVIDIA). Requires nvidia-container-toolkit on host.
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
|
||||
ollama-init:
|
||||
profiles: ["ai"]
|
||||
# One-shot — ensures the default model is present. `ollama pull` is
|
||||
# idempotent: present-and-up-to-date → fast no-op; missing → downloads.
|
||||
image: ollama/ollama:latest
|
||||
container_name: puter-ollama-init
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
OLLAMA_HOST: http://ollama:11434
|
||||
OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-tinyllama}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
echo "[ollama-init] ensuring $${OLLAMA_DEFAULT_MODEL}"
|
||||
ollama pull "$${OLLAMA_DEFAULT_MODEL}"
|
||||
echo "[ollama-init] done"
|
||||
restart: "no"
|
||||
|
||||
puter:
|
||||
image: ghcr.io/heyputer/puter:main
|
||||
pull_policy: always
|
||||
# Uncomment to build from this directory instead of pulling the published
|
||||
# image. Also flip pull_policy to `never` so compose doesn't overwrite
|
||||
# your local build by re-pulling :latest.
|
||||
# build:
|
||||
# context: .
|
||||
# # buildx-only: cross-compile to both archs in a single push
|
||||
# # platforms:
|
||||
# # - linux/amd64
|
||||
# # - linux/arm64
|
||||
container_name: puter
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
dynamo:
|
||||
condition: service_started
|
||||
s3-init:
|
||||
condition: service_completed_successfully
|
||||
# Internal-only: nginx reaches it on the compose network. Uncomment
|
||||
# to also expose port 4100 directly on the host (useful for debugging).
|
||||
# ports:
|
||||
# - "4100:4100"
|
||||
expose:
|
||||
- "4100"
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
volumes:
|
||||
- ./puter/config:/etc/puter
|
||||
- ./puter/data:/var/puter
|
||||
# Drop your config.json here — see selfhosted/full-stack.md.
|
||||
- ./puter/config:/etc/puter:z
|
||||
# Persistent runtime data (anything your config points at /var/puter).
|
||||
- ./puter/data/puter:/var/puter:z
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: puter-nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
puter:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
# Uncomment when you enable TLS in nginx/nginx.conf:
|
||||
# - "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro,z
|
||||
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
|
||||
- ./puter/tls:/etc/nginx/tls:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
+96
-170
@@ -1,204 +1,130 @@
|
||||
import js from '@eslint/js';
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
import tseslintPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tseslintParser from '@typescript-eslint/parser';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import bangSpaceIf from './eslint/bang-space-if.js';
|
||||
import controlStructureSpacing from './eslint/control-structure-spacing.js';
|
||||
import spaceUnaryOpsWithException from './eslint/space-unary-ops-with-exception.js';
|
||||
|
||||
export const rules = {
|
||||
'no-invalid-this': 'error',
|
||||
'no-unused-vars': ['error', {
|
||||
vars: 'all',
|
||||
args: 'after-used',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: false,
|
||||
ignoreUsingDeclarations: false,
|
||||
reportUsedIgnorePattern: false,
|
||||
argsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
// typescript-eslint's flat/recommended preset is an array of configs (base +
|
||||
// eslint-recommended overrides + recommended rules). Flatten its rules so we
|
||||
// can apply them via our own `files`-scoped blocks.
|
||||
const tsRecommendedRules = tseslintPlugin.configs['flat/recommended'].reduce(
|
||||
(acc, cfg) => ({ ...acc, ...(cfg.rules ?? {}) }),
|
||||
{},
|
||||
);
|
||||
|
||||
}],
|
||||
curly: ['error', 'multi-line'],
|
||||
'@stylistic/curly-newline': ['error', 'always'],
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/indent': ['error', 4, {
|
||||
SwitchCase: 1,
|
||||
CallExpression: {
|
||||
arguments: 1,
|
||||
},
|
||||
}],
|
||||
'@stylistic/indent-binary-ops': ['error', 4],
|
||||
'@stylistic/array-bracket-newline': ['error', 'consistent'],
|
||||
'@stylistic/semi': ['error', 'always'],
|
||||
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true }],
|
||||
'@stylistic/function-call-argument-newline': ['error', 'consistent'],
|
||||
'@stylistic/function-paren-newline': ['error', 'multiline-arguments'],
|
||||
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
|
||||
'@stylistic/space-before-function-paren': 'error',
|
||||
'@stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
|
||||
'@stylistic/keyword-spacing': ['error', { 'before': true, 'after': true }],
|
||||
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
|
||||
'@stylistic/comma-spacing': ['error', { 'before': false, 'after': true }],
|
||||
'@stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||
'@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }],
|
||||
'@stylistic/dot-location': ['error', 'property'],
|
||||
'@stylistic/space-infix-ops': ['error'],
|
||||
'no-undef': 'error',
|
||||
'custom/control-structure-spacing': 'error',
|
||||
'custom/bang-space-if': 'error',
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/space-before-blocks': ['error', 'always'],
|
||||
'prefer-template': 'error',
|
||||
'@stylistic/no-mixed-spaces-and-tabs': ['error', 'smart-tabs'],
|
||||
'custom/space-unary-ops-with-exception': ['error', { words: true, nonwords: false }],
|
||||
'@stylistic/no-multi-spaces': ['error', { exceptions: { 'VariableDeclarator': true } }],
|
||||
'@stylistic/type-annotation-spacing': 'error',
|
||||
'@stylistic/type-generic-spacing': 'error',
|
||||
'@stylistic/type-named-tuple-spacing': ['error'],
|
||||
'no-use-before-define': ['error', {
|
||||
'functions': false,
|
||||
}],
|
||||
'@stylistic/array-bracket-spacing': ['error', 'never'],
|
||||
'@stylistic/linebreak-style': ['error', 'unix'],
|
||||
'no-useless-computed-key': 'error',
|
||||
'no-sequences': [
|
||||
'error', {
|
||||
allowInParentheses: false,
|
||||
},
|
||||
],
|
||||
const prettierRules = {
|
||||
...prettierConfig.rules,
|
||||
'prettier/prettier': 'error',
|
||||
};
|
||||
|
||||
const tsRules = {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrors: 'none' }],
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
const unusedVarsOptions = {
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
};
|
||||
|
||||
const sharedPlugins = {
|
||||
js,
|
||||
'@stylistic': stylistic,
|
||||
custom: {
|
||||
rules: {
|
||||
'control-structure-spacing': controlStructureSpacing,
|
||||
'bang-space-if': bangSpaceIf,
|
||||
'space-unary-ops-with-exception': spaceUnaryOpsWithException,
|
||||
},
|
||||
},
|
||||
const preferConstOptions = {
|
||||
destructuring: 'all',
|
||||
ignoreReadBeforeAssign: false,
|
||||
};
|
||||
|
||||
const sharedJsConfig = {
|
||||
rules,
|
||||
plugins: sharedPlugins,
|
||||
const lintedGlobals = {
|
||||
...globals.node,
|
||||
extension: 'readonly',
|
||||
config: 'readonly',
|
||||
global_config: 'readonly',
|
||||
};
|
||||
|
||||
const recommendedJsConfig = {
|
||||
...sharedJsConfig,
|
||||
extends: ['js/recommended'],
|
||||
};
|
||||
const jsFiles = [
|
||||
'src/backend/**/*.{js,mjs,cjs}',
|
||||
'extensions/**/*.{js,mjs,cjs}',
|
||||
];
|
||||
|
||||
const createTsConfig = ({ files, project, ignores = [], globals: tsGlobals }) => ({
|
||||
const tsIgnores = [
|
||||
'**/*.test.ts',
|
||||
'**/*.test.mts',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.mts',
|
||||
'src/backend/test/**',
|
||||
'src/backend/tools/**',
|
||||
'src/backend/vitest.config.ts',
|
||||
'src/backend/vitest.bench.config.ts',
|
||||
];
|
||||
|
||||
const createTsConfig = ({ files, project }) => ({
|
||||
files,
|
||||
ignores,
|
||||
ignores: tsIgnores,
|
||||
languageOptions: {
|
||||
parser: tseslintParser,
|
||||
...(tsGlobals ? { globals: tsGlobals } : {}),
|
||||
globals: lintedGlobals,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project,
|
||||
projectService: { defaultProject: project },
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslintPlugin,
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
rules: {
|
||||
...tsRecommendedRules,
|
||||
...prettierRules,
|
||||
'@typescript-eslint/no-unused-vars': ['error', unusedVarsOptions],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'prefer-const': ['error', preferConstOptions],
|
||||
},
|
||||
rules: tsRules,
|
||||
});
|
||||
|
||||
const backendConfig = {
|
||||
...recommendedJsConfig,
|
||||
files: [
|
||||
'src/backend/**/*.{js,mjs,cjs,ts}',
|
||||
'src/putility/**/*.{js,mjs,cjs,ts}',
|
||||
],
|
||||
ignores: [
|
||||
'**/*.test.js',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.mts',
|
||||
],
|
||||
languageOptions: { globals: globals.node },
|
||||
};
|
||||
|
||||
const testConfig = {
|
||||
...sharedJsConfig,
|
||||
files: [
|
||||
'**/*.test.js',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.mts',
|
||||
],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.vitest } },
|
||||
};
|
||||
|
||||
const extensionConfig = {
|
||||
...recommendedJsConfig,
|
||||
files: ['extensions/**/*.{js,mjs,cjs,ts}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
extension: 'readonly',
|
||||
config: 'readonly',
|
||||
global_config: 'readonly',
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const frontendConfig = {
|
||||
...recommendedJsConfig,
|
||||
files: ['**/*.{js,mjs,cjs,ts}', 'src/gui/src/**/*.js'],
|
||||
ignores: [
|
||||
'src/backend/**/*.{js,mjs,cjs,ts}',
|
||||
'extensions/**/*.{js,mjs,cjs,ts}',
|
||||
'submodules/**',
|
||||
'**/*.test.{js,ts,mts,mjs}',
|
||||
'**/*.min.js',
|
||||
'**/*.min.cjs',
|
||||
'**/*.min.mjs',
|
||||
'**/socket.io.js',
|
||||
'**/dist/*.js',
|
||||
'src/gui/src/lib/**',
|
||||
'src/gui/dist/**',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.jquery,
|
||||
i18n: 'readonly',
|
||||
puter: 'readonly',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: [
|
||||
'**/*.dbmig.js',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'volatile/**',
|
||||
'node_modules/**',
|
||||
'puter.js/**',
|
||||
'apps/**',
|
||||
'experiment/**',
|
||||
'workers/**',
|
||||
'src/public/**',
|
||||
'src/gui/**',
|
||||
'src/docs/**',
|
||||
'src/puter-js/**',
|
||||
'src/worker/**',
|
||||
'submodules/**',
|
||||
'tests/**',
|
||||
'tools/**',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: jsFiles,
|
||||
ignores: ['**/*.test.js'],
|
||||
plugins: {
|
||||
js,
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
extends: ['js/recommended'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: lintedGlobals,
|
||||
},
|
||||
rules: {
|
||||
...prettierRules,
|
||||
'no-unused-vars': ['error', unusedVarsOptions],
|
||||
'prefer-const': ['error', preferConstOptions],
|
||||
},
|
||||
},
|
||||
createTsConfig({
|
||||
files: ['**/*.test.ts', '**/*.test.mts', '**/*.test.setup.ts'],
|
||||
ignores: ['tests/playwright/tests/**/*.ts'],
|
||||
project: './tests/tsconfig.json',
|
||||
globals: { ...globals.node, ...globals.vitest },
|
||||
}),
|
||||
createTsConfig({
|
||||
files: ['**/*.ts'],
|
||||
ignores: ['**/*.test.ts', '**/*.test.mts', 'extensions/**/*.ts'],
|
||||
files: ['src/backend/**/*.ts', 'extensions/**/*.ts'],
|
||||
project: './tsconfig.json',
|
||||
}),
|
||||
createTsConfig({
|
||||
files: ['extensions/**/*.ts'],
|
||||
project: './extensions/tsconfig.json',
|
||||
}),
|
||||
backendConfig,
|
||||
testConfig,
|
||||
extensionConfig,
|
||||
frontendConfig,
|
||||
]);
|
||||
@@ -1,69 +0,0 @@
|
||||
// eslint-plugin-bang-space-if/index.js
|
||||
'use strict';
|
||||
|
||||
/** @type {import('eslint').ESLint.Plugin} */
|
||||
export default {
|
||||
meta: {
|
||||
type: 'layout',
|
||||
docs: {
|
||||
description:
|
||||
"Require a space after a top-level '!' in an if(...) condition (e.g., `if ( ! entry )`).",
|
||||
recommended: false,
|
||||
},
|
||||
fixable: 'whitespace',
|
||||
schema: [], // no options
|
||||
},
|
||||
create (context) {
|
||||
const source = context.getSourceCode();
|
||||
|
||||
// Unwrap ParenthesizedExpression layers, if any
|
||||
function unwrapParens (node) {
|
||||
let n = node;
|
||||
// ESLint/ESTree: ParenthesizedExpression is supported by espree
|
||||
while ( n && n.type === 'ParenthesizedExpression' ) {
|
||||
n = n.expression;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
return {
|
||||
IfStatement (ifNode) {
|
||||
const testRaw = ifNode.test;
|
||||
if ( ! testRaw ) return;
|
||||
|
||||
const test = unwrapParens(testRaw);
|
||||
if ( !test || test.type !== 'UnaryExpression' || test.operator !== '!' ) {
|
||||
return; // only top-level `!` expressions
|
||||
}
|
||||
|
||||
// Ignore boolean-cast `!!x` cases to avoid producing `! !x`
|
||||
if ( test.argument && test.argument.type === 'UnaryExpression' && test.argument.operator === '!' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab operator and argument tokens
|
||||
const opToken = source.getFirstToken(test); // should be '!'
|
||||
const argToken = source.getTokenAfter(opToken, { includeComments: false });
|
||||
if ( !opToken || !argToken ) return;
|
||||
|
||||
// Compute current whitespace between '!' and the argument
|
||||
const between = source.text.slice(opToken.range[1], argToken.range[0]);
|
||||
|
||||
// We want exactly one space
|
||||
if ( between === ' ' ) return;
|
||||
|
||||
context.report({
|
||||
node: test,
|
||||
loc: {
|
||||
start: opToken.loc.end,
|
||||
end: argToken.loc.start,
|
||||
},
|
||||
message: "Expected a single space after top-level '!' in if(...) condition.",
|
||||
fix (fixer) {
|
||||
return fixer.replaceTextRange([opToken.range[1], argToken.range[0]], ' ');
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};;;;
|
||||
@@ -1,206 +0,0 @@
|
||||
export default {
|
||||
meta: {
|
||||
type: 'layout',
|
||||
docs: {
|
||||
description: 'enforce spacing inside parentheses for control structures only',
|
||||
category: 'Stylistic Issues',
|
||||
},
|
||||
fixable: 'whitespace',
|
||||
schema: [],
|
||||
messages: {
|
||||
missingSpaceAfterOpen: 'Missing space after opening parenthesis in control structure.',
|
||||
missingSpaceBeforeClose: 'Missing space before closing parenthesis in control structure.',
|
||||
unexpectedSpaceAfterOpen: 'Unexpected space after opening parenthesis in function call.',
|
||||
unexpectedSpaceBeforeClose: 'Unexpected space before closing parenthesis in function call.',
|
||||
},
|
||||
},
|
||||
|
||||
create (context) {
|
||||
const sourceCode = context.getSourceCode();
|
||||
|
||||
function checkControlStructureSpacing (node) {
|
||||
// For control structures, we need to find the parentheses around the condition/test
|
||||
let conditionNode;
|
||||
|
||||
if ( node.type === 'IfStatement' || node.type === 'WhileStatement' || node.type === 'DoWhileStatement' ) {
|
||||
conditionNode = node.test;
|
||||
} else if ( node.type === 'ForStatement' || node.type === 'ForInStatement' || node.type === 'ForOfStatement' ) {
|
||||
// For loops, we want the parentheses around the entire for clause
|
||||
conditionNode = node;
|
||||
} else if ( node.type === 'SwitchStatement' ) {
|
||||
conditionNode = node.discriminant;
|
||||
} else if ( node.type === 'CatchClause' ) {
|
||||
conditionNode = node.param;
|
||||
}
|
||||
|
||||
if ( ! conditionNode ) return;
|
||||
|
||||
// Find the opening paren - it should be right before the condition starts
|
||||
const openParen = sourceCode.getTokenBefore(conditionNode, token => token.value === '(');
|
||||
if ( !openParen || openParen.value !== '(' ) return;
|
||||
|
||||
// Find the closing paren - it should be right after the condition ends
|
||||
const closeParen = sourceCode.getTokenAfter(conditionNode, token => token.value === ')');
|
||||
if ( !closeParen || closeParen.value !== ')' ) return;
|
||||
|
||||
const afterOpen = sourceCode.getTokenAfter(openParen);
|
||||
const beforeClose = sourceCode.getTokenBefore(closeParen);
|
||||
|
||||
{
|
||||
const contentBetweenParens = sourceCode.getText().slice(openParen.range[1], closeParen.range[0]);
|
||||
const isSingleCharVariable = /^\s*[a-zA-Z_$]\s*$/.test(contentBetweenParens);
|
||||
|
||||
// Skip spacing requirements for single character variables
|
||||
if ( isSingleCharVariable ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Control structures should have spacing
|
||||
if ( afterOpen && openParen.range[1] === afterOpen.range[0] ) {
|
||||
context.report({
|
||||
node,
|
||||
loc: openParen.loc,
|
||||
messageId: 'missingSpaceAfterOpen',
|
||||
fix (fixer) {
|
||||
return fixer.insertTextAfter(openParen, ' ');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ( beforeClose && beforeClose.range[1] === closeParen.range[0] ) {
|
||||
context.report({
|
||||
node,
|
||||
loc: closeParen.loc,
|
||||
messageId: 'missingSpaceBeforeClose',
|
||||
fix (fixer) {
|
||||
return fixer.insertTextBefore(closeParen, ' ');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkForLoopSpacing (node) {
|
||||
// For loops are special - we need to find the opening paren after the 'for' keyword
|
||||
// and the closing paren before the body
|
||||
const forKeyword = sourceCode.getFirstToken(node);
|
||||
if ( !forKeyword || forKeyword.value !== 'for' ) return;
|
||||
|
||||
const openParen = sourceCode.getTokenAfter(forKeyword, token => token.value === '(');
|
||||
if ( ! openParen ) return;
|
||||
|
||||
// The closing paren should be right before the body
|
||||
const closeParen = sourceCode.getTokenBefore(node.body, token => token.value === ')');
|
||||
if ( ! closeParen ) return;
|
||||
|
||||
const afterOpen = sourceCode.getTokenAfter(openParen);
|
||||
const beforeClose = sourceCode.getTokenBefore(closeParen);
|
||||
|
||||
if ( afterOpen && openParen.range[1] === afterOpen.range[0] ) {
|
||||
context.report({
|
||||
node,
|
||||
loc: openParen.loc,
|
||||
messageId: 'missingSpaceAfterOpen',
|
||||
fix (fixer) {
|
||||
return fixer.insertTextAfter(openParen, ' ');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ( beforeClose && beforeClose.range[1] === closeParen.range[0] ) {
|
||||
context.report({
|
||||
node,
|
||||
loc: closeParen.loc,
|
||||
messageId: 'missingSpaceBeforeClose',
|
||||
fix (fixer) {
|
||||
return fixer.insertTextBefore(closeParen, ' ');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkFunctionCallSpacing (node) {
|
||||
// Find the opening parenthesis for this function call
|
||||
const openParen = sourceCode.getFirstToken(node, token => token.value === '(');
|
||||
const closeParen = sourceCode.getLastToken(node, token => token.value === ')');
|
||||
|
||||
if ( !openParen || !closeParen ) return;
|
||||
// Defer multi-line call/new formatting to stylistic paren/argument rules.
|
||||
if ( openParen.loc.start.line !== closeParen.loc.end.line ) return;
|
||||
|
||||
const afterOpen = sourceCode.getTokenAfter(openParen);
|
||||
const beforeClose = sourceCode.getTokenBefore(closeParen);
|
||||
|
||||
// Function calls should NOT have spacing on the same line (multi-line calls are allowed)
|
||||
if ( afterOpen && openParen.range[1] !== afterOpen.range[0] ) {
|
||||
const spaceAfter = sourceCode.getText().slice(openParen.range[1], afterOpen.range[0]);
|
||||
if ( /^\s+$/.test(spaceAfter) && !spaceAfter.includes('\n') ) {
|
||||
context.report({
|
||||
node,
|
||||
loc: openParen.loc,
|
||||
messageId: 'unexpectedSpaceAfterOpen',
|
||||
fix (fixer) {
|
||||
return fixer.removeRange([openParen.range[1], afterOpen.range[0]]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ( beforeClose && beforeClose.range[1] !== closeParen.range[0] ) {
|
||||
const spaceBefore = sourceCode.getText().slice(beforeClose.range[1], closeParen.range[0]);
|
||||
if ( /^\s+$/.test(spaceBefore) && !spaceBefore.includes('\n') ) {
|
||||
context.report({
|
||||
node,
|
||||
loc: closeParen.loc,
|
||||
messageId: 'unexpectedSpaceBeforeClose',
|
||||
fix (fixer) {
|
||||
return fixer.removeRange([beforeClose.range[1], closeParen.range[0]]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Control structures that should have spacing
|
||||
IfStatement (node) {
|
||||
checkControlStructureSpacing(node);
|
||||
},
|
||||
WhileStatement (node) {
|
||||
checkControlStructureSpacing(node);
|
||||
},
|
||||
DoWhileStatement (node) {
|
||||
checkControlStructureSpacing(node);
|
||||
},
|
||||
SwitchStatement (node) {
|
||||
checkControlStructureSpacing(node);
|
||||
},
|
||||
CatchClause (node) {
|
||||
if ( node.param ) {
|
||||
checkControlStructureSpacing(node);
|
||||
}
|
||||
},
|
||||
|
||||
// For loops need special handling
|
||||
ForStatement (node) {
|
||||
checkForLoopSpacing(node);
|
||||
},
|
||||
ForInStatement (node) {
|
||||
checkForLoopSpacing(node);
|
||||
},
|
||||
ForOfStatement (node) {
|
||||
checkForLoopSpacing(node);
|
||||
},
|
||||
|
||||
// Function calls that should NOT have spacing
|
||||
CallExpression (node) {
|
||||
checkFunctionCallSpacing(node);
|
||||
},
|
||||
NewExpression (node) {
|
||||
if ( node.arguments.length > 0 || sourceCode.getLastToken(node).value === ')' ) {
|
||||
checkFunctionCallSpacing(node);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import tseslintPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
|
||||
const backendLanguageOptions = {
|
||||
globals: {
|
||||
// Current, intentionally supported globals
|
||||
extension: 'readonly',
|
||||
config: 'readonly',
|
||||
global_config: 'readonly',
|
||||
|
||||
// Older not entirely ideal globals
|
||||
use: 'readonly', // <-- older import mechanism
|
||||
def: 'readonly', // <-- older import mechanism
|
||||
kv: 'readonly', // <-- should be passed/imported
|
||||
ll: 'readonly', // <-- questionable
|
||||
|
||||
// Language/environment globals
|
||||
...globals.node,
|
||||
},
|
||||
};
|
||||
|
||||
const mandatoryRules = {
|
||||
'no-undef': 'error',
|
||||
'no-use-before-define': ['error', {
|
||||
'functions': false,
|
||||
}],
|
||||
'no-invalid-this': 'warn',
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: [
|
||||
'src/backend/src/modules/apps/AppInformationService.js', // TEMPORARY - SHOULD BE FIXED!
|
||||
'src/backend/src/services/worker/WorkerService.js', // TEMPORARY - SHOULD BE FIXED!
|
||||
'src/backend/src/public/**/*', // We may be able to delete this! I don't think it's used
|
||||
|
||||
// These files run in the worker environment, so these rules don't apply
|
||||
'src/backend/src/services/worker/dist/**/*.{js,cjs,mjs}',
|
||||
'src/backend/src/services/worker/src/**/*.{js,cjs,mjs}',
|
||||
'src/backend/src/services/worker/template/puter-portable.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslintPlugin,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/backend/**/*.{js,mjc,cjs}',
|
||||
'extensions/**/*.{js,mjc,cjs}',
|
||||
],
|
||||
ignores: [
|
||||
'src/backend/src/services/database/sqlite_setup/**/*.js',
|
||||
],
|
||||
rules: mandatoryRules,
|
||||
languageOptions: {
|
||||
...backendLanguageOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/backend/src/services/database/sqlite_setup/**/*.js',
|
||||
],
|
||||
rules: mandatoryRules,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
read: 'readonly',
|
||||
write: 'readonly',
|
||||
log: 'readonly',
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/backend/**/*.{ts}',
|
||||
'extensions/**/*.{ts}',
|
||||
],
|
||||
rules: mandatoryRules,
|
||||
languageOptions: {
|
||||
...backendLanguageOptions,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1,37 +0,0 @@
|
||||
import ruleComposer from 'eslint-rule-composer';
|
||||
|
||||
// Adjust this require to match the package you use for the rule.
|
||||
// For eslint-stylistic v2+ the package is "@stylistic/eslint-plugin"
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
const baseRule = stylistic.rules['space-unary-ops'];
|
||||
|
||||
// unwrap nested parentheses
|
||||
function unwrapParens (node) {
|
||||
let n = node;
|
||||
while ( n && n.type === 'ParenthesizedExpression' ) n = n.expression;
|
||||
return n;
|
||||
}
|
||||
|
||||
function isTopLevelBangInIfTest (node) {
|
||||
if ( !node || node.type !== 'UnaryExpression' || node.operator !== '!' ) return false;
|
||||
|
||||
// Walk up through ancestors manually using .parent (safe in ESLint)
|
||||
let current = node;
|
||||
let parent = current.parent;
|
||||
|
||||
// Skip ParenthesizedExpression layers
|
||||
while ( parent && parent.type === 'ParenthesizedExpression' ) {
|
||||
current = parent;
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
return parent && parent.type === 'IfStatement' && unwrapParens(parent.test) === node;
|
||||
}
|
||||
|
||||
// Filter out ONLY the reports for top-level ! inside if(...) condition
|
||||
export default ruleComposer.filterReports(baseRule, (problem, context) => {
|
||||
const { node } = problem;
|
||||
// If this particular report is about a top-level ! in an if(...) test,
|
||||
// suppress it. Otherwise, keep the original report.
|
||||
return !isTopLevelBangInIfTest(node, context);
|
||||
});
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import backend from '@heyputer/backend';
|
||||
export default backend;
|
||||
Whitespace-only changes.
@@ -1,11 +0,0 @@
|
||||
# Extension System Development Guide
|
||||
|
||||
## Where to find documentation
|
||||
|
||||
### Here
|
||||
Documentation for extensions is [here](src/backend/doc/extensions/README.md).
|
||||
|
||||
### Not Here
|
||||
|
||||
Outdated documentation for extensions is [here](../doc/contributors/extensions/README.md).
|
||||
This documentation may include some topics that are missing from the current documentation. Eventually those topics should be updated and transferred to the current documentation so that this documentation may be removed.
|
||||
Vendored
-207
@@ -1,207 +0,0 @@
|
||||
|
||||
import type APIError from '@heyputer/backend/src/api/APIError.js';
|
||||
import type query from '@heyputer/backend/src/om/query/query';
|
||||
import type { Actor } from '@heyputer/backend/src/services/auth/Actor.js';
|
||||
import type { ServicesMap } from '@heyputer/backend/src/services/BaseService.d.ts';
|
||||
import type { BaseDatabaseAccessService } from '@heyputer/backend/src/services/database/BaseDatabaseAccessService.d.ts';
|
||||
import type { DynamoKVStore } from '@heyputer/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.ts';
|
||||
import type { IUser } from '@heyputer/backend/src/services/User.js';
|
||||
import type { Context } from '@heyputer/backend/src/util/context.js';
|
||||
import type kvjs from '@heyputer/kv.js';
|
||||
import type { RequestHandler } from 'express';
|
||||
import type { Cluster } from 'ioredis';
|
||||
import type FSNodeContext from '../src/backend/src/filesystem/FSNodeContext.js';
|
||||
import type helpers from '../src/backend/src/helpers.js';
|
||||
import type { ICompleteArguments } from '../src/backend/src/services/ai/chat/providers/types.ts';
|
||||
import type * as ExtensionControllerExports from './ExtensionController/src/ExtensionController.ts';
|
||||
import type { s3ClientProvider } from '../src/backend/src/clients/s3/s3ClientProvider.js';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
services: {
|
||||
get: <T extends keyof ServicesMap | (string & {})>(
|
||||
string: T,
|
||||
) => T extends keyof ServicesMap ? ServicesMap[T] : unknown;
|
||||
};
|
||||
actor?: Actor;
|
||||
rawBody: Buffer;
|
||||
/** @deprecated use actor instead */
|
||||
user: IUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { Cluster } from 'ioredis';
|
||||
|
||||
export interface EndpointOptions {
|
||||
allowedMethods?: string[];
|
||||
subdomain?: string;
|
||||
noauth?: boolean;
|
||||
mw?: RequestHandler[];
|
||||
otherOpts?: Record<string, unknown> & {
|
||||
json?: boolean;
|
||||
noReallyItsJson?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Driver interface types
|
||||
interface ParameterDefinition {
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
optional: boolean;
|
||||
}
|
||||
interface MethodDefinition {
|
||||
description: string;
|
||||
parameters: Record<string, ParameterDefinition>;
|
||||
}
|
||||
interface DriverInterface {
|
||||
description: string;
|
||||
methods: Record<string, MethodDefinition>;
|
||||
}
|
||||
|
||||
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
|
||||
|
||||
export type AddRouteFunction = (
|
||||
path: string,
|
||||
options: EndpointOptions,
|
||||
handler: RequestHandler,
|
||||
) => void;
|
||||
|
||||
export type RouterMethods = {
|
||||
[K in HttpMethod]: {
|
||||
(path: string, options: EndpointOptions, handler: RequestHandler): void;
|
||||
(path: string, handler: RequestHandler, options?: EndpointOptions): void;
|
||||
};
|
||||
};
|
||||
|
||||
interface CoreRuntimeModule {
|
||||
util: {
|
||||
helpers: typeof helpers;
|
||||
};
|
||||
redisClient: Cluster;
|
||||
kvjs: kvjs
|
||||
s3ClientProvider: typeof s3ClientProvider;
|
||||
Context: typeof Context;
|
||||
APIError: typeof APIError;
|
||||
}
|
||||
|
||||
interface FilesystemModule {
|
||||
FSNodeContext: FSNodeContext;
|
||||
selectors: unknown;
|
||||
}
|
||||
|
||||
export interface ExtensionEventTypeMap {
|
||||
'metering:registerAvailablePolicies': {
|
||||
availablePolicies: unknown[]
|
||||
},
|
||||
'create.drivers': {
|
||||
createDriver: (interface: string, service: string, executors: any) => any;
|
||||
};
|
||||
'create.permissions': {
|
||||
grant_to_everyone: (permission: string) => void;
|
||||
grant_to_users: (permission: string) => void;
|
||||
};
|
||||
'create.interfaces': {
|
||||
createInterface: (interface: string, interfaces: DriverInterface) => void;
|
||||
};
|
||||
'puter.gui.addons': {
|
||||
bodyContent: string;
|
||||
headContent: string;
|
||||
guiParams: {
|
||||
env: string;
|
||||
app_origin: string;
|
||||
api_origin: string;
|
||||
gui_origin: string;
|
||||
asset_dir: string;
|
||||
launch_options: unknown;
|
||||
app_name_regex: RegExp;
|
||||
app_name_max_length: number;
|
||||
app_title_max_length: number;
|
||||
hosting_domain: string;
|
||||
subdomain_regex: RegExp;
|
||||
subdomain_max_length: number;
|
||||
domain: string;
|
||||
protocol: string;
|
||||
api_base_url: string;
|
||||
app?: { name: string, uid: string } & Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
'app.changed': {
|
||||
app_uid: string;
|
||||
action: 'updated' | 'deleted';
|
||||
};
|
||||
'app.privateAccess.check': {
|
||||
appUid: string;
|
||||
userUid?: string | null;
|
||||
requestHost?: string;
|
||||
requestPath?: string;
|
||||
result: {
|
||||
allowed: boolean;
|
||||
redirectUrl?: string;
|
||||
reason?: string;
|
||||
checkedBy?: string;
|
||||
};
|
||||
};
|
||||
'app.privateAccess.resolveLaunch': {
|
||||
appUid: string;
|
||||
appName?: string;
|
||||
userUid?: string | null;
|
||||
source?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result: {
|
||||
hasAccess: boolean;
|
||||
fallbackAppName?: string;
|
||||
fallbackArgs?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
checkedBy?: string;
|
||||
};
|
||||
};
|
||||
'ai.prompt.validate': {
|
||||
actor: Actor;
|
||||
actor,
|
||||
completionId: string,
|
||||
allow: boolean,
|
||||
intended_service: string,
|
||||
parameters: ICompleteArguments
|
||||
}
|
||||
'outer.cacheUpdate': { cacheKey: string | string[], ttlSeconds?: number, data?: unknown }
|
||||
}
|
||||
|
||||
interface Extension extends RouterMethods {
|
||||
exports: Record<string, unknown>;
|
||||
span: (<T>(label: string, fn: () => T) => () => T) & {
|
||||
run<T>(label: string, fn: () => T): T;
|
||||
run<T>(fn: () => T): T;
|
||||
};
|
||||
config: Record<string | number | symbol, any>;
|
||||
|
||||
on<E extends keyof ExtensionEventTypeMap>(
|
||||
name: E,
|
||||
listener: (event: ExtensionEventTypeMap[E], metadata?: { from_outside?: boolean }) => void | Promise<void>
|
||||
): void;
|
||||
on<T>(name: string, listener: (event: T, metadata?: { from_outside?: boolean }) => void | Promise<void>): void
|
||||
|
||||
import(module: 'data'): {
|
||||
db: BaseDatabaseAccessService;
|
||||
kv: DynamoKVStore;
|
||||
cache: Cluster;
|
||||
s3ClientProvider: typeof s3ClientProvider;
|
||||
};
|
||||
import(module: 'core'): CoreRuntimeModule;
|
||||
import(module: 'fs'): FilesystemModule;
|
||||
import(module: 'query'): typeof query;
|
||||
import(module: 'extensionController'): typeof ExtensionControllerExports;
|
||||
import<T extends `service:${keyof ServicesMap}` | (string & {})>(
|
||||
module: T
|
||||
): T extends `service:${infer R extends keyof ServicesMap}`
|
||||
? ServicesMap[R]
|
||||
: unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// Declare the extension variable
|
||||
const extension: Extension;
|
||||
const config: Record<string | number | symbol, any>;
|
||||
const global_config: Record<string | number | symbol, unknown>;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
const { Eq } = extension.import('query');
|
||||
const { db } = extension.import('data');
|
||||
const { APIError, Context } = extension.import('core');
|
||||
const app_es = extension.import('service:es:app') as any;
|
||||
const svc_permission = extension.import('service:permission') as any;
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 1000;
|
||||
const MAX_OFFSET = 100_000;
|
||||
|
||||
const parseIntegerParam = (
|
||||
value: unknown,
|
||||
{
|
||||
key,
|
||||
min,
|
||||
max,
|
||||
fallback,
|
||||
}: { key: string, min: number, max: number, fallback: number },
|
||||
) => {
|
||||
if ( value === undefined || value === null ) return fallback;
|
||||
|
||||
const parsed = typeof value === 'number'
|
||||
? value
|
||||
: (typeof value === 'string' && value.trim() !== ''
|
||||
? Number(value)
|
||||
: Number.NaN);
|
||||
|
||||
if ( !Number.isFinite(parsed) || !Number.isInteger(parsed) ) {
|
||||
throw APIError.create('field_invalid', undefined, {
|
||||
key,
|
||||
expected: `an integer between ${min} and ${max}`,
|
||||
got: value,
|
||||
});
|
||||
}
|
||||
|
||||
if ( parsed < min || parsed > max ) {
|
||||
throw APIError.create('field_invalid', undefined, {
|
||||
key,
|
||||
expected: `an integer between ${min} and ${max}`,
|
||||
got: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
extension.on('create.interfaces', (event) => {
|
||||
event.createInterface('app-telemetry', {
|
||||
description: 'Provides methods for getting app telemetry',
|
||||
methods: {
|
||||
get_users: {
|
||||
description: 'Returns users who have used your app',
|
||||
parameters: {
|
||||
app_uuid: {
|
||||
type: 'string',
|
||||
optional: false,
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
optional: true,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user_count: {
|
||||
description: 'Returns number of users who have used your app',
|
||||
parameters: {
|
||||
app_uuid: {
|
||||
type: 'string',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
extension.on('create.drivers', event => {
|
||||
event.createDriver('app-telemetry', 'app-telemetry', {
|
||||
async get_users ({ app_uuid, limit, offset }: { app_uuid: string, limit?: number, offset?: number }) {
|
||||
const safeLimit = parseIntegerParam(limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntegerParam(offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
|
||||
// first lets make sure executor owns this app
|
||||
const [result] = (await app_es.select({ predicate: new Eq({ key: 'uid', value: app_uuid }) }));
|
||||
if ( ! result ) {
|
||||
throw APIError.create('permission_denied');
|
||||
}
|
||||
if ( ! (await svc_permission.check(Context.get('actor'), `apps-of-user:${result.values_.owner.uuid}:write`, { no_cache: true })) ) {
|
||||
throw APIError.create('permission_denied');
|
||||
}
|
||||
|
||||
// Fetch and return users
|
||||
const users: Array<{ username: string, uuid: string }> = await db.read(
|
||||
`SELECT user.username, user.uuid FROM user_to_app_permissions
|
||||
INNER JOIN user ON user_to_app_permissions.user_id = user.id
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id=? ORDER BY (dt IS NOT NULL), dt, user_id LIMIT ? OFFSET ?`,
|
||||
[result.private_meta.mysql_id, safeLimit, safeOffset],
|
||||
);
|
||||
return users.map(e => {
|
||||
return { user: e.username, user_uuid: e.uuid };
|
||||
});
|
||||
},
|
||||
async user_count ({ app_uuid }: { app_uuid: string }) {
|
||||
// first lets make sure executor owns this app
|
||||
const [result] = (await app_es.select({ predicate: new Eq({ key: 'uid', value: app_uuid }) }));
|
||||
if ( ! result ) {
|
||||
throw APIError.create('permission_denied');
|
||||
}
|
||||
|
||||
// Fetch and return authenticated user count
|
||||
const [data] = await db.read(
|
||||
`SELECT count(*) FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id=?;`,
|
||||
[result.private_meta.mysql_id],
|
||||
);
|
||||
const count = data['count(*)'];
|
||||
return count;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
extension.on('create.permissions', (event) => {
|
||||
event.grant_to_everyone('service:app-telemetry:ii:app-telemetry');
|
||||
});
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
import '../api.js';
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@heyputer/app-telemetry",
|
||||
"main": "app-user-count.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "tsc --noCheck",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"*.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import {
|
||||
handleAppTelemetryUserCount,
|
||||
handleAppTelemetryUsers,
|
||||
} from './appTelemetry.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||||
({ query }) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
// `handleAppTelemetryUsers` calls `Context.get('actor')` for the permission
|
||||
// gate. Wrap test calls in `runWithContext` so the ALS lookup resolves.
|
||||
const callWithActor = async (
|
||||
actor: { user: { uuid: string; id?: number }; app?: null } | undefined,
|
||||
fn: () => Promise<void>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(makeReq({}), res),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('app_uuid'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(400) for a non-numeric limit', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-anything', limit: 'banana' }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('limit'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when offset exceeds the allowed maximum', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-anything', offset: 1_000_001 }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws HttpError(404) when the app cannot be found', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-does-not-exist' }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
message: 'App not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(403) when the caller does not own the app', async () => {
|
||||
// Seed an owner user + an app belonging to them, then call the
|
||||
// handler as a *different* user. The real permission service should
|
||||
// reject since the caller has no `apps-of-user:<owner>:write` perm.
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `app_${slug}`,
|
||||
title: `App ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
const intruder = await server.stores.user.create({
|
||||
username: `intruder_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
|
||||
const { res } = makeRes();
|
||||
await callWithActor(
|
||||
{ user: { uuid: intruder.uuid, id: intruder.id as number } },
|
||||
async () => {
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
message: 'Permission denied',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty list for an owned app with no authenticated users', async () => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner2_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `app2_${slug}`,
|
||||
title: `App2 ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
async () => {
|
||||
await handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appTelemetry extension — handleAppTelemetryUserCount', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUserCount(makeReq({}), res),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws HttpError(404) for an unknown app_uuid', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUserCount(
|
||||
makeReq({ app_uuid: 'app-not-here' }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('returns { count: 0 } for an app with no authenticated users', async () => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `counto_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `appcount_${slug}`,
|
||||
title: `AppCount ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await handleAppTelemetryUserCount(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual({ count: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
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';
|
||||
|
||||
const clients = extension.import('client');
|
||||
const stores = extension.import('store');
|
||||
const services = extension.import('service');
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 1000;
|
||||
const MAX_OFFSET = 100_000;
|
||||
|
||||
const parseIntParam = (
|
||||
value: unknown,
|
||||
{
|
||||
key,
|
||||
min,
|
||||
max,
|
||||
fallback,
|
||||
}: { key: string; min: number; max: number; fallback: number },
|
||||
): number => {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
const parsed =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && value.trim() !== ''
|
||||
? Number(value)
|
||||
: NaN;
|
||||
if (
|
||||
!Number.isFinite(parsed) ||
|
||||
!Number.isInteger(parsed) ||
|
||||
parsed < min ||
|
||||
parsed > max
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${key} must be an integer between ${min} and ${max}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const handleAppTelemetryUsers = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const safeLimit = parseIntParam(req.query.limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(req.query.offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly. v1 got
|
||||
// this for free because its entity-storage layer eager-joined the
|
||||
// owner row; v2's AppStore.getByUid returns the raw row with only
|
||||
// `owner_user_id` populated.
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = (await stores.user.getById(ownerId)) as {
|
||||
uuid?: string;
|
||||
} | null;
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const ownsApp = await services.permission
|
||||
.check(actor!, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
const users = await clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
INNER JOIN ${clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
|
||||
);
|
||||
|
||||
res.json(
|
||||
(users as Array<{ username: string; uuid: string }>).map((e) => ({
|
||||
user: e.username,
|
||||
user_uuid: e.uuid,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
export const handleAppTelemetryUserCount = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
const [row] = (await clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as Record<string, unknown>).id],
|
||||
)) as Array<{ n: number }>;
|
||||
|
||||
res.json({ count: row?.n ?? 0 });
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/users',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
handleAppTelemetryUsers,
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/user-count',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
handleAppTelemetryUserCount,
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
//@extension priority -10000
|
||||
|
||||
const { redisClient, kvjs, s3ClientProvider } = extension.import('core');
|
||||
const svc_database = extension.import('service:database');
|
||||
const svc_kvstore = extension.import('service:puter-kvstore');
|
||||
|
||||
// Methods on the object from `.as()` come from TraitsFeature.js,
|
||||
// and they are already bound to their respective instance.
|
||||
const simplified_kv = { ...svc_kvstore.as('puter-kvstore') };
|
||||
|
||||
const original_get = simplified_kv.get;
|
||||
const original_set = simplified_kv.set;
|
||||
|
||||
simplified_kv.get = (...a) => {
|
||||
if ( typeof a[0] === 'string' ) {
|
||||
return original_get({ key: a[0] });
|
||||
}
|
||||
return original_get(...a);
|
||||
};
|
||||
|
||||
simplified_kv.set = (...a) => {
|
||||
if ( typeof a[0] === 'string' ) {
|
||||
return original_set({ key: a[0], value: a[1] });
|
||||
}
|
||||
return original_set(...a);
|
||||
};
|
||||
|
||||
extension.exports = {
|
||||
db: svc_database.get(),
|
||||
kv: simplified_kv,
|
||||
cache: redisClient,
|
||||
kvjs: kvjs,
|
||||
s3ClientProvider,
|
||||
};
|
||||
@@ -0,0 +1,421 @@
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { PuterService } from '@heyputer/backend/src/services/types.js';
|
||||
import { nativeImport } from '@heyputer/backend/src/util/nativeImport.js';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
const webpack = requireFromHere('webpack') as typeof import('webpack');
|
||||
|
||||
type EnvFactory = (args: {
|
||||
global_config: Record<string, unknown> | null;
|
||||
}) => string | undefined;
|
||||
|
||||
type EnvMap = Record<string, string | undefined | EnvFactory>;
|
||||
|
||||
type CommandEntry = {
|
||||
name: string;
|
||||
directory: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: EnvMap;
|
||||
};
|
||||
|
||||
type WebpackEntry = {
|
||||
name?: string;
|
||||
directory: string;
|
||||
env?: EnvMap;
|
||||
onConfig?: (config: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
type WebpackStats = {
|
||||
hasErrors: () => boolean;
|
||||
toJson: (options: Record<string, unknown>) => {
|
||||
errors?: Array<{ message?: string }>;
|
||||
warnings?: Array<{ message?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
type DevWatcherConfig = {
|
||||
enabled?: boolean;
|
||||
root?: string;
|
||||
commands?: CommandEntry[];
|
||||
webpack?: WebpackEntry[];
|
||||
ready_delay_ms?: number;
|
||||
};
|
||||
|
||||
class ProxyLogger {
|
||||
#buffer = '';
|
||||
|
||||
constructor(private readonly log: (line: string) => void) {}
|
||||
|
||||
attach(stream: NodeJS.ReadableStream | null): void {
|
||||
if (!stream) return;
|
||||
stream.on('data', (chunk) => {
|
||||
this.#buffer += chunk.toString();
|
||||
let lineEndIndex = this.#buffer.indexOf('\n');
|
||||
while (lineEndIndex !== -1) {
|
||||
const line = this.#buffer.substring(0, lineEndIndex);
|
||||
this.log(line);
|
||||
this.#buffer = this.#buffer.substring(lineEndIndex + 1);
|
||||
lineEndIndex = this.#buffer.indexOf('\n');
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (this.#buffer.length) {
|
||||
this.log(this.#buffer);
|
||||
this.#buffer = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const findPackageRoot = (): string => {
|
||||
let dir = __dirname;
|
||||
for (;;) {
|
||||
if (
|
||||
existsSync(path.join(dir, 'package.json')) &&
|
||||
existsSync(path.join(dir, 'src', 'gui')) &&
|
||||
existsSync(path.join(dir, 'src', 'puter-js'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
};
|
||||
|
||||
const resolveFromRoot = (root: string, value: string): string =>
|
||||
path.isAbsolute(value) ? value : path.resolve(root, value);
|
||||
|
||||
const defaultWebpackEntries: WebpackEntry[] = [
|
||||
{
|
||||
name: 'puter.js',
|
||||
directory: 'src/puter-js',
|
||||
onConfig: (config) => {
|
||||
const output = (config.output ?? {}) as Record<string, unknown>;
|
||||
output.filename = 'puter.dev.js';
|
||||
config.output = output;
|
||||
config.devtool = 'source-map';
|
||||
},
|
||||
env: {
|
||||
PUTER_ORIGIN: ({ global_config }) =>
|
||||
String(global_config?.origin ?? ''),
|
||||
PUTER_API_ORIGIN: ({ global_config }) =>
|
||||
String(global_config?.api_base_url ?? ''),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gui',
|
||||
directory: 'src/gui',
|
||||
},
|
||||
];
|
||||
|
||||
class DevWatcherService extends PuterService {
|
||||
#processes: Array<{ name: string; proc: ChildProcess }> = [];
|
||||
#watchers: ReturnType<ReturnType<typeof webpack>['watch']>[] = [];
|
||||
#started = false;
|
||||
#packageRoot = findPackageRoot();
|
||||
|
||||
override async onServerStart(): Promise<void> {
|
||||
if (!this.#shouldStart()) return;
|
||||
if (this.#started) return;
|
||||
this.#started = true;
|
||||
|
||||
const devwatch = this.#devwatchConfig();
|
||||
const root = resolveFromRoot(
|
||||
this.#packageRoot,
|
||||
devwatch.root ?? this.#packageRoot,
|
||||
);
|
||||
const commands = devwatch.commands ?? [];
|
||||
const webpackEntries = devwatch.webpack ?? defaultWebpackEntries;
|
||||
|
||||
console.log(`[devwatch] starting watchers from ${root}`);
|
||||
await Promise.all([
|
||||
...commands.map((entry) => this.#startCommand(root, entry)),
|
||||
...webpackEntries.map((entry) =>
|
||||
this.#startWebpackWatcher(root, entry),
|
||||
),
|
||||
]);
|
||||
|
||||
const readyDelayMs = devwatch.ready_delay_ms ?? 5000;
|
||||
if (readyDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, readyDelayMs));
|
||||
}
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
await Promise.all(
|
||||
this.#watchers.map(
|
||||
(watcher) =>
|
||||
new Promise<void>((resolve) => {
|
||||
watcher?.close((err) => {
|
||||
if (err) {
|
||||
console.warn(
|
||||
'[devwatch] failed to close webpack watcher:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.#watchers = [];
|
||||
|
||||
for (const { name, proc } of this.#processes) {
|
||||
if (proc.exitCode !== null || proc.killed) continue;
|
||||
console.log(`[devwatch:${name}] stopping process`);
|
||||
proc.kill();
|
||||
}
|
||||
this.#processes = [];
|
||||
}
|
||||
|
||||
#devwatchConfig(): DevWatcherConfig {
|
||||
const raw = (this.config as Record<string, unknown>).devwatch;
|
||||
return raw && typeof raw === 'object' ? (raw as DevWatcherConfig) : {};
|
||||
}
|
||||
|
||||
#shouldStart(): boolean {
|
||||
const config = this.config as Record<string, unknown>;
|
||||
const devwatch = this.#devwatchConfig();
|
||||
|
||||
if (config.no_devwatch === true) return false;
|
||||
if (devwatch.enabled === false) return false;
|
||||
if (devwatch.enabled === true) return true;
|
||||
|
||||
return this.config.env === 'dev';
|
||||
}
|
||||
|
||||
async #startCommand(root: string, entry: CommandEntry): Promise<void> {
|
||||
const fullpath = resolveFromRoot(root, entry.directory);
|
||||
console.log(`[devwatch] starting ${entry.name} in ${fullpath}`);
|
||||
|
||||
const proc = spawn(entry.command, entry.args ?? [], {
|
||||
shell: true,
|
||||
cwd: fullpath,
|
||||
env: {
|
||||
...process.env,
|
||||
...this.#evaluateEnv(entry.env),
|
||||
},
|
||||
});
|
||||
this.#processes.push({ name: entry.name, proc });
|
||||
|
||||
new ProxyLogger((line) =>
|
||||
console.log(`[devwatch:${entry.name}:1] ${line}`),
|
||||
).attach(proc.stdout);
|
||||
new ProxyLogger((line) =>
|
||||
console.warn(`[devwatch:${entry.name}:2] ${line}`),
|
||||
).attach(proc.stderr);
|
||||
|
||||
proc.on('exit', () => {
|
||||
console.log(
|
||||
`[devwatch:${entry.name}:exit] process exited (${proc.exitCode})`,
|
||||
);
|
||||
this.#processes = this.#processes.filter(
|
||||
(instance) => instance.proc !== proc,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #startWebpackWatcher(
|
||||
root: string,
|
||||
entry: WebpackEntry,
|
||||
): Promise<void> {
|
||||
const directory = entry.directory;
|
||||
let { configjsPath: webpackConfigPath, moduleType } = this.#getConfigJs(
|
||||
{
|
||||
root,
|
||||
directory,
|
||||
configIsFor: 'webpack',
|
||||
possibleConfigNames: [
|
||||
['webpack.config.js', 'package.json'],
|
||||
['webpack.config.cjs', 'commonjs'],
|
||||
['webpack.config.mjs', 'module'],
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
let webpackConfig = await this.#withEnv(entry.env, async () => {
|
||||
if (moduleType === 'module') {
|
||||
webpackConfigPath = pathToFileURL(webpackConfigPath).href;
|
||||
const imported = await nativeImport<{ default?: unknown }>(
|
||||
webpackConfigPath,
|
||||
);
|
||||
return imported.default ?? imported;
|
||||
}
|
||||
return requireFromHere(webpackConfigPath);
|
||||
});
|
||||
|
||||
if (typeof webpackConfig === 'function') {
|
||||
webpackConfig = await this.#withEnv(entry.env, () =>
|
||||
(webpackConfig as () => unknown)(),
|
||||
);
|
||||
}
|
||||
|
||||
this.#normalizeWebpackContext(root, directory, webpackConfig);
|
||||
if (entry.onConfig) {
|
||||
entry.onConfig(webpackConfig as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const compiler = webpack(
|
||||
webpackConfig as Parameters<typeof webpack>[0],
|
||||
);
|
||||
const watcher = compiler.watch({}, (err, stats) => {
|
||||
this.#handleWebpackUpdate(entry, err, stats);
|
||||
});
|
||||
this.#watchers.push(watcher);
|
||||
}
|
||||
|
||||
#getConfigJs(args: {
|
||||
root: string;
|
||||
directory: string;
|
||||
configIsFor: string;
|
||||
possibleConfigNames: Array<
|
||||
[string, 'package.json' | 'commonjs' | 'module']
|
||||
>;
|
||||
}): {
|
||||
configjsPath: string;
|
||||
moduleType: 'commonjs' | 'module';
|
||||
} {
|
||||
const { root, directory, configIsFor, possibleConfigNames } = args;
|
||||
let configjsPath: string | undefined;
|
||||
let moduleType: 'package.json' | 'commonjs' | 'module' | undefined;
|
||||
|
||||
for (const [configName, supposedModuleType] of possibleConfigNames) {
|
||||
const supposedPath = path.join(root, directory, configName);
|
||||
if (existsSync(supposedPath)) {
|
||||
configjsPath = supposedPath;
|
||||
moduleType = supposedModuleType;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configjsPath || !moduleType) {
|
||||
throw new Error(
|
||||
`could not find ${configIsFor} config for: ${directory}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (moduleType === 'package.json') {
|
||||
const packageJSONPath = path.join(root, directory, 'package.json');
|
||||
const packageJSONObject = JSON.parse(
|
||||
readFileSync(packageJSONPath, 'utf8'),
|
||||
) as { type?: 'commonjs' | 'module' };
|
||||
moduleType = packageJSONObject.type ?? 'module';
|
||||
}
|
||||
|
||||
return {
|
||||
configjsPath,
|
||||
moduleType,
|
||||
};
|
||||
}
|
||||
|
||||
async #withEnv<T>(env: EnvMap | undefined, fn: () => T | Promise<T>) {
|
||||
if (!env) return fn();
|
||||
|
||||
const oldEnv = process.env;
|
||||
process.env = {
|
||||
...oldEnv,
|
||||
...this.#evaluateEnv(env),
|
||||
};
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.env = oldEnv;
|
||||
}
|
||||
}
|
||||
|
||||
#evaluateEnv(env: EnvMap | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!env) return out;
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
try {
|
||||
const evaluated =
|
||||
typeof value === 'function'
|
||||
? value({
|
||||
global_config: this.config as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
})
|
||||
: value;
|
||||
if (evaluated) out[key] = String(evaluated);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (
|
||||
!msg.includes('Cannot read properties of null') &&
|
||||
!msg.includes('Cannot read properties of undefined')
|
||||
) {
|
||||
console.warn(
|
||||
`[devwatch] could not evaluate env function for ${key}: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
#normalizeWebpackContext(
|
||||
root: string,
|
||||
directory: string,
|
||||
webpackConfig: unknown,
|
||||
): void {
|
||||
const configs = Array.isArray(webpackConfig)
|
||||
? webpackConfig
|
||||
: [webpackConfig];
|
||||
|
||||
for (const config of configs) {
|
||||
if (!config || typeof config !== 'object') continue;
|
||||
const obj = config as Record<string, unknown>;
|
||||
obj.context = obj.context
|
||||
? path.resolve(path.join(root, directory), String(obj.context))
|
||||
: path.join(root, directory);
|
||||
}
|
||||
}
|
||||
|
||||
#handleWebpackUpdate(
|
||||
entry: WebpackEntry,
|
||||
err: Error | null | undefined,
|
||||
stats: WebpackStats | undefined,
|
||||
): void {
|
||||
const name = entry.name ?? entry.directory;
|
||||
const firstEventKey = `__devwatch_first_${entry.directory}`;
|
||||
const firstEvent = !(entry as Record<string, unknown>)[firstEventKey];
|
||||
(entry as Record<string, unknown>)[firstEventKey] = true;
|
||||
|
||||
if (err || stats?.hasErrors()) {
|
||||
const info = stats?.toJson({
|
||||
all: false,
|
||||
errors: true,
|
||||
warnings: true,
|
||||
});
|
||||
console.error(
|
||||
`[devwatch] error information: ${name} using Webpack`,
|
||||
{
|
||||
err: err ? err.message : null,
|
||||
errors: info?.errors?.map((e) => e.message) ?? [],
|
||||
warnings: info?.warnings?.map((w) => w.message) ?? [],
|
||||
},
|
||||
);
|
||||
console.error(`[devwatch] failed to update ${name} using Webpack`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstEvent) {
|
||||
console.log(`[devwatch] updated ${name} using Webpack`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension.registerService('devWatcher', DevWatcherService);
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "@puter/extension-controller",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "tsc --noCheck"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"http-status-codes": "^2.3.0",
|
||||
"stripe": "^19.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"priority": -10
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import type {
|
||||
EndpointOptions,
|
||||
HttpMethod,
|
||||
RouterMethods,
|
||||
} from '../../api.d.ts';
|
||||
declare const extension: Partial<Record<HttpMethod, RouterMethods[HttpMethod]>>;
|
||||
/**
|
||||
* Class decorator to set prefix on prototype and register routes on instantiation
|
||||
* @argument prefix - prefix for all routes under the class
|
||||
* @argument [adminUsernames] - gate all routes behind admin username check
|
||||
*/
|
||||
export const Controller = (
|
||||
prefix: string,
|
||||
adminUsernames?: string[],
|
||||
allowedAppIds?: string[],
|
||||
): ClassDecorator => {
|
||||
return (target: Function) => {
|
||||
target.prototype.__controllerPrefix = prefix;
|
||||
target.prototype.__allowedAppIds = allowedAppIds;
|
||||
target.prototype.__adminUsernames = adminUsernames
|
||||
? [...adminUsernames, 'admin', 'system']
|
||||
: undefined;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Method decorator factory that collects route metadata
|
||||
*/
|
||||
interface RouteMeta {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
options?: EndpointOptions | undefined;
|
||||
handler: (req: Request, res: Response, next: NextFunction) => void | Promise<void>;
|
||||
adminUsernames?: string[];
|
||||
allowedAppIds?: string[];
|
||||
}
|
||||
|
||||
const createMethodDecorator = (method: HttpMethod) => {
|
||||
return <This>(
|
||||
path: string,
|
||||
routeOptions?: EndpointOptions & { allowedAppIds?: string[] },
|
||||
adminUsernames?: string[],
|
||||
) => {
|
||||
const { allowedAppIds, ...options } = routeOptions ?? {};
|
||||
return (
|
||||
target: (req: Request, res: Response, next: NextFunction) => void | Promise<void>,
|
||||
_context: ClassMethodDecoratorContext<
|
||||
This,
|
||||
(
|
||||
this: This,
|
||||
...args: [req: Request, res: Response, next: NextFunction]
|
||||
) => void | Promise<void>
|
||||
>,
|
||||
) => {
|
||||
_context.addInitializer(function () {
|
||||
// eslint-disable-next-line no-invalid-this
|
||||
const proto = Object.getPrototypeOf(this); // will be bound to class
|
||||
if ( ! proto.__routes ) {
|
||||
proto.__routes = [];
|
||||
}
|
||||
proto.__routes.push({
|
||||
method,
|
||||
path,
|
||||
options: options as EndpointOptions | undefined,
|
||||
adminUsernames: adminUsernames
|
||||
? [...adminUsernames, 'admin', 'system']
|
||||
: undefined,
|
||||
allowedAppIds,
|
||||
handler: target,
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// HTTP method decorators
|
||||
export const Get = createMethodDecorator('get');
|
||||
export const Post = createMethodDecorator('post');
|
||||
export const Put = createMethodDecorator('put');
|
||||
export const Delete = createMethodDecorator('delete');
|
||||
// TODO DS: add others as needed (patch, etc)
|
||||
|
||||
interface HttpErrorOptions {
|
||||
cause?: unknown;
|
||||
legacyCode?: string;
|
||||
code?: string;
|
||||
fields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isHttpErrorOptions = (value: unknown): value is HttpErrorOptions => {
|
||||
if ( !value || typeof value !== 'object' || Array.isArray(value) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(value, 'cause')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'legacyCode')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'code')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'fields')
|
||||
);
|
||||
};
|
||||
|
||||
export class HttpError extends Error {
|
||||
statusCode: number;
|
||||
legacyCode?: string;
|
||||
code?: string;
|
||||
fields?: Record<string, unknown>;
|
||||
constructor (
|
||||
statusCode: StatusCodes,
|
||||
message: string,
|
||||
causeOrOptions?: unknown,
|
||||
legacyCode?: string,
|
||||
) {
|
||||
const options = isHttpErrorOptions(causeOrOptions)
|
||||
? causeOrOptions
|
||||
: undefined;
|
||||
const cause = options
|
||||
? options.cause
|
||||
: causeOrOptions;
|
||||
const resolvedLegacyCode = legacyCode ?? options?.legacyCode;
|
||||
const code = options?.code;
|
||||
super(
|
||||
`${statusCode} - ${message}`,
|
||||
cause !== undefined ? { cause } : undefined,
|
||||
);
|
||||
this.statusCode = statusCode;
|
||||
this.legacyCode = resolvedLegacyCode;
|
||||
this.code = code;
|
||||
this.fields = options?.fields;
|
||||
}
|
||||
}
|
||||
|
||||
// Registers all routes from a decorated controller instance to an Express router
|
||||
export class ExtensionController {
|
||||
logger?: Console;
|
||||
// TODO DS: make this work with other express-like routers
|
||||
registerRoutes () {
|
||||
const logger = this.logger || console;
|
||||
const prefix = Object.getPrototypeOf(this).__controllerPrefix || '';
|
||||
const adminsForController = Object.getPrototypeOf(this).__adminUsernames as
|
||||
| string[]
|
||||
| undefined;
|
||||
const allowedAppIdsForController = Object.getPrototypeOf(this).__allowedAppIds as
|
||||
| string[]
|
||||
| undefined;
|
||||
const routes: RouteMeta[] = Object.getPrototypeOf(this).__routes || [];
|
||||
for ( const route of routes ) {
|
||||
const fullPath = `${prefix}/${route.path}`.replace(/\/+/g, '/');
|
||||
const adminsForRoute = route.adminUsernames
|
||||
? adminsForController
|
||||
? adminsForController.concat(route.adminUsernames)
|
||||
: route.adminUsernames
|
||||
: adminsForController
|
||||
? adminsForController
|
||||
: undefined;
|
||||
const allowedAppIds = route.allowedAppIds
|
||||
? allowedAppIdsForController
|
||||
? allowedAppIdsForController.concat(route.allowedAppIds)
|
||||
: route.allowedAppIds
|
||||
: allowedAppIdsForController
|
||||
? allowedAppIdsForController
|
||||
: undefined;
|
||||
|
||||
if ( ! extension[route.method] ) {
|
||||
throw new Error(`Unsupported HTTP method: ${route.method}`);
|
||||
} else {
|
||||
logger.log(`Registering route: [${route.method.toUpperCase()}] ${fullPath}`);
|
||||
|
||||
(extension[route.method] as RouterMethods[HttpMethod])(
|
||||
fullPath,
|
||||
route.options || {},
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
if ( adminsForRoute || allowedAppIds ) {
|
||||
if ( ! req.actor ) {
|
||||
throw new HttpError(StatusCodes.UNAUTHORIZED, 'Unauthenticated');
|
||||
}
|
||||
}
|
||||
if ( adminsForRoute ) {
|
||||
if ( ! adminsForRoute.includes(req.actor!.type.user.username) ) {
|
||||
throw new HttpError(
|
||||
StatusCodes.FORBIDDEN,
|
||||
'Only admins may request this resource.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if ( allowedAppIds ) {
|
||||
if ( ( req.actor!.type?.app?.uid && !allowedAppIds.includes(req.actor!.type.app.uid) ) ) {
|
||||
throw new HttpError(
|
||||
StatusCodes.FORBIDDEN,
|
||||
'This app may not request this resource.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return await route.handler.bind(this)(req, res, next);
|
||||
} catch ( error ) {
|
||||
if ( error instanceof HttpError ) {
|
||||
const payload: Record<string, unknown> = {
|
||||
error: error.message,
|
||||
};
|
||||
if ( error.legacyCode ) {
|
||||
payload.code = error.legacyCode;
|
||||
}
|
||||
if ( error.code ) {
|
||||
if ( payload.code === undefined ) {
|
||||
payload.code = error.code;
|
||||
} else {
|
||||
payload.errorCode = error.code;
|
||||
}
|
||||
}
|
||||
if ( error.fields ) {
|
||||
for ( const [key, value] of Object.entries(error.fields) ) {
|
||||
if ( payload[key] !== undefined ) {
|
||||
continue;
|
||||
}
|
||||
payload[key] = value;
|
||||
}
|
||||
}
|
||||
res.status(error.statusCode).send(payload);
|
||||
logger.warn('httpError:', error);
|
||||
return;
|
||||
}
|
||||
if ( error instanceof Error ) {
|
||||
res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ error: error.message });
|
||||
logger.error('Non-http error:', error);
|
||||
return;
|
||||
}
|
||||
res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ error: 'An unknown error occurred' });
|
||||
logger.error('An unknown error occurred:', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Controller, Delete, ExtensionController, Get, HttpError, Post, Put } from './ExtensionController.js';
|
||||
|
||||
extension.exports = {
|
||||
ExtensionController,
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Post,
|
||||
Delete,
|
||||
HttpError,
|
||||
};
|
||||
|
||||
export {
|
||||
Controller, Delete, ExtensionController, Get, HttpError, Post, Put,
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"*.js"
|
||||
]
|
||||
}
|
||||
Generated
-2653
File diff suppressed because it is too large.
Load diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@heyputer/fs",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "tsc --noCheck",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^24.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"@aws-sdk/client-s3": "^3.1021.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/s3-request-presigner": "^3.1021.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"busboy": "^1.6.0",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"stripe": "^20.1.2",
|
||||
"uuid": "^13.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
*.js
|
||||
*.js.map
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,153 +0,0 @@
|
||||
import type { FSEntryRepository } from '../repositories/FSEntryRepository.js';
|
||||
import type {
|
||||
FsRemoveNodeEventPayload,
|
||||
FsRemoveNodeTarget,
|
||||
OuterGuiItemEventPayload,
|
||||
} from './types.js';
|
||||
|
||||
export class FSEntryCacheInvalidationEventHandler {
|
||||
#fsEntryRepository: FSEntryRepository;
|
||||
|
||||
constructor (fsEntryRepository: FSEntryRepository) {
|
||||
this.#fsEntryRepository = fsEntryRepository;
|
||||
this.#registerHandlers();
|
||||
}
|
||||
|
||||
#registerHandlers (): void {
|
||||
extension.on('outer.gui.item.added', async (event: OuterGuiItemEventPayload) => {
|
||||
await this.#runSafely(() => this.#handleOuterGuiItemEvent(event), 'outer.gui.item.added');
|
||||
});
|
||||
extension.on('outer.gui.item.updated', async (event: OuterGuiItemEventPayload) => {
|
||||
await this.#runSafely(() => this.#handleOuterGuiItemEvent(event), 'outer.gui.item.updated');
|
||||
});
|
||||
extension.on('outer.gui.item.moved', async (event: OuterGuiItemEventPayload) => {
|
||||
await this.#runSafely(() => this.#handleOuterGuiItemEvent(event), 'outer.gui.item.moved');
|
||||
});
|
||||
extension.on('fs.remove.node', async (event: FsRemoveNodeEventPayload) => {
|
||||
await this.#runSafely(() => this.#handleRemoveNodeEvent(event), 'fs.remove.node');
|
||||
});
|
||||
}
|
||||
|
||||
async #runSafely (handler: () => Promise<void>, eventName: string): Promise<void> {
|
||||
try {
|
||||
await handler();
|
||||
} catch ( error ) {
|
||||
console.error(`prodfsv2 cache invalidation failed for ${eventName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
#toUserIds (value: unknown): number[] {
|
||||
if ( ! Array.isArray(value) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userIds: number[] = [];
|
||||
for ( const item of value ) {
|
||||
const numeric = Number(item);
|
||||
if ( Number.isInteger(numeric) && numeric > 0 ) {
|
||||
userIds.push(numeric);
|
||||
}
|
||||
}
|
||||
return userIds;
|
||||
}
|
||||
|
||||
#toNonEmptyString (value: unknown): string | null {
|
||||
if ( typeof value !== 'string' ) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
#isUnrecognizedTargetKeyError (error: unknown): boolean {
|
||||
if ( ! (error instanceof Error) ) {
|
||||
return false;
|
||||
}
|
||||
return error.message.includes('unrecognize key for FSNodeContext.get:');
|
||||
}
|
||||
|
||||
async #readTargetValue (target: FsRemoveNodeTarget, keys: string[]): Promise<unknown> {
|
||||
if ( typeof target.get !== 'function' ) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for ( const key of keys ) {
|
||||
try {
|
||||
return await target.get(key);
|
||||
} catch ( error ) {
|
||||
if ( this.#isUnrecognizedTargetKeyError(error) ) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#extractUidFromEntry (value: unknown): string | null {
|
||||
if ( !value || typeof value !== 'object' ) {
|
||||
return null;
|
||||
}
|
||||
const entry = value as { uid?: unknown; uuid?: unknown };
|
||||
return this.#toNonEmptyString(entry.uid) ?? this.#toNonEmptyString(entry.uuid);
|
||||
}
|
||||
|
||||
async #handleOuterGuiItemEvent (event: OuterGuiItemEventPayload): Promise<void> {
|
||||
const userIds = this.#toUserIds(event?.user_id_list);
|
||||
const response = event?.response ?? {};
|
||||
|
||||
const path = this.#toNonEmptyString(response.path);
|
||||
const oldPath = this.#toNonEmptyString(response.old_path);
|
||||
const uid =
|
||||
this.#toNonEmptyString(response.uid)
|
||||
?? this.#toNonEmptyString(response.uuid)
|
||||
?? this.#toNonEmptyString(response.id);
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
for ( const userId of userIds ) {
|
||||
if ( path ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByPathForUser(userId, path));
|
||||
}
|
||||
if ( oldPath && oldPath !== path ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByPathForUser(userId, oldPath));
|
||||
}
|
||||
}
|
||||
if ( uid ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByUuid(uid));
|
||||
}
|
||||
|
||||
if ( tasks.length > 0 ) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
async #handleRemoveNodeEvent (event: FsRemoveNodeEventPayload): Promise<void> {
|
||||
const target = event?.target;
|
||||
if ( !target || typeof target.get !== 'function' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userIdValue = await this.#readTargetValue(target, ['user_id']);
|
||||
const pathValue = await this.#readTargetValue(target, ['path']);
|
||||
const uidValue =
|
||||
await this.#readTargetValue(target, ['uid', 'uuid'])
|
||||
?? this.#extractUidFromEntry(await this.#readTargetValue(target, ['entry']));
|
||||
|
||||
const userId = Number(userIdValue);
|
||||
const path = this.#toNonEmptyString(pathValue);
|
||||
const uuid = this.#toNonEmptyString(uidValue);
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
if ( Number.isInteger(userId) && userId > 0 && path ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByPathForUser(userId, path));
|
||||
}
|
||||
if ( uuid ) {
|
||||
tasks.push(this.#fsEntryRepository.invalidateEntryCacheByUuid(uuid));
|
||||
}
|
||||
|
||||
if ( tasks.length > 0 ) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export interface OuterGuiItemEventResponse {
|
||||
path?: string;
|
||||
old_path?: string;
|
||||
uid?: string;
|
||||
uuid?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface OuterGuiItemEventPayload {
|
||||
user_id_list?: Array<number | string>;
|
||||
response?: OuterGuiItemEventResponse;
|
||||
}
|
||||
|
||||
export interface FsRemoveNodeTarget {
|
||||
get?: (key: string) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface FsRemoveNodeEventPayload {
|
||||
target?: FsRemoveNodeTarget;
|
||||
}
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
/// <reference path="../../api.d.ts" />
|
||||
|
||||
export {};
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
import '@heyputer/backend/src/services/User.js';
|
||||
declare module '../../packages/puter/src/backend/src/services/User.d.ts' {
|
||||
export interface IUser {
|
||||
stripe_customer_id?: string;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { DynamoKVStore } from '@heyputer/backend/src/services/DynamoKVStore/DynamoKVStore.js';
|
||||
import { FSController } from './controllers/FSController.js';
|
||||
import { FSEntryCacheInvalidationEventHandler } from './eventHandlers/FSEntryCacheInvalidationEventHandler.js';
|
||||
import { FSEntryRepository } from './repositories/FSEntryRepository.js';
|
||||
import { S3StorageProvider } from './repositories/S3FileStorageRepository.js';
|
||||
import { FSEntryService } from './services/FSEntryService.js';
|
||||
|
||||
const databaseService = extension.import('service:database');
|
||||
const { cache, s3ClientProvider } = extension.import('data');
|
||||
const eventService = extension.import('service:event');
|
||||
const kvStore = extension.import('service:puter-kvstore') as DynamoKVStore;
|
||||
const filesystemDb = databaseService;
|
||||
|
||||
const fsEntryRepository = new FSEntryRepository(filesystemDb, cache, kvStore);
|
||||
const s3StorageProvider = new S3StorageProvider(s3ClientProvider);
|
||||
const fsEntryService = new FSEntryService(fsEntryRepository, s3StorageProvider);
|
||||
|
||||
const fsController = new FSController(fsEntryService, eventService);
|
||||
(fsController as unknown as { registerRoutes: () => void }).registerRoutes();
|
||||
new FSEntryCacheInvalidationEventHandler(fsEntryRepository);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,383 +0,0 @@
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectCommand,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
UploadPartCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import type { s3ClientProvider } from '@heyputer/backend/src/clients/s3/s3ClientProvider.js';
|
||||
import { Readable } from 'node:stream';
|
||||
import type {
|
||||
MultipartCompleteInput,
|
||||
ServerUploadInput,
|
||||
SignedMultipartPartUrlsInput,
|
||||
SignedUploadInput,
|
||||
SignedUploadPart,
|
||||
SignedUploadResult,
|
||||
} from './s3Types.js';
|
||||
|
||||
export class S3StorageProvider {
|
||||
|
||||
#s3ClientProvider: typeof s3ClientProvider;
|
||||
|
||||
constructor (s3Provider: typeof s3ClientProvider) {
|
||||
this.#s3ClientProvider = s3Provider;
|
||||
}
|
||||
|
||||
#getClientForRegion (region: string): S3Client {
|
||||
return this.#s3ClientProvider.get(region);
|
||||
}
|
||||
|
||||
getMaxSingleUploadSize (): number {
|
||||
return this.#s3ClientProvider.maxSingleUploadSize;
|
||||
}
|
||||
|
||||
getMultipartPartSize (): number {
|
||||
return this.#s3ClientProvider.partSize;
|
||||
}
|
||||
|
||||
#resolveMultipartPartSize (requestedPartSize?: number): number {
|
||||
return Math.max(
|
||||
this.getMaxSingleUploadSize(),
|
||||
requestedPartSize ?? this.getMultipartPartSize(),
|
||||
);
|
||||
}
|
||||
|
||||
async createSignedUploadUrl (fileMetadata: SignedUploadInput, region: string): Promise<SignedUploadResult> {
|
||||
const [result] = await this.batchCreateSignedUploadUrls([fileMetadata], region);
|
||||
if ( ! result ) {
|
||||
throw new Error('Failed to create signed upload url');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async batchCreateSignedUploadUrls (filesMetadata: SignedUploadInput[], region: string): Promise<SignedUploadResult[]> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
const now = Date.now();
|
||||
const settledResults = await Promise.allSettled(filesMetadata.map(async (fileMetadata) => {
|
||||
const expiresInSeconds = Math.max(60, Math.min(60 * 60, fileMetadata.expiresInSeconds));
|
||||
const expiresAt = now + expiresInSeconds * 1000;
|
||||
const maxSingleUploadSize = this.getMaxSingleUploadSize();
|
||||
const shouldUseSingleUpload = fileMetadata.uploadMode === 'single'
|
||||
&& fileMetadata.size <= maxSingleUploadSize;
|
||||
|
||||
if ( shouldUseSingleUpload ) {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: fileMetadata.bucket,
|
||||
Key: fileMetadata.objectKey,
|
||||
ContentType: fileMetadata.contentType,
|
||||
});
|
||||
const url = await getSignedUrl(client, command, { expiresIn: expiresInSeconds });
|
||||
return {
|
||||
uploadMode: 'single' as const,
|
||||
expiresAt,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
const multipartPartSize = this.#resolveMultipartPartSize(fileMetadata.multipartPartSize);
|
||||
const multipartPartCount = Math.max(1, Math.ceil(fileMetadata.size / multipartPartSize));
|
||||
let multipartUploadId: string | undefined;
|
||||
|
||||
try {
|
||||
const multipartResult = await client.send(new CreateMultipartUploadCommand({
|
||||
Bucket: fileMetadata.bucket,
|
||||
Key: fileMetadata.objectKey,
|
||||
ContentType: fileMetadata.contentType,
|
||||
}));
|
||||
|
||||
if ( ! multipartResult.UploadId ) {
|
||||
throw new Error('Failed to initialize multipart upload');
|
||||
}
|
||||
|
||||
multipartUploadId = multipartResult.UploadId;
|
||||
const partUrls = await this.createSignedMultipartPartUrls({
|
||||
bucket: fileMetadata.bucket,
|
||||
objectKey: fileMetadata.objectKey,
|
||||
multipartUploadId,
|
||||
partNumbers: Array.from({ length: multipartPartCount }, (_, index) => index + 1),
|
||||
expiresInSeconds,
|
||||
}, region);
|
||||
|
||||
return {
|
||||
uploadMode: 'multipart' as const,
|
||||
expiresAt,
|
||||
multipartUploadId,
|
||||
multipartPartSize,
|
||||
multipartPartCount,
|
||||
multipartPartUrls: partUrls,
|
||||
};
|
||||
} catch ( error ) {
|
||||
if ( multipartUploadId ) {
|
||||
try {
|
||||
await this.abortMutipartUpload(
|
||||
multipartUploadId,
|
||||
region,
|
||||
fileMetadata.bucket,
|
||||
fileMetadata.objectKey,
|
||||
);
|
||||
} catch {
|
||||
// Best effort cleanup for partially initialized multipart uploads.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
const failedResults = settledResults.filter((result) => result.status === 'rejected');
|
||||
if ( failedResults.length > 0 ) {
|
||||
await Promise.allSettled(settledResults.map((result, index) => {
|
||||
if ( result.status !== 'fulfilled' ) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if ( result.value.uploadMode !== 'multipart' || !result.value.multipartUploadId ) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const fileMetadata = filesMetadata[index];
|
||||
if ( ! fileMetadata ) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.abortMutipartUpload(
|
||||
result.value.multipartUploadId,
|
||||
region,
|
||||
fileMetadata.bucket,
|
||||
fileMetadata.objectKey,
|
||||
);
|
||||
}));
|
||||
|
||||
const firstFailure = failedResults[0]?.reason;
|
||||
if ( firstFailure instanceof Error ) {
|
||||
throw firstFailure;
|
||||
}
|
||||
throw new Error('Failed to create signed upload urls');
|
||||
}
|
||||
|
||||
return settledResults.map((result) => {
|
||||
if ( result.status !== 'fulfilled' ) {
|
||||
throw new Error('Failed to create signed upload urls');
|
||||
}
|
||||
return result.value;
|
||||
});
|
||||
}
|
||||
|
||||
async createSignedMultipartPartUrls (
|
||||
input: SignedMultipartPartUrlsInput,
|
||||
region: string,
|
||||
): Promise<SignedUploadPart[]> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
const expiresInSeconds = Math.max(60, Math.min(60 * 60, input.expiresInSeconds));
|
||||
|
||||
return Promise.all(input.partNumbers.map(async (partNumber) => {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: input.multipartUploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
const url = await getSignedUrl(client, command, {
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
return {
|
||||
partNumber,
|
||||
url,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async completeMultipartUpload (input: MultipartCompleteInput, region: string): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
await client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: input.multipartUploadId,
|
||||
MultipartUpload: {
|
||||
Parts: [...input.parts]
|
||||
.sort((partA, partB) => partA.partNumber - partB.partNumber)
|
||||
.map((part) => ({
|
||||
PartNumber: part.partNumber,
|
||||
ETag: part.etag,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async abortMutipartUpload (uploadId: string, region: string, bucket: string, objectKey: string): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
await client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
UploadId: uploadId,
|
||||
}));
|
||||
}
|
||||
|
||||
async uploadFromServer (input: ServerUploadInput, region: string): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
const maxSingleUploadSize = this.getMaxSingleUploadSize();
|
||||
const resolvedContentLength = this.#resolveContentLength(input);
|
||||
const shouldUseMultipart = input.body instanceof Readable
|
||||
? resolvedContentLength === undefined || resolvedContentLength > maxSingleUploadSize
|
||||
: resolvedContentLength !== undefined && resolvedContentLength > maxSingleUploadSize;
|
||||
|
||||
if ( ! shouldUseMultipart ) {
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
ContentType: input.contentType,
|
||||
Body: input.body,
|
||||
...(input.contentLength !== undefined ? { ContentLength: input.contentLength } : {}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#uploadFromServerMultipart(input, region, this.#resolveMultipartPartSize());
|
||||
}
|
||||
|
||||
async deleteObject (bucket: string, objectKey: string, region: string): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
await client.send(new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
}));
|
||||
}
|
||||
|
||||
#resolveContentLength (input: ServerUploadInput): number | undefined {
|
||||
if ( Number.isFinite(input.contentLength) && Number(input.contentLength) >= 0 ) {
|
||||
return Number(input.contentLength);
|
||||
}
|
||||
if ( Number.isFinite(input.sizeHint) && Number(input.sizeHint) >= 0 ) {
|
||||
return Number(input.sizeHint);
|
||||
}
|
||||
if ( Buffer.isBuffer(input.body) || input.body instanceof Uint8Array ) {
|
||||
return input.body.byteLength;
|
||||
}
|
||||
if ( typeof input.body === 'string' ) {
|
||||
return Buffer.byteLength(input.body);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#toBuffer (chunk: unknown): Buffer {
|
||||
if ( Buffer.isBuffer(chunk) ) {
|
||||
return chunk;
|
||||
}
|
||||
if ( chunk instanceof Uint8Array ) {
|
||||
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
}
|
||||
if ( typeof chunk === 'string' ) {
|
||||
return Buffer.from(chunk);
|
||||
}
|
||||
throw new Error('Unsupported chunk type for multipart upload');
|
||||
}
|
||||
|
||||
async #uploadFromServerMultipart (
|
||||
input: ServerUploadInput,
|
||||
region: string,
|
||||
partSize: number,
|
||||
): Promise<void> {
|
||||
const client = this.#getClientForRegion(region);
|
||||
const createResult = await client.send(new CreateMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
ContentType: input.contentType,
|
||||
}));
|
||||
|
||||
const uploadId = createResult.UploadId;
|
||||
if ( ! uploadId ) {
|
||||
throw new Error('Failed to initialize multipart upload');
|
||||
}
|
||||
|
||||
const completedParts: Array<{ ETag: string; PartNumber: number; }> = [];
|
||||
let partNumber = 1;
|
||||
|
||||
const uploadPart = async (partBody: Buffer) => {
|
||||
const uploadPartResult = await client.send(new UploadPartCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
Body: partBody,
|
||||
ContentLength: partBody.byteLength,
|
||||
}));
|
||||
if ( ! uploadPartResult.ETag ) {
|
||||
throw new Error(`Multipart upload returned no ETag for part ${partNumber}`);
|
||||
}
|
||||
completedParts.push({
|
||||
ETag: uploadPartResult.ETag,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
partNumber++;
|
||||
};
|
||||
|
||||
try {
|
||||
if ( Buffer.isBuffer(input.body) || input.body instanceof Uint8Array ) {
|
||||
const bufferBody = this.#toBuffer(input.body);
|
||||
for ( let offset = 0; offset < bufferBody.byteLength; offset += partSize ) {
|
||||
const partBody = bufferBody.subarray(offset, offset + partSize);
|
||||
await uploadPart(partBody);
|
||||
}
|
||||
} else if ( typeof input.body === 'string' ) {
|
||||
const bufferBody = Buffer.from(input.body);
|
||||
for ( let offset = 0; offset < bufferBody.byteLength; offset += partSize ) {
|
||||
const partBody = bufferBody.subarray(offset, offset + partSize);
|
||||
await uploadPart(partBody);
|
||||
}
|
||||
} else if ( input.body instanceof Readable ) {
|
||||
let pendingChunk: Buffer = Buffer.alloc(0);
|
||||
for await ( const chunk of input.body ) {
|
||||
const chunkBuffer = this.#toBuffer(chunk);
|
||||
if ( chunkBuffer.byteLength === 0 ) {
|
||||
continue;
|
||||
}
|
||||
pendingChunk = pendingChunk.byteLength === 0
|
||||
? chunkBuffer
|
||||
: Buffer.concat([pendingChunk, chunkBuffer]);
|
||||
while ( pendingChunk.byteLength >= partSize ) {
|
||||
const partBody = pendingChunk.subarray(0, partSize);
|
||||
await uploadPart(partBody);
|
||||
pendingChunk = pendingChunk.subarray(partSize);
|
||||
}
|
||||
}
|
||||
if ( pendingChunk.byteLength > 0 ) {
|
||||
await uploadPart(pendingChunk);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unsupported body type for multipart upload');
|
||||
}
|
||||
|
||||
if ( completedParts.length === 0 ) {
|
||||
await client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: uploadId,
|
||||
}));
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
ContentType: input.contentType,
|
||||
Body: Buffer.alloc(0),
|
||||
ContentLength: 0,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: completedParts,
|
||||
},
|
||||
}));
|
||||
} catch ( error ) {
|
||||
await client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.objectKey,
|
||||
UploadId: uploadId,
|
||||
})).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
export interface SignedUploadInput {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadMode: 'single' | 'multipart';
|
||||
expiresInSeconds: number;
|
||||
multipartPartSize?: number;
|
||||
}
|
||||
|
||||
export interface SignedUploadPart {
|
||||
partNumber: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SignedUploadResult {
|
||||
uploadMode: 'single' | 'multipart';
|
||||
expiresAt: number;
|
||||
url?: string;
|
||||
multipartUploadId?: string;
|
||||
multipartPartSize?: number;
|
||||
multipartPartCount?: number;
|
||||
multipartPartUrls?: SignedUploadPart[];
|
||||
}
|
||||
|
||||
export interface MultipartCompletePart {
|
||||
partNumber: number;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface MultipartCompleteInput {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
multipartUploadId: string;
|
||||
parts: MultipartCompletePart[];
|
||||
}
|
||||
|
||||
export interface SignedMultipartPartUrlsInput {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
multipartUploadId: string;
|
||||
partNumbers: number[];
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
export interface ServerUploadInput {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
contentType: string;
|
||||
body: Buffer | Uint8Array | string | Readable;
|
||||
contentLength?: number;
|
||||
sizeHint?: number;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { FSEntryCreateInput } from '../types/FSEntry.js';
|
||||
|
||||
export interface FSEntryRow {
|
||||
id: number;
|
||||
uuid: string;
|
||||
bucket: string | null;
|
||||
bucket_region: string | null;
|
||||
public_token: string | null;
|
||||
file_request_token: string | null;
|
||||
is_shortcut: number | boolean;
|
||||
shortcut_to: number | null;
|
||||
user_id: number;
|
||||
parent_id: number | null;
|
||||
parent_uid: string | null;
|
||||
associated_app_id: number | null;
|
||||
is_dir: number | boolean;
|
||||
layout: string | null;
|
||||
sort_by: 'name' | 'modified' | 'type' | 'size' | null;
|
||||
sort_order: 'asc' | 'desc' | null;
|
||||
is_public: number | boolean | null;
|
||||
thumbnail: string | null;
|
||||
immutable: number | boolean;
|
||||
name: string;
|
||||
metadata: string | null;
|
||||
modified: number;
|
||||
created: number | null;
|
||||
accessed: number | null;
|
||||
size: number | null;
|
||||
symlink_path: string | null;
|
||||
is_symlink: number | boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface NormalizedEntryWrite {
|
||||
index: number;
|
||||
input: FSEntryCreateInput;
|
||||
userId: number;
|
||||
targetPath: string;
|
||||
parentPath: string;
|
||||
fileName: string;
|
||||
metadataJson: string | null;
|
||||
bucket: string | null;
|
||||
bucketRegion: string | null;
|
||||
size: number;
|
||||
createPaths: boolean;
|
||||
}
|
||||
|
||||
export interface ReadEntriesByPathsOptions {
|
||||
useTryHardRead?: boolean;
|
||||
skipCache?: boolean;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"*.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { handleInstalledApps } from './installedApps.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||||
({ query }) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.user.create({
|
||||
username: `iauser_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
};
|
||||
|
||||
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}`,
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
};
|
||||
|
||||
const grantInstalled = async (appId: number, userId: number) => {
|
||||
// Mimic the `flag:app-is-authenticated` perm row the handler joins on.
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||||
[userId, appId, 'flag:app-is-authenticated', null],
|
||||
);
|
||||
};
|
||||
|
||||
describe('installedApps extension — handleInstalledApps', () => {
|
||||
it('throws HttpError(401) when no actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleInstalledApps(makeReq({}), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when orderBy is not in the allowlist', async () => {
|
||||
const user = await seedUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
},
|
||||
},
|
||||
() =>
|
||||
handleInstalledApps(
|
||||
// SQL injection attempt — must be rejected.
|
||||
makeReq({ orderBy: 'apps.id; DROP TABLE apps;--' }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Invalid orderBy'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty list for a user with no installed apps', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the caller’s installed apps with an iconUrl field', 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).toHaveLength(1);
|
||||
expect(list[0].uid).toBe(app!.uid);
|
||||
expect(list[0].name).toBe(app!.name);
|
||||
expect(list[0].title).toBe(app!.title);
|
||||
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
// page=0 and limit=999 should be clamped without throwing.
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() =>
|
||||
handleInstalledApps(
|
||||
makeReq({ page: 0, limit: 999, desc: '1' }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
expect(Array.isArray(captured.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1657 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user