Everything in this repository is meant to run on your own Cloudflare account.
There is no hosted instance to fall back to and nothing withheld from the open
source: wrangler.toml carries placeholders, .dev.vars.example lists every
key, and no operator-specific behaviour exists outside the seams docs/ARCHITECTURE.md
§6 describes.
Budget about fifteen minutes, plus however long the provider app registration takes — Dropbox is minutes, Google is the longest.
Two things about the commands below. They are all run from the repository root —
wrangleris a dependency ofapps/apiand not of the root, which is why they reach it aspnpm --filter @skysa/api exec wranglerrather than by changing directory; runpnpm run deployfrom insideapps/apiand you get that package's owndeploy, which is not the one you want. And it ispnpm run setup, notpnpm setup: bothsetupanddeployare pnpm's own built-in commands, so therunis required or pnpm does something else entirely.
- A Cloudflare account. Workers Free is enough indefinitely for personal use; see Cost before running an instance for other people.
- Node 22.13 or later and pnpm 10 (
corepack enable). - An app registration at each storage provider you want to offer. Every
OAuth provider needs its own; there is no way around this, and it is why
ENABLED_PROVIDERSdefaults to justdropbox. - A domain on Cloudflare, if you want something other than
<worker>.<subdomain>.workers.dev.
git clone https://github.com/skysa-org/skysa-notes.git
cd skysa-notes
pnpm install
pnpm run setup # asks for what it needs, writes apps/api/.dev.varspnpm run setup asks which providers you want and only asks for the credentials
of those. That is not politeness: listing a provider in ENABLED_PROVIDERS
commits the deployment to having its credentials, and the Worker refuses to
boot without them rather than starting up and failing at the first connect.
It also generates SECRETS_KEY — 32 random bytes, base64 — wherever openssl
is on the path, and tells you how to generate one yourself where it is not. A
value that does not decode to exactly 32 bytes is refused at boot.
It will not write a file the Worker would refuse to boot from. An empty answer to a required key, or to all three providers, is asked again rather than written down.
You can write apps/api/.dev.vars by hand instead. Copy .dev.vars.example,
which documents every key including the exact redirect URI, scopes and account
types each provider wants.
The full instructions for each live in
.dev.vars.example next to the key they fill in, so
they cannot drift from the code that reads them. In summary:
| Provider | Where | The thing people get wrong |
|---|---|---|
| Dropbox | App Console → Scoped access, App folder | The app name is immutable and must be skysa-notes |
| OneDrive | Entra app registration | Platform is Web, not SPA — the exchange happens on the server, with the secret |
| Google Drive | Cloud console → OAuth client, Web application | Testing status caps you at 100 test users and expires refresh tokens after 7 days |
Each redirect URI is <APP_ORIGIN>/api/auth/connect/<provider>/callback, so
they all change when you move from http://localhost:5173 to your real domain.
Register both if you want to keep developing against the same app.
Google is involved enough to have its own walk-through, including leaving
Testing status without needing verification:
docs/google-oauth.md.
pnpm db:migrate # create and migrate the local D1 database
pnpm dev # Vite on :5173, wrangler dev on :8787Vite proxies /api to the Worker so the app and the API share an origin, which
is what production does too — one Worker serves both.
Connect an account and create a note. If it syncs here, the only things left that can be wrong in production are the origin, the secrets and the database.
pnpm --filter @skysa/api exec wrangler login
pnpm --filter @skysa/api exec wrangler d1 create skysa-notesThat prints a database_id. Put it in apps/api/wrangler.toml, replacing the
placeholder:
[[d1_databases]]
binding = "DB"
database_name = "skysa-notes"
database_id = "paste-it-here"This is the one edit to a tracked file that self-hosting requires. It is not a secret — it names a database only your account can reach.
.dev.vars is for local development and is gitignored. A deployed Worker reads
secrets from Cloudflare, so set each one:
w() { pnpm --filter @skysa/api exec wrangler "$@"; }
w secret put APP_ORIGIN # https://notes.example.com
w secret put SECRETS_KEY
w secret put DROPBOX_CLIENT_ID
w secret put DROPBOX_CLIENT_SECRET
# …and the pair for each other provider in ENABLED_PROVIDERSAPP_ORIGIN has no default and the Worker will not boot without it. That is
deliberate: every OAuth redirect URI is derived from it, and a wrong guess sends
users' consent to somewhere that is not you.
AUTH_MODE, ENABLED_PROVIDERS and WEBDAV_ALLOW_PRIVATE are not secrets and
live in [vars] in wrangler.toml. Edit them there.
SECRETS_KEY_ID defaults to k1 and only needs setting when you rotate
SECRETS_KEY, which is what it exists for. MICROSOFT_TENANT defaults to
common and only needs setting for a single-tenant Entra registration — and it
has to be set as a secret like the rest, because wrangler.toml has no [vars]
entry for it.
Treat SECRETS_KEY with the same weight as the database itself: it is what
encrypts every stored refresh token, and whoever holds both holds every
connected account on the instance.
pnpm --filter @skysa/api exec wrangler d1 migrations apply skysa-notes --remote
pnpm run deploypnpm run deploy builds the core package, then the web bundle, then the Worker,
then deploys. The order matters, and so does being at the repository root:
apps/api's own deploy script is also called deploy and does not build the
SPA, so reaching that one — by running it from inside apps/api — ships
whatever stale apps/web/dist happens to be lying around, or nothing at all.
For a custom domain, add a route in the Cloudflare dashboard (Workers → your
Worker → Settings → Domains & Routes), then make APP_ORIGIN match it exactly,
scheme and all, and add the matching redirect URIs at each provider.
The shell is served with Strict-Transport-Security: max-age=63072000: a
browser that has loaded it once will not reach that host over plain HTTP again
for two years. It says nothing about your other hosts, on purpose. If every
subdomain under the one you serve from is yours to commit to HTTPS, add
; includeSubDomains to that line in apps/web/public/_headers before you
deploy — and ; preload only if you mean to submit the domain to the preload
list, which is slow to undo. Served from an apex domain, includeSubDomains
covers every subdomain you own. apps/web/tests/csp.test.ts holds the default
and will fail on your fork once you change it; change its assertion with it.
Migrations are separate from deploys on purpose. pnpm --filter @skysa/api run deploy:migrate does both in order when you want them together.
- The app loads and creates notes offline.
- Connecting an account redirects to the provider and comes back to
?connect=ok. - A note appears in the app folder in your storage within a few seconds.
wrangler tailshows no errors. Note content never appears in the logs, because it never reaches the Worker at all.
Workers Free is enough for one person indefinitely. The Worker is only involved in connecting an account and in minting access tokens — never in reading or writing a note, which goes straight from the browser to the storage provider — so a browser costs it tens of requests a day rather than thousands, against a 100k/day limit. Static assets are free and unlimited on both plans.
Running an instance for other people, two things to know:
- Free hard-stops at 100k requests/day until 00:00 UTC, which would break
token refresh for every user at once. Workers Paid bills per request instead,
lifts the CPU limit, extends logs to 7 days and enables Logpush.
wrangler.tomlalready setslimits.cpu_ms = 50to cap runaway cost; it is harmless on Free. - Back up D1.
wrangler d1 exporton a cron trigger. The database holds encrypted refresh tokens and connection metadata — never note content, which is in the user's own storage and their browser.
Pricing verified 2026-09; check Cloudflare's own pages before relying on it.
The app builds as "Notes", in gray, with generic icons and the system's fonts.
To give your instance its own name and look, write a brand.json of your own,
outside this repository, and point NOTES_BRAND at it when you build:
NOTES_BRAND="$PWD/../my-brand/brand.json" pnpm run deploy(The file itself is plain JSON; the comments are only for this page.)
brandfills buttons and colours links,onBrandis the text on it, andthemecolours the browser's and the installed app's title bar. PickbrandandonBrandwith enough contrast between them, andbrandagainst the page, in each theme.- The icon folder holds six PNGs at these names and sizes:
favicon-16x16.png,favicon-32x32.png,apple-touch-icon.png(180),pwa-192x192.png,pwa-512x512.pngandpwa-maskable-512x512.png, which keeps its artwork inside the central 80% circle. To put the default artwork on your own colour, seeapps/web/brand/recolour.mjs. - Fonts are
.woff2files served from your instance, never from a font CDN, which the Content-Security-Policy would refuse. - A relative
NOTES_BRANDis resolved from the directory the build runs in; an absolute path avoids the question.
The build stops with the file and the problem if anything in it is wrong.
The brand changes what people see and nothing about their data: the folder in
their storage and the app's local database keep their names, so changing the
brand later keeps every connected library. On names and marks, see
TRADEMARK.md.
By default every account that connects may sync. To choose which ones, give
createApp an EntitlementProvider of your own. It is asked when an account
connects and each time a device mints a token. It can say no with a reason
in your words and a code from a fixed list: not_allowed, lapsed or
limit_reached.
The app words a refusal by its code in both places it can come. Your reason
reaches only one of them: the storage panel, for an account that is already
connected and refused a token later. An account refused as it first connects
sees only the code's wording, because that refusal comes back in a URL, and a
URL does not carry free text. docs/ARCHITECTURE.md §6, "Entitlement seam",
has the details.
Give it a gate too, and people see your message and one link where the
connect buttons are, before a provider's consent screen ends in a refusal. A
device that already has an account syncing on your instance still sees the
buttons, with the gate beside them. Every other device sees the gate, plus an
"Already have access?" control that shows the buttons. Showing them grants
nothing, because your policy still decides.
import type { EntitlementProvider } from '@skysa/core';
const invited = new Set(['dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc']);
export const entitlements: EntitlementProvider = {
check: ({ accountId }) =>
Promise.resolve(
invited.has(accountId)
? { allowed: true }
: { allowed: false, code: 'not_allowed', reason: 'This server syncs invited accounts.' }
),
gate: {
message: 'This server syncs invited accounts only.',
action: { label: 'Ask for an invite', url: 'https://example.com/invite' },
},
};A gate can ask for a code as well, when your policy lets accounts in by
something other than the account itself: an invite, or a code your own page
sends to someone who has paid. Give it connectCode: { label: 'Invite code' }
and the app shows a field under that label where the gate is. A gate that asks
for a code needs checkCode beside it, which the app asks as the code is used,
so a wrong one is said at once rather than after the provider's consent screen;
it answers how many seconds the app should keep the code (at most a year), or
why not. That is advice: whatever the app kept reaches your policy again at the
callback, as check(subject, { connectCode }), trimmed, at most 256 characters,
and never blank, and that is where it decides. /token does not pass it, so a
policy that accepts a code has to remember the account it let in:
export const entitlements: EntitlementProvider = {
check: async ({ provider, accountId }, context) => {
if (await admitted(provider, accountId)) return { allowed: true };
if (context?.connectCode !== undefined && (await redeem(context.connectCode))) {
await admit(provider, accountId);
return { allowed: true };
}
return { allowed: false, code: 'not_allowed' };
},
checkCode: async (code) =>
(await isOpen(code))
? { accepted: true, expiresIn: 15 * 60 }
: { accepted: false, reason: 'That invite code is not valid.' },
gate: {
message: 'This server syncs invited accounts only.',
action: { label: 'Ask for an invite', url: 'https://example.com/invite' },
connectCode: { label: 'Invite code' },
},
};admitted, redeem, admit and isOpen are yours: somewhere your Worker
keeps what it has let in. checkCode only reads; the callback is where an
invite is spent. The app words a plain refusal to a connect that carried a code
as "The code you entered was not accepted".
An accepted answer may also carry hold: something the app keeps and sends in
place of what was typed, with connects and to checkCode, for expiresIn.
An invite that becomes a pass for the device it was typed on is one use: the
invite is good once, briefly, and the pass, which your Worker issues and can
check without keeping, lets that device connect more accounts later without
another invite. The app never shows a held value, and asks about it again as
it loads, at most once an hour, keeping what you answer (a fresh hold keeps a
pass alive while the device is used) and letting it go if you refuse it. Don't
have a fresh hold end the one it replaces: two tabs loading together can
leave the earlier one held. A held value is a bearer: anyone who copies it out
of the browser can use it as that device would, until your policy stops taking
it. Apps from before holds existed refuse an answer longer than a day, so give
long holds once your deployment's app has been updated.
This goes in a Worker entry of your own. Copy apps/api/src/worker.ts, change
its createApp({ … }) call to pass entitlements as well, and point main in
wrangler.toml at your copy. Beside the original in apps/api/src, its
relative imports work as they are. Anywhere else, import createApp and
parseEnv from @skysa/api instead, and ConnectionRelay and
durableObjectRelay from @skysa/api/relay. Keep the copy's
export { ConnectionRelay } whether or not you run the relay: wrangler.toml
binds that class, and a deploy whose entry does not export it is refused. The
stock entry never sets a gate.
Keep the copy's try/catch. createApp checks the gate and throws on any of
these:
- a message that is blank or over 500 characters;
- a label that is blank or over 40 characters;
- a link that is not an
https:URL, or is over 2048 characters; - a code field whose label is blank or over 40 characters;
- a code field without
checkCode.
The entry then logs the problem and answers server_misconfigured, rather
than showing a broken link to your users. The link is served in its
normalized form, so https:example.com goes out as https://example.com/.
Off by default. With it off, a device finds another device's edits the next time it syncs: within a minute while the app is open, and at once when it is brought back to the front. With it on, it finds them within a second or two. A device that has just sent its changes says so over a WebSocket to the Worker, and the Worker tells the account's other devices, which then sync.
What it costs you in privacy terms. No note content and no file names pass through it: the message is "something changed", nothing else. But the Worker does learn when each connected account is edited, and from which device, which it otherwise would not — without it, it sees a token refresh about once an hour. Nothing per message is logged by the app; Cloudflare's own request logs record each socket being opened. Tell your users if that matters to them.
What it costs in requests. Each device opens a socket each time the app comes to the front: a ticket request, the upgrade, and a Durable Object request. Roughly 80 Worker requests per active user per day, plus a few hundred WebSocket messages billed at 20:1 as Durable Object requests.
- On Workers Paid that is about $0.0007 per active user per month (Cloudflare's published prices, 2026-10).
- On Workers Free the cost is the daily cap instead. 80 requests per user per day against 100k, shared with token refresh, puts an instance with a thousand daily users most of the way to the cap — and at the cap, sync stops for everyone until 00:00 UTC. For more than a handful of people, turn this on only on Paid. Durable Objects themselves are available on Free.
Turning it on. The Durable Object it needs (ConnectionRelay, bound as
RELAY_HUB) is already declared in wrangler.toml, so there is nothing to
create. Set RELAY = "true" in [vars] in wrangler.toml, deploy, and check:
curl -s https://notes.example.com/api/config
# … "relay":true …Apps open at the time pick it up the next time they load. Polling carries on
as before: the relay hears nothing about edits made in the folder by other
tools, such as the provider's own app, and a device whose socket has dropped
still syncs. Locally, put RELAY="true" in apps/api/.dev.vars.
Turning it off is RELAY = "false" and a deploy. The class can stay
declared; an object nothing addresses costs nothing. To remove it entirely,
delete the [[durable_objects.bindings]] block and add a migration that
deletes the class, then deploy:
[[migrations]]
tag = "v2"
deleted_classes = ["ConnectionRelay"]The [[migrations]] block with tag = "v1" stays: Cloudflare applies
migrations in order and remembers which it has.
git pull
pnpm install
pnpm --filter @skysa/api exec wrangler d1 migrations apply skysa-notes --remote
pnpm run deployDeploy from a release tag rather than from main (git tag --list 'v*',
then git checkout vX.Y.Z); each release's notes say whether it carries a
migration. The security of your instance is the security of the commit you
deployed. See SECURITY.md.
The Worker refuses to start rather than starting up wrong. Every failure is
reported as Invalid environment: followed by one line per problem — all of
them, not just the first. The three that catch people:
| The line | What to do |
|---|---|
APP_ORIGIN: Invalid input: expected string, received undefined |
No default exists; set it. (Invalid URL instead means it is set but malformed — the scheme is what is usually missing.) |
SECRETS_KEY: SECRETS_KEY must be base64 of exactly 32 bytes |
Regenerate: openssl rand -base64 32 |
DROPBOX_CLIENT_SECRET: required because ENABLED_PROVIDERS includes "dropbox" |
Register that provider's app, or drop it from ENABLED_PROVIDERS |
RELAY: true, but the RELAY_HUB Durable Object binding is missing from wrangler.toml |
Put back the [[durable_objects.bindings]] block, or set RELAY = "false" |
AUTH_MODE=account-first is refused the same way — "account-first is not
implemented and will not be". Sign-in separate from storage was dropped in
September 2026: on this instance a person is their storage account, and
accepting the setting would leave you believing connections were gated behind a
sign-in when they were not. storage-first is the only value, and the variable
itself will go (#118).
{ "$schema": "<path to this repo>/apps/web/brand/brand.schema.json", "name": "Acme Notes", "shortName": "Acme Notes", // under a home-screen icon: 12 characters at most "description": "Acme's notes, in your own storage.", "colors": { "light": { "brand": "#B91C1C", "onBrand": "#FFFFFF", "theme": "#FFFFFF" }, "dark": { "brand": "#F87171", "onBrand": "#1F2937", "theme": "#111827" }, "background": "#FFFFFF", }, "icons": "icons", // a folder beside this file "fonts": { // optional: either, both or neither "sans": { "family": "Inter", "files": [ { "src": "fonts/inter-latin-400.woff2", "weight": 400, "unicodeRange": "U+0000-00FF" }, { "src": "fonts/inter-latin-700.woff2", "weight": 700, "unicodeRange": "U+0000-00FF" }, ], }, }, }