mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-12 08:15:58 +00:00
* feat: bake published handlers into a generated events worker
* feat: deploy and address the per-app events worker behind a flag
* test: single delivery end to end through a real local worker
* feat: events workers run their own runtime, in their own namespace
An events worker was being deployed as an ordinary worker: default dispatch
namespace, a `subdomains` row, the router preamble, and an app-scoped worker
token baked in. The public dispatcher resolves any script in that namespace
straight off the hostname, so the worker answered at `<name>.puter.work`, and
the only thing in front of it was an unguessable name plus a check that a
`puter-auth` header was present — which the router never validates. Anyone who
learned the hostname could run an app's handlers with a body of their choosing,
in an isolate holding the owner's token as `me`.
Instead:
- Handlers run on their own runtime (`src/worker/src/events-runtime.js`), which
provides no `router` and no `me`, owns the single invoke route, and hands a
handler only `{ event, ctx, user, fetch, ack }`. `user` is built from the
invocation's delivery token, so a handler acts as the subscriber whose
delivery it is and nothing wider. The preamble build emits one bundle per
runtime; the shared half of the template is now included by both.
- The deploy target carries the runtime to prepend, the source to deploy, and
whether to mint a worker token at all, so an events worker deploys into the
`events` dispatch namespace from generated source with no token binding, no
`subdomains` row, and no claim on the owner's worker quota or worker list.
- An invocation carries a key derived from the deployment secret and the script
name, bound as a secret and checked in constant time inside the isolate,
which reads it once and drops it before handler code runs.
- Scripts are named after the handler set they contain, so publishing writes
rows and deploys nothing: a set is deployed the first time a delivery needs
it, and a changed set is a new script rather than an overwrite of a running
one. Publish responses keep the shape they had before the runtime existed.
- Invocations reach a worker only through the events dispatcher, which has no
zone route and requires the internal secret; the backend's own deploy path is
the rehydrate route the dispatcher calls on a namespace miss. Locally there is
no dispatcher, so the controller hands the service an in-process transport
that deploys on miss itself.
The SDK stops allowlisting `puter` as a handler global — a handler that reaches
for an ambient SDK is now refused at publish time, naming `user` instead, rather
than passing the scan and failing on its first delivery.
Requires `events.workerNamespace`, `events.dispatcherUrl` and
`events.internalSecret`; without them nothing is addressable and background
deliveries stay retriable, as they did with the runtime off.
* fix: a handler's delivery token gets through the read routes
An events handler acts as the subscriber through the access token its
invocation carried, but every FS read route refused scoped access tokens
outright, so `user.fs.stat(event.path)` — the design's own example — answered
403 inside the worker. The read-side routes now admit them; the ACL each
handler already runs intersects the token's grant with its issuer's, which is
the check that keeps a token to what it was minted for. The end-to-end suite
asserts the stat from inside the isolate.
* fix: shorthand-method handlers publish as functions
`{ ingest({ event }) { … } }` stringifies without the `function` keyword, so
its source is not an expression and the events worker baked it as a broken
stub — every delivery a retriable 500 until the subscription suspended, with
nothing at publish time to say why. The SDK now gives a shorthand method the
keyword before hashing and sending; getters, setters and computed names are
left for the server-side check to refuse.
* feat: an app's events worker is listable and destroyable
An app with published handlers has an events worker, and hosted deployments
bill it monthly per app, so its owner needs to see it and be able to take it
down. The core announces the lifecycle on the bus — `events.worker.create`
when an app's first handler is published, `events.worker.destroy` when its last
one goes — with the owner as the actor, so pricing can plug in from outside.
`GET /events/workers` lists the caller's workers (paginated, with the script
each set deploys as) and `POST /events/workers/destroy` removes every handler
of an app under the same owner scoping as the handler routes, suspending the
subscriptions bound to them. `puter.events.workers.list/destroy` in the SDK,
a docs page, and a 5 MB cap on an app's combined handler source
(`events_worker_too_large`) so a set that publishes can always deploy.
* fix: harden the events worker runtime for production
- A 4xx is terminal only when it carries the handled marker the runtime (and
the dispatcher) stamp on every answer that came from a script; an unmarked
4xx — an edge 404 for a wrong dispatcher hostname, a WAF page — stays
retriable and is logged, once per script per minute, with the runtime's
reason header.
- Script names are scoped to this backend's exposed API origin, so two
backends sharing a namespace never resolve one script with the wrong
endpoint binding or key. Shape unchanged.
- Each handler is validated in the exact context it is emitted into and the
whole generated file is compiled once; a source that would break the script
marks every handler broken instead of deploying a SyntaxError.
- Locally, events scripts live under their own registry key: the public local
worker host cannot reach them and an ordinary worker cannot take their name.
- A suspended or deleted app owner stops invocations; deploys are throttled
per app per hour; in-flight deploys are keyed by app and script; the
upstream deploy call times out; the generated source is size-capped with a
margin over the publish cap; boot fails when the runtime is on but its
preamble is not built. Byte-length secret compare, appUid shape check,
dispatcher URL prefix preserved, wider connection pool.
* feat: background workers are listed in the sessions manager
A user paying for an app's events worker needs somewhere to see it and take it
down. The sessions manager gets a section listing the apps that run event
handlers in the background, with a Destroy action that removes their published
handlers.
1250 lines
49 KiB
TypeScript
1250 lines
49 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import type { PuterRouter } from './core/http/PuterRouter';
|
|
|
|
export interface IAWSCredentials {
|
|
access_key?: string;
|
|
secret_key?: string;
|
|
region?: string;
|
|
}
|
|
|
|
export interface IDynamoConfig {
|
|
aws?: IAWSCredentials;
|
|
endpoint?: string;
|
|
/**
|
|
* Filesystem path for the local dynalite store. Defaults to
|
|
* `./volatile/runtime/puter-ddb`. Pass `':memory:'` (or set `inMemory:
|
|
* true`) to run dynalite without persistence — the recommended setup for
|
|
* unit/integration tests.
|
|
*/
|
|
path?: string;
|
|
/**
|
|
* Run dynalite in-memory with no on-disk state. Equivalent to `path:
|
|
* ':memory:'`. Intended for tests so each suite gets a pristine in-process
|
|
* DynamoDB.
|
|
*/
|
|
inMemory?: boolean;
|
|
/**
|
|
* Create required tables on startup if they don't exist. Off by default
|
|
* because real-AWS deployments provision tables externally (Terraform /
|
|
* IaC). Set to `true` when pointing at a local DynamoDB emulator so
|
|
* self-hosters don't have to bootstrap by hand.
|
|
*/
|
|
bootstrapTables?: boolean;
|
|
}
|
|
|
|
export interface IRedisConfig {
|
|
startupNodes?: Array<{
|
|
host: string;
|
|
port: number;
|
|
}>;
|
|
/**
|
|
* Use TLS for cluster connections. Defaults to `true` (matches prod
|
|
* ElastiCache). Set `false` for self-host plain-TCP Valkey/Redis.
|
|
*/
|
|
tls?: boolean;
|
|
/**
|
|
* Use ioredis-mock instead of a real Redis cluster — fully in-process, no
|
|
* network. Defaults to `true` when `startupNodes` is empty (so tests with
|
|
* no redis config get a mock for free). Intended for unit/integration
|
|
* tests.
|
|
*/
|
|
useMock?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Redis-backed read cache in front of the KV store's point reads (`get`, and
|
|
* the per-key half of a batch `get`). Off unless `enabled` is set.
|
|
*
|
|
* Only user/app namespaces are cached; internal state under the system
|
|
* namespace is always read through, as are consistent reads.
|
|
*
|
|
* Turning the cache off does not clear what it already holds. Entries stop
|
|
* being read but also stop being invalidated, so a disable followed by a
|
|
* re-enable inside `ttlSeconds` can serve values written in between — wait out
|
|
* `ttlSeconds` before switching back on.
|
|
*/
|
|
export interface IKvCacheConfig {
|
|
/** Master switch. Default false. */
|
|
enabled?: boolean;
|
|
/** Seconds a cached value is served for. Default 60. */
|
|
ttlSeconds?: number;
|
|
/**
|
|
* Seconds a cached absence is served for. Shorter than `ttlSeconds` because
|
|
* a key that doesn't exist yet is the one most likely to appear. Default
|
|
* 10.
|
|
*/
|
|
missTtlSeconds?: number;
|
|
/**
|
|
* Seconds after a write during which that key's reads bypass the cache.
|
|
* Must comfortably exceed how long a mutation takes to become visible to
|
|
* every reader, or a read that raced the write can re-cache the old value.
|
|
* Default 5.
|
|
*/
|
|
blockSeconds?: number;
|
|
/**
|
|
* Largest cached entry, in bytes of serialized envelope. Bigger values are
|
|
* read through — they earn the least per byte of cache memory. Default
|
|
* 32768.
|
|
*/
|
|
maxEntryBytes?: number;
|
|
/**
|
|
* Milliseconds invalidations accumulate for before one broadcast carries
|
|
* them all. Local invalidation is always immediate; this only batches the
|
|
* message to peers. 0 sends one per write. Default 250.
|
|
*/
|
|
broadcastCoalesceMs?: number;
|
|
}
|
|
|
|
/**
|
|
* Alert severity. Ordered `info` < `warning` < `error` < `critical`; each alert
|
|
* transport takes everything at or above its own `minSeverity`, so the severity
|
|
* a call site picks is what decides where the alarm lands.
|
|
*/
|
|
export type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
|
|
|
|
/** A severity, or `mute` to drop the alarm before any transport sees it. */
|
|
export type SeverityRule = PagerSeverity | 'mute';
|
|
|
|
export interface IPagerDutyConfig {
|
|
enabled?: boolean;
|
|
routingKey?: string;
|
|
/**
|
|
* Lowest severity that reaches PagerDuty. Default `warning`, which keeps
|
|
* `info` alarms out of the paging system entirely.
|
|
*/
|
|
minSeverity?: PagerSeverity;
|
|
}
|
|
|
|
export interface ISlackAlertConfig {
|
|
enabled?: boolean;
|
|
/** Incoming-webhook URL to post alerts to. */
|
|
webhookUrl?: string;
|
|
/** Channel override (e.g. `#alerts`). Defaults to the webhook's own. */
|
|
channel?: string;
|
|
/** Bot display name on the posted message. */
|
|
username?: string;
|
|
/** Lowest severity posted to Slack. Default `info`. */
|
|
minSeverity?: PagerSeverity;
|
|
/**
|
|
* Highest severity posted to Slack. Defaults to `info` when PagerDuty is
|
|
* configured — anything that pages belongs in the paging system, not in
|
|
* chat — and to `critical` (everything) when Slack is the only transport.
|
|
*/
|
|
maxSeverity?: PagerSeverity;
|
|
/**
|
|
* Don't repost the same alarm id within this window. The first occurrence
|
|
* always posts; repeats inside the window only bump the occurrence count
|
|
* that the next post reports. Default 15 minutes; `0` disables throttling.
|
|
*/
|
|
repeatThrottleMs?: number;
|
|
}
|
|
|
|
export interface IPagerConfig {
|
|
/** Severity used when a call site doesn't pass one. Default `critical`. */
|
|
defaultSeverity?: PagerSeverity;
|
|
/**
|
|
* Operator overrides keyed by alarm id, or by prefix with a trailing `*`
|
|
* (`cronMonitor:*`). Exact ids beat patterns and the longest matching
|
|
* prefix wins. Applied after the call site's severity and any known-error
|
|
* rule, so this is the final say — it can retier or mute a noisy alarm
|
|
* without a deploy.
|
|
*/
|
|
severityOverrides?: Record<string, SeverityRule>;
|
|
pagerduty?: IPagerDutyConfig;
|
|
slack?: ISlackAlertConfig;
|
|
}
|
|
|
|
export interface ICfFileCacheConfig {
|
|
/**
|
|
* POST endpoint that accepts batched `{ site, path }[]` invalidation
|
|
* payloads.
|
|
*/
|
|
endpoint: string;
|
|
/** Flush cadence in ms. Default 500. */
|
|
throttle_ms?: number;
|
|
}
|
|
|
|
export interface IClickhouseConfig {
|
|
url: string;
|
|
username?: string;
|
|
password?: string;
|
|
/** Milliseconds. Default 15000. */
|
|
request_timeout?: number;
|
|
/** Max pending rows before backpressure drops oldest. Default 100000. */
|
|
max_buffer_size?: number;
|
|
/** Rows per flush. Default 500. */
|
|
batch_size?: number;
|
|
/** Flush cadence in ms. Default 5000. */
|
|
flush_interval_ms?: number;
|
|
}
|
|
|
|
export interface IEmailConfig {
|
|
/** "From" address used when callers don't override. */
|
|
from?: string;
|
|
// nodemailer transport options (passed through as-is)
|
|
host?: string;
|
|
port?: number;
|
|
secure?: boolean;
|
|
auth?: {
|
|
user?: string;
|
|
pass?: string;
|
|
};
|
|
service?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/** Prelude (https://prelude.so) Verify v2 — SMS phone verification provider. */
|
|
export interface IPreludeConfig {
|
|
/** Prelude v2 API key (sent as `Authorization: Bearer <apiKey>`). */
|
|
apiKey?: string;
|
|
/** Default region for parsing local-format phone numbers (e.g. 'US'). */
|
|
defaultCountry?: string;
|
|
/** Per-SMS cost ceiling in EUR. */
|
|
maxSmsCostEur?: number;
|
|
/**
|
|
* Verification template id (from the Prelude dashboard) that controls the
|
|
* SMS wording — e.g. a "Your Puter verification code is {{code}}" template.
|
|
* The message text itself is authored in Prelude, not here; this just
|
|
* selects it. Omit to use the dashboard default.
|
|
*/
|
|
templateId?: string;
|
|
/**
|
|
* Alphanumeric Sender ID to brand who the SMS is "from" (e.g. "Puter").
|
|
* Must be pre-enabled by Prelude and isn't supported by all
|
|
* carriers/regions (notably US long/short codes). Omit to use Prelude's
|
|
* default sender.
|
|
*/
|
|
senderId?: string;
|
|
/**
|
|
* Channel Prelude prioritizes for delivery. Defaults to 'rcs' (much cheaper
|
|
* than SMS); Prelude falls back to SMS when RCS isn't reachable. Requires
|
|
* an RCS agent provisioned in the Prelude account to actually use RCS.
|
|
*/
|
|
preferredChannel?:
|
|
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
|
|
}
|
|
|
|
/**
|
|
* S3-compatible bucket the thumbnails extension uses for storing generated
|
|
* thumbnails. When unset, the extension falls back to the main `S3Client`
|
|
* (fauxqs locally, real S3 in prod) and writes into the default bucket.
|
|
*/
|
|
export interface IThumbnailStoreConfig {
|
|
/** Bucket name. Default: `puter-local`. */
|
|
name?: string;
|
|
/** Endpoint URL — unset forces the fallback. */
|
|
endpoint?: string;
|
|
credentials?: {
|
|
accessKeyId: string;
|
|
secretAccessKey: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Shape of an entry under `config.providers.*` — each AI / integration driver
|
|
* reads a slightly different subset of these keys. Kept permissive so new
|
|
* providers don't have to touch the root type.
|
|
*/
|
|
export interface IAIProviderConfig {
|
|
/**
|
|
* API key. Sole canonical name — drivers no longer accept
|
|
* `secret_key`/`api_key`/`key` aliases.
|
|
*/
|
|
apiKey?: string;
|
|
/**
|
|
* Cloudflare API token (semantically distinct from a regular key).
|
|
* Cloudflare-only.
|
|
*/
|
|
apiToken?: string;
|
|
/**
|
|
* Override the provider's HTTP base URL (OpenRouter, Cloudflare,
|
|
* ElevenLabs, Ollama).
|
|
*/
|
|
apiBaseUrl?: string;
|
|
/**
|
|
* Azure AI Foundry deployment endpoint (azure-openai). Required alongside
|
|
* `apiKey`.
|
|
*/
|
|
apiURL?: string;
|
|
/** Cloudflare account id. */
|
|
accountId?: string;
|
|
/** ElevenLabs default voice id. */
|
|
defaultVoiceId?: string;
|
|
/** ElevenLabs speech-to-speech model id. */
|
|
speechToSpeechModelId?: string;
|
|
/** Ollama toggle — defaults true; set `false` to disable. */
|
|
enabled?: boolean;
|
|
/** AWS credentials for AWS-backed providers (Polly, Textract). */
|
|
aws?: IAWSCredentials;
|
|
/** Escape hatch — providers often expose additional tuning knobs. */
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* OIDC provider sub-config (google, custom, …). `google` uses discovery, so
|
|
* only `client_id` + `client_secret` are required; custom providers must also
|
|
* supply the three endpoint URLs explicitly.
|
|
*/
|
|
export interface IOIDCProviderConfig {
|
|
client_id?: string;
|
|
client_secret?: string;
|
|
authorization_endpoint?: string;
|
|
token_endpoint?: string;
|
|
userinfo_endpoint?: string;
|
|
/** Space-separated OAuth scopes. Default depends on provider. */
|
|
scopes?: string;
|
|
/** Apple Developer Team ID (apple provider only). */
|
|
team_id?: string;
|
|
/** Key ID for the Sign in with Apple private key (apple provider only). */
|
|
key_id?: string;
|
|
/** PKCS#8 PEM private key content from Apple (apple provider only). */
|
|
private_key?: string;
|
|
/** Azure AD tenant ID (microsoft provider only). Defaults to "common". */
|
|
tenant_id?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface IOIDCConfig {
|
|
providers?: Record<string, IOIDCProviderConfig>;
|
|
}
|
|
|
|
export interface IPeersConfig {
|
|
/** WebRTC signaller URL returned to clients. */
|
|
signaller_url?: string;
|
|
/** Fallback ICE server list when TURN credential generation fails. */
|
|
fallback_ice?: unknown[];
|
|
/** TURN credential generation config (Cloudflare-backed). */
|
|
turn?: {
|
|
cloudflare_turn_service_id?: string;
|
|
cloudflare_turn_api_token?: string;
|
|
/** Credential TTL in seconds. Default 86400. */
|
|
ttl?: number;
|
|
};
|
|
/**
|
|
* Relay access for guests of an authenticated host, who mint credentials
|
|
* against a signed grant instead of an account of their own.
|
|
*/
|
|
guest_turn?: {
|
|
/**
|
|
* HMAC key for guest grants. Absent disables the guest routes with a
|
|
* 503 — a deployment opts into guest relay access by setting this. Must
|
|
* not be shared with any other secret.
|
|
*/
|
|
grant_secret?: string;
|
|
/** Grant lifetime in seconds. Default 3600. */
|
|
grant_ttl?: number;
|
|
/** Guest credential TTL in seconds, clamped to `turn.ttl`. Default 3600. */
|
|
credential_ttl?: number;
|
|
};
|
|
/** Shared secret for the internal `/turn/ingest-usage` endpoint. */
|
|
internal_auth_secret?: string;
|
|
}
|
|
|
|
export interface IBroadcastPeerConfig {
|
|
/** Stable id of the peer (also sent as `X-Broadcast-Peer-Id`). */
|
|
peerId?: string;
|
|
/** Whether this peer should receive webhooks. Non-webhook peers are skipped. */
|
|
webhook?: boolean;
|
|
/** HTTPS endpoint to POST broadcast events to. */
|
|
webhook_url?: string;
|
|
/** HMAC-SHA256 secret shared with the peer for signing. */
|
|
webhook_secret?: string;
|
|
}
|
|
|
|
export interface IBroadcastConfig {
|
|
peers?: IBroadcastPeerConfig[];
|
|
webhook?: {
|
|
/**
|
|
* This server's peerId, sent in outbound POSTs as
|
|
* `X-Broadcast-Peer-Id`.
|
|
*/
|
|
peerId?: string;
|
|
/** Secret used to sign OUTBOUND POSTs. */
|
|
secret?: string;
|
|
};
|
|
/**
|
|
* Reject webhooks whose timestamp is more than this many seconds in the
|
|
* past. Default 300.
|
|
*/
|
|
webhook_replay_window_seconds?: number;
|
|
/**
|
|
* Time to wait coalescing outbound events into a single peer POST. Default
|
|
* 2000ms.
|
|
*/
|
|
outbound_flush_ms?: number;
|
|
}
|
|
|
|
/** Cloudflare Workers deployment config used by `WorkerDriver`. */
|
|
export interface IWorkersConfig {
|
|
XAUTHKEY?: string;
|
|
ACCOUNTID?: string;
|
|
/**
|
|
* Optional dispatch namespace — when set, scripts deploy under
|
|
* `/dispatch/namespaces/<ns>`.
|
|
*/
|
|
namespace?: string;
|
|
/**
|
|
* Base URL included as the `puter_endpoint` binding. Default
|
|
* `https://api.puter.com`.
|
|
*/
|
|
internetExposedUrl?: string;
|
|
/**
|
|
* URL returned by `getLoggingUrl()` — surfaced to clients that render
|
|
* worker logs.
|
|
*/
|
|
loggingUrl?: string;
|
|
[key: string]: string | undefined;
|
|
}
|
|
|
|
/**
|
|
* Optional outbound-fetch proxy used by `secureFetch()` when the backend has to
|
|
* fetch a user-supplied URL (e.g. image-gen `input_image`). Requests get
|
|
* prefixed with `url` and sent through the Worker with `x-cors-proxy-auth-
|
|
* secret: <secret>`; the Worker authenticates the secret, fetches the real URL,
|
|
* and strips CORS on the response. Unset → fetches go direct (still guarded by
|
|
* the URL/redirect/DNS checks in secureFetch).
|
|
*/
|
|
export interface ISecureCorsProxyConfig {
|
|
url: string;
|
|
secret: string;
|
|
}
|
|
|
|
export interface IWispConfig {
|
|
/** WISP relay server address returned to clients on token create. */
|
|
server?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface IServerHealthConfig {
|
|
/** DB liveness latency threshold (ms). Default 1500. */
|
|
db_liveness_latency_fail_ms?: number;
|
|
/**
|
|
* Latency threshold for the primary-pinned probe (ms). Separate from
|
|
* `db_liveness_latency_fail_ms` because that one measures the local read
|
|
* path while this one crosses to whichever region holds the primary.
|
|
* Default 3000. Values at or above the 4000ms per-check timeout are moot —
|
|
* the check runner gives up first.
|
|
*/
|
|
db_primary_liveness_latency_fail_ms?: number;
|
|
/**
|
|
* Consecutive over-threshold runs before the primary probe reports
|
|
* unhealthy. Default 2, i.e. sustained slowness rather than one slow round
|
|
* trip. A primary that errors or hangs fails on the first run regardless.
|
|
*/
|
|
db_primary_liveness_breaches_to_fail?: number;
|
|
/** Staleness threshold for the health-check loop itself (ms). */
|
|
stale_health_loop_fail_ms?: number;
|
|
/**
|
|
* Cadence for the external-dependency probes (redis, dynamo, object store,
|
|
* primary database). Deliberately slower than the 5s check loop so probing
|
|
* a paid, rate-limited backing service stays a rounding error against real
|
|
* traffic. Default 30000.
|
|
*/
|
|
dependency_check_interval_ms?: number;
|
|
/** Redis liveness latency threshold (ms). Default 1000. */
|
|
redis_liveness_latency_fail_ms?: number;
|
|
/** Dynamo liveness latency threshold (ms). Default 1500. */
|
|
dynamo_liveness_latency_fail_ms?: number;
|
|
/** Object-store liveness latency threshold (ms). Default 2000. */
|
|
s3_liveness_latency_fail_ms?: number;
|
|
/**
|
|
* Check names to skip registering entirely — an operator kill switch for a
|
|
* probe that turns out to be noisy, without waiting on a deploy.
|
|
*/
|
|
disabled_checks?: string[];
|
|
}
|
|
|
|
export interface IS3LocalConfig {
|
|
/**
|
|
* Run fauxqs entirely in-memory: random port on `127.0.0.1`, no `dataDir` /
|
|
* `s3StorageDir`. Intended for tests so each suite gets a pristine
|
|
* in-process S3.
|
|
*/
|
|
inMemory?: boolean;
|
|
host?: string;
|
|
port?: number;
|
|
dataDir?: string;
|
|
s3StorageDir?: string;
|
|
}
|
|
|
|
export interface IS3RemoteConfig {
|
|
useCredentialChain?: boolean;
|
|
endpoint: string;
|
|
/**
|
|
* Endpoint used when generating presigned URLs handed to clients (browser
|
|
* uploads/downloads). Defaults to `endpoint`. Set this when the server-side
|
|
* S3 endpoint isn't reachable from the browser — e.g. self-host with
|
|
* `endpoint: http://s3:9000` (docker-internal) and `publicEndpoint:
|
|
* http://localhost:9000` (host-published port).
|
|
*/
|
|
publicEndpoint?: string;
|
|
accessKeyId: string;
|
|
secretAccessKey: string;
|
|
region?: string;
|
|
/**
|
|
* Use path-style URLs (`<endpoint>/<bucket>`) instead of virtual-hosted
|
|
* style (`<bucket>.<endpoint>`). Defaults to AWS SDK's default (virtual-
|
|
* hosted, which only works on real AWS S3). Set `true` for S3-compatible
|
|
* servers (RustFS, MinIO, fauxqs) where DNS-style addressing fails.
|
|
*/
|
|
forcePathStyle?: boolean;
|
|
}
|
|
|
|
export interface IS3Config {
|
|
localConfig?: IS3LocalConfig;
|
|
s3Config?: IS3RemoteConfig;
|
|
}
|
|
|
|
export interface IDatabaseConfig {
|
|
engine: 'sqlite' | 'mysql' | 'postgres';
|
|
// sqlite
|
|
/**
|
|
* SQLite database file path. Defaults to `':memory:'` (the better-sqlite3
|
|
* in-memory mode), which is also what tests should use. `inMemory: true` is
|
|
* an explicit alias for the same.
|
|
*/
|
|
path?: string;
|
|
/**
|
|
* Force in-memory SQLite (ignores `path`). Equivalent to `path:
|
|
* ':memory:'`. Intended for tests so each suite gets a pristine in-process
|
|
* database. Test utilities also use `engine: 'postgres'` with `inMemory:
|
|
* true` to run against pgmock.
|
|
*/
|
|
inMemory?: boolean;
|
|
targetVersion?: number;
|
|
// mysql
|
|
host?: string;
|
|
port?: number;
|
|
user?: string;
|
|
password?: string;
|
|
database?: string;
|
|
connectionString?: string;
|
|
url?: string;
|
|
replica?: {
|
|
host?: string;
|
|
port?: number;
|
|
user?: string;
|
|
password?: string;
|
|
database?: string;
|
|
connectionString?: string;
|
|
url?: string;
|
|
};
|
|
/**
|
|
* Server-side execution cap for SELECT statements in ms (mysql engine;
|
|
* applies to both pools — MySQL only enforces it on SELECTs, so writes are
|
|
* unaffected). 0 disables. Default 30000.
|
|
*/
|
|
selectTimeoutMs?: number;
|
|
/**
|
|
* Max time to wait for a pooled connection before the query batcher treats
|
|
* acquisition as failed, in ms (mysql engine). 0 disables the bound.
|
|
* Default 5000.
|
|
*/
|
|
acquireTimeoutMs?: number;
|
|
/**
|
|
* Ordered list of directories whose `.sql` files are run sequentially at
|
|
* server start (mysql/postgres engines). Numbered migration filenames sort
|
|
* numerically; directories are processed in array order. Files MUST be
|
|
* idempotent — there is no per-file applied-state tracking. Relative paths
|
|
* resolve from `process.cwd()`.
|
|
*/
|
|
migrationPaths?: string[];
|
|
}
|
|
|
|
/**
|
|
* Bucket of pass-through values surfaced to the client-side `gui()` boot
|
|
* function. Known fields are declared for lookup hygiene; unknown keys are
|
|
* still tolerated so product teams can add one-off flags without churn.
|
|
*/
|
|
export interface IGuiParams {
|
|
title?: string;
|
|
short_description?: string;
|
|
social_media_image?: string;
|
|
/**
|
|
* Public SDK key for Prelude's JS Signals SDK. When set, the phone-
|
|
* verification window collects a `dispatch_id` (browser signals) and
|
|
* forwards it to /send-confirm-phone, which passes it to Prelude's Verify
|
|
* API so its abuse model can weigh the device. Omit to disable collection.
|
|
*/
|
|
preludeSdkKey?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface IDevWatcherConfig {
|
|
/** Force-enable/disable the dev watcher. Defaults to enabled in dev. */
|
|
enabled?: boolean;
|
|
/** Root path for watcher entries. Relative paths resolve from package root. */
|
|
root?: string;
|
|
/** Delay after watcher startup before boot continues. Default: 5000. */
|
|
ready_delay_ms?: number;
|
|
/** Optional extra child processes to start with the dev watcher. */
|
|
commands?: Array<{
|
|
name: string;
|
|
directory: string;
|
|
command: string;
|
|
args?: string[];
|
|
env?: Record<string, string>;
|
|
}>;
|
|
/**
|
|
* Optional webpack watcher entries. Omit to use the built-in GUI/puter.js
|
|
* watchers.
|
|
*/
|
|
webpack?: Array<{
|
|
name?: string;
|
|
directory: string;
|
|
env?: Record<string, string>;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Complete shape of Puter's root config. Everything is optional here —
|
|
* mandatory fields (only `port` + `extensions`) are pulled out of the
|
|
* `Partial<...>` below and listed after it.
|
|
*
|
|
* When adding a new config field, declare it here with a doc comment so there's
|
|
* a single discoverable reference for every config-driven switch.
|
|
*
|
|
* One value, one location: each setting lives at exactly one key. There are no
|
|
* legacy aliases or fallback paths — older configs that relied on them need to
|
|
* migrate.
|
|
*/
|
|
interface IConfigOptional {
|
|
// -- Environment / identity --------------------------------------
|
|
|
|
/**
|
|
* Environment marker. `dev` disables blocked-email checks, opens
|
|
* auto-browser, etc.
|
|
*/
|
|
env: 'dev' | 'prod';
|
|
/**
|
|
* Free-form name of the config profile (e.g. `oss-default`). Surfaced in
|
|
* logs.
|
|
*/
|
|
config_name: string;
|
|
/**
|
|
* Console output format. `json` replaces the global console so every call
|
|
* emits one structured JSON line (`level`, `timestamp`, `msg`, and the
|
|
* active `traceId`) — one event per call, so a line-oriented log collector
|
|
* can't split stack traces across events, and level filtering works. `text`
|
|
* (the default) leaves console output human-readable for local/dev.
|
|
*/
|
|
log_format: 'json' | 'text';
|
|
/**
|
|
* Keep serving after an uncaught exception instead of exiting. Uncaught
|
|
* exceptions are always logged either way; this only decides whether one
|
|
* ends the process. Default: false, matching Node's own behavior. Set it
|
|
* where losing the node costs more than running a possibly-degraded one — a
|
|
* small pool behind a health check that replaces bad nodes anyway.
|
|
*/
|
|
keep_alive_on_uncaught: boolean;
|
|
/** Server version. Falls back to `npm_package_version`. */
|
|
version: string;
|
|
/**
|
|
* Stable identity for this server node. Enables pager alerts + graceful
|
|
* shutdown delay.
|
|
*/
|
|
serverId: string;
|
|
|
|
// -- Networking / URLs -------------------------------------------
|
|
|
|
/**
|
|
* Protocol used for the externally-visible origin ('http' or 'https').
|
|
* Default: 'http'.
|
|
*/
|
|
protocol: string;
|
|
/** Primary domain for Puter (e.g., `puter.localhost`, `puter.com`). */
|
|
domain: string;
|
|
/**
|
|
* Externally-visible port. Defaults to `port`. Behind a reverse proxy, set
|
|
* this to the public port.
|
|
*/
|
|
pub_port: number;
|
|
/**
|
|
* Teams and workspaces. Off means `/teams` 404s and the schema is inert, so
|
|
* the tables can ship to production before anything can create a workspace.
|
|
* It is also the backout: turning it off removes the feature without
|
|
* touching data.
|
|
*/
|
|
teams_enabled: boolean;
|
|
/**
|
|
* Fully-qualified externally-visible URL (protocol + domain + port).
|
|
* Computed from `protocol`/`domain`/`pub_port` if unset.
|
|
*/
|
|
origin: string;
|
|
/**
|
|
* Public base URL for the API subdomain, e.g. `https://api.puter.com`. Used
|
|
* to build signed URLs.
|
|
*/
|
|
api_base_url: string;
|
|
/** Static hosting domain for user sites (e.g., `puter.site`). */
|
|
static_hosting_domain: string;
|
|
/** Alt static hosting domain. */
|
|
static_hosting_domain_alt: string;
|
|
/** Private app hosting domain (e.g., `app.puter.localhost`). */
|
|
private_app_hosting_domain: string;
|
|
/** Alt private app hosting domain. */
|
|
private_app_hosting_domain_alt: string;
|
|
/**
|
|
* Groups of equivalent app index_url hosts. Each group lists hosts that
|
|
* should resolve to the same canonical app: `appUidFromOrigin` looks up any
|
|
* DB row whose `index_url` is one of the group's hosts and returns that
|
|
* row's UID for every host in the group.
|
|
*
|
|
* Hosts listed here are also reserved — `apps.create` / `apps.update`
|
|
* reject any attempt to register a different app under one of these hosts,
|
|
* so the group is owned by exactly one app row.
|
|
*
|
|
* Entries are bare hosts (no scheme), lowercased. Example: [
|
|
* ["camera.puter.com", "camera.puter.site", "camera.ca"],
|
|
* ["player.puter.com", "player.puter.site"], ]
|
|
*/
|
|
app_origin_aliases?: string[][];
|
|
/** When true, accept any Host header value. Dev/testing only. */
|
|
allow_all_host_values: boolean;
|
|
/** When true, accept requests without a Host header. */
|
|
allow_no_host_header: boolean;
|
|
/** When true, allow nip.io wildcard domains. */
|
|
allow_nipio_domains: boolean;
|
|
/** When true, support custom domain resolution for hosted sites. */
|
|
custom_domains_enabled: boolean;
|
|
/** When true, enable IP validation via event bus. */
|
|
enable_ip_validation: boolean;
|
|
/**
|
|
* Express `trust proxy` setting — controls how `req.ip` is derived from
|
|
* `X-Forwarded-For`. Set to the number of reverse-proxy hops in front of
|
|
* the server (e.g. `1` for a single Cloudflare or nginx hop, `2` for
|
|
* Cloudflare → ALB → app), or to a CIDR / IP / list of trusted proxy
|
|
* addresses. `false` (default) disables XFF parsing — `req.ip` returns the
|
|
* direct socket peer, which is the safe choice when no proxy is in front.
|
|
* Never set to `true` in production: it trusts _every_ hop and makes XFF
|
|
* forgeable. See https://expressjs.com/en/guide/behind-proxies.html.
|
|
*/
|
|
trust_proxy: boolean | number | string | string[];
|
|
/** Don't launch browser when starting. */
|
|
no_browser_launch: boolean;
|
|
/** Disable dev-time frontend webpack watchers. */
|
|
no_devwatch: boolean;
|
|
/**
|
|
* Skip first-boot bootstrap of the `admin` user and the credentials banner
|
|
* that DefaultUserService prints. Intended for tests.
|
|
*/
|
|
no_default_user: boolean;
|
|
/**
|
|
* Import `.ts` extension sources instead of built `.js`. Only for
|
|
* transform-capable runtimes (the test harness sets this); plain node
|
|
* cannot execute the TypeScript sources.
|
|
*/
|
|
import_ts_extensions?: boolean;
|
|
/** Optional dev-time frontend watcher overrides. */
|
|
devwatch: IDevWatcherConfig;
|
|
|
|
// -- Auth / session ----------------------------------------------
|
|
|
|
/** HMAC secret used to sign and verify auth JWTs (`kid: 'v2'`). */
|
|
jwt_secret_v2: string;
|
|
/**
|
|
* Optional extra `Origin` header values allowed to call the routes that
|
|
* hand back a session credential (`/login`, `/signup`,
|
|
* `/session/sync-cookie` — see `guiOriginGate`). The main `origin` is
|
|
* always allowed, and callers with no `Origin` at all (CLI, mobile,
|
|
* server-side, tests) are never gated.
|
|
*
|
|
* Only for deployments that genuinely serve their GUI from a different
|
|
* origin than `config.origin`. Do NOT list loopback origins in production:
|
|
* `http://localhost:4000` is not an authenticatable origin, it's whatever
|
|
* is listening on that port on the visitor's machine. A local GUI should
|
|
* obtain its token via the AuthMe flow instead.
|
|
*/
|
|
allow_gui_origins?: string[];
|
|
/** HMAC secret for signed file URLs (/file, /writeFile, /sign). */
|
|
url_signature_secret: string;
|
|
/** Name of the session cookie the auth probe reads. */
|
|
cookie_name: string;
|
|
/** Minimum password length for login/signup validation. */
|
|
min_pass_length: number;
|
|
/** When true, allow the 'system' user to log in. */
|
|
allow_system_login: boolean;
|
|
/**
|
|
* When true, anonymous users cannot create new accounts or temporary
|
|
* sessions. Existing accounts can still log in, and pre-existing
|
|
* placeholder rows may still be claimed.
|
|
*/
|
|
disable_user_signup: boolean;
|
|
/** Reject auth-gated routes unless the user has confirmed their email. */
|
|
strict_email_verification_required: boolean;
|
|
/**
|
|
* Force SMS phone verification on every new signup, regardless of abuse
|
|
* reputation. Off by default; mainly a test/QA switch so the phone gate can
|
|
* be exercised on demand (it otherwise only triggers for low-reputation
|
|
* signups). Requires `prelude.apiKey` to actually deliver codes.
|
|
*/
|
|
always_require_phone_verification: boolean;
|
|
/**
|
|
* Force credit-card verification on every new signup, regardless of abuse
|
|
* reputation. Off by default; mainly a test/QA switch so the card gate can
|
|
* be exercised on demand (it otherwise only triggers for low-reputation
|
|
* signups). Requires a payments extension to actually run the $0 auth.
|
|
*/
|
|
always_require_card_verification: boolean;
|
|
/**
|
|
* Let a user who keeps getting blocked on SMS phone verification fall back
|
|
* to credit-card verification, which clears the phone gate (and the card
|
|
* gate too, when one is set).
|
|
*
|
|
* The fallback opens once the user has made `after_attempts` SMS _send_
|
|
* attempts inside the send rate-limit window, which by default means only
|
|
* after they have used up the window's entire send allowance — the card
|
|
* option is an escape hatch for a phone that isn't working, not a choice
|
|
* offered alongside a working SMS flow. Successful sends count too, so this
|
|
* trades the phone signal for a card signal; it does NOT guarantee SMS
|
|
* actually failed. Once open, the fallback stays open for 24 hours so the
|
|
* user can finish the card flow. Requires a payments extension to run the
|
|
* actual card check.
|
|
*/
|
|
phone_verification_card_fallback: {
|
|
/**
|
|
* Tri-state. Set it and that wins, either way — this is the opt-out.
|
|
* Omit it and the fallback is on wherever both gates it bridges
|
|
* actually work: an SMS provider is configured _and_ an installed
|
|
* extension reports card verification enabled. On a build with no card
|
|
* gate behind it the fallback stays off, since taking the offer there
|
|
* could only strand the user.
|
|
*/
|
|
enabled?: boolean;
|
|
/**
|
|
* SMS send attempts (within the send rate-limit window) before the card
|
|
* fallback opens. Defaults to the send route's full rate limit
|
|
* (10/hour), i.e. the fallback appears only once the user is out of SMS
|
|
* attempts. Values above that limit are clamped down to it — requests
|
|
* past the route limit never reach the attempt counter, so a higher
|
|
* threshold could never be crossed. Lower it (e.g. 2) to reach the card
|
|
* path without burning the whole allowance, which is mainly useful for
|
|
* QA.
|
|
*/
|
|
after_attempts?: number;
|
|
};
|
|
/** Captcha configuration. */
|
|
captcha: { enabled: boolean; difficulty?: 'easy' | 'medium' | 'hard' };
|
|
/** OIDC / OAuth2 providers (google + custom). */
|
|
oidc: IOIDCConfig;
|
|
|
|
// -- Groups / provisioning ---------------------------------------
|
|
|
|
/**
|
|
* UID of the persistent group that non-temp users are enrolled in at
|
|
* signup.
|
|
*/
|
|
default_user_group: string;
|
|
/**
|
|
* UID of the persistent group that temporary users are enrolled in at
|
|
* signup.
|
|
*/
|
|
default_temp_group: string;
|
|
/** When true, ACL grants read/list/see on `/<user>/Public` to any actor. */
|
|
enable_public_folders: boolean;
|
|
|
|
/**
|
|
* Whether a recipient who already has an account is emailed about a share
|
|
* as well as notified in-app. **On unless set to false**; recipients
|
|
* decline with the unsubscribe link the mail carries, or by blocking a
|
|
* sender. An invite to an address with no account is emailed regardless.
|
|
*/
|
|
share_email_notifications?: boolean;
|
|
|
|
/**
|
|
* Ceiling on how many shares one user may create per UTC day. An abuse
|
|
* bound, not an accounting one — it exists so a script can't blanket other
|
|
* accounts with unwanted items and the notifications that follow. Omit to
|
|
* use the built-in default.
|
|
*/
|
|
share_daily_limit?: number;
|
|
|
|
/**
|
|
* How often a share may interrupt its recipient — the notification pushed
|
|
* to their screen and the email that goes with it. The share itself is
|
|
* never refused for being over budget; only the announcement is dropped.
|
|
*
|
|
* Both axes are needed: the pair bounds hold one sharer back, and the
|
|
* recipient bounds are what stop many senders from burying one person
|
|
* between them. Omit any field for the built-in default; a non-positive
|
|
* value removes that bound.
|
|
*/
|
|
share_notify_limits?: {
|
|
/**
|
|
* Quiet period after one sharer reaches a recipient, in seconds. Also
|
|
* how long their notification keeps absorbing new shares.
|
|
*/
|
|
pairWindowSeconds?: number;
|
|
/** Interruptions one sharer may cause a recipient per day. */
|
|
pairDaily?: number;
|
|
/** Interruptions a recipient may receive per hour, from anyone. */
|
|
recipientHourly?: number;
|
|
/** Same, per day. */
|
|
recipientDaily?: number;
|
|
/**
|
|
* How long emails to one recipient are held and merged into a single
|
|
* digest, in seconds. Default 90; non-positive sends immediately.
|
|
*/
|
|
emailBatchSeconds?: number;
|
|
};
|
|
|
|
/**
|
|
* Ceiling on recipients, and on items, in a single share request. Bounds
|
|
* the fan-out one call can trigger; the daily limit bounds the total.
|
|
*/
|
|
share_max_recipients?: number;
|
|
share_max_items?: number;
|
|
|
|
/**
|
|
* How long a notification is kept, in days from creation — the mailbox is
|
|
* not an archive, so acknowledged or not, a row past this goes. Omit it, or
|
|
* set 0, and nothing is ever swept.
|
|
*/
|
|
notificationRetentionDays?: number;
|
|
|
|
// -- Storage / S3 ------------------------------------------------
|
|
|
|
/** S3 storage config (local fauxqs or remote). */
|
|
s3: IS3Config;
|
|
/** Default S3 bucket for file storage. */
|
|
s3_bucket: string;
|
|
/** Default S3 region. */
|
|
s3_region: string;
|
|
/** Fallback AWS region. */
|
|
region: string;
|
|
/**
|
|
* Where each node stores files, keyed by node id - the same ids `user.home`
|
|
* names. A write may pass one to place its bytes there; absent, or absent
|
|
* an entry, every write uses `s3_bucket` / `s3_region`.
|
|
*/
|
|
servers?: Record<string, { bucket: string; bucketRegion: string }>;
|
|
/** Default storage capacity per user (bytes). */
|
|
storage_capacity: number;
|
|
/** When false, storage is effectively unlimited (bounded by device space). */
|
|
is_storage_limited: boolean;
|
|
/** Bytes of device storage available (used when is_storage_limited=false). */
|
|
available_device_storage: number;
|
|
/** Optional dedicated S3-compatible bucket used by the thumbnails extension. */
|
|
thumbnailStore: IThumbnailStoreConfig;
|
|
|
|
// -- Database ----------------------------------------------------
|
|
|
|
database: IDatabaseConfig;
|
|
|
|
// -- Clients / infra ---------------------------------------------
|
|
|
|
dynamo: IDynamoConfig;
|
|
redis: IRedisConfig;
|
|
/** Read cache in front of KV point reads. Off unless `enabled` is set. */
|
|
kvCache: IKvCacheConfig;
|
|
pager: IPagerConfig;
|
|
email: IEmailConfig;
|
|
/** Optional — only set when SMS phone verification (Prelude) is wired in. */
|
|
prelude: IPreludeConfig;
|
|
/** Optional — only set when a ClickHouse analytics client is wired in. */
|
|
clickhouse?: IClickhouseConfig;
|
|
cf_file_cache: ICfFileCacheConfig;
|
|
|
|
// -- Rate limiting -----------------------------------------------
|
|
|
|
rate_limit: {
|
|
/**
|
|
* Rate limiter backend selection.
|
|
*
|
|
* - `memory`: per-node in-memory counters.
|
|
* - `redis`: sorted-sets in Redis — shared state across nodes (default).
|
|
* - `kv`: per-hit rows in the system KV store (DynamoDB), with TTL.
|
|
*/
|
|
backend?: 'memory' | 'redis' | 'kv';
|
|
};
|
|
|
|
// -- AI / integration providers ----------------------------------
|
|
//
|
|
// All AI providers — chat, image, video, TTS, OCR, speech-to-text,
|
|
// speech-to-speech — are configured under `providers[<provider-id>]`.
|
|
// Provider ids match the driver-side identifier (e.g. `claude`,
|
|
// `openai-image-generation`, `aws-textract`). There is no `services`
|
|
// bag and no top-level `openai`/`gemini`/`mistral`/`elevenlabs`/`aws`
|
|
// shortcut.
|
|
providers: Record<string, IAIProviderConfig | undefined>;
|
|
|
|
// -- Cross-node / external integrations --------------------------
|
|
|
|
/** Cross-node event replication config. */
|
|
broadcast: IBroadcastConfig;
|
|
/** WebRTC signalling + TURN. */
|
|
peers: IPeersConfig;
|
|
/** WISP relay proxy. */
|
|
wisp: IWispConfig;
|
|
/** Cloudflare Workers driver config. */
|
|
workers: IWorkersConfig;
|
|
/** Optional CORS-stripping signed-Worker proxy used by `secureFetch`. */
|
|
secureCorsProxy: ISecureCorsProxyConfig;
|
|
/** Legacy Stripe billing extension. */
|
|
|
|
// -- GUI / static mounts -----------------------------------------
|
|
|
|
/** Absolute path to the GUI assets root. */
|
|
gui_assets_root: string;
|
|
/** Which profile in `puter-gui.json` to load. Default: `development`. */
|
|
gui_profile: string;
|
|
/** Map of built-in app name → local directory served at `/builtin/<name>`. */
|
|
builtin_apps: Record<string, string>;
|
|
/** Force the bundled GUI even in dev. Default: false. */
|
|
use_bundled_gui: boolean;
|
|
/** Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. */
|
|
gui_bundle: string;
|
|
/** Override the GUI CSS path when bundled. Default: `/dist/bundle.min.css`. */
|
|
gui_css: string;
|
|
/**
|
|
* Override the puter.js preload URL when bundled. Default:
|
|
* `https://js.puter.com/v2/`.
|
|
*/
|
|
gui_puterjs_bundle: string;
|
|
/**
|
|
* Free-form bag of values passed through to the client-side `gui()`
|
|
* function.
|
|
*/
|
|
gui_params: IGuiParams;
|
|
/**
|
|
* Absolute path to the directory holding native app bundles, each in a
|
|
* subdirectory matching its subdomain (e.g. `<root>/editor/`).
|
|
*/
|
|
native_apps_root: string;
|
|
/**
|
|
* Absolute path to a directory holding `puter.js`/`putility.js` version
|
|
* bundles.
|
|
*/
|
|
client_libs_root: string;
|
|
/** Path to the puter-js SDK root (serves `/sdk/*` and `/puter.js/v{1,2}`). */
|
|
puterjs_root: string;
|
|
|
|
// -- Extension-specific ------------------------------------------
|
|
|
|
/**
|
|
* Flat `{ flag_name: boolean }` bag of feature toggles. Non-boolean values
|
|
* are coerced before use.
|
|
*
|
|
* Server-only by default. Flags are surfaced to clients via `/whoami` only
|
|
* if their key is on the allowlist in `extensions/whoami.ts`
|
|
* (`CLIENT_VISIBLE_FEATURE_FLAGS`). New flags should be assumed internal —
|
|
* add them to the allowlist explicitly if (and only if) the client needs to
|
|
* read them.
|
|
*/
|
|
feature_flags: Record<string, boolean | string | number>;
|
|
/** Blocked email TLDs / domains — checked in `prod` only. */
|
|
blockedEmailDomains: string[];
|
|
/** Contact-form recipient. Default `support@puter.com`. */
|
|
support_email: string;
|
|
/** Worker / subdomain names that cannot be allocated by users. */
|
|
reserved_words: string[];
|
|
/** Max subdomains a single user may own. Default 10. */
|
|
max_subdomains_per_user: number;
|
|
/** Health-check tuning. */
|
|
server_health: IServerHealthConfig;
|
|
|
|
//Metering
|
|
unlimitedMetering?: boolean;
|
|
/**
|
|
* Fleet-wide spend rate, in micro-cents per minute, past which the metering
|
|
* service raises an alarm. There is no defensible default here — the right
|
|
* number is a multiple of what this deployment's own traffic normally
|
|
* costs, so set it from observed rate and revisit it as traffic grows. An
|
|
* unset or non-positive value turns the check off rather than guessing.
|
|
*/
|
|
maxGlobalUsagePerMinute?: number;
|
|
/**
|
|
* How long per-request usage (response bytes, object-store requests) is
|
|
* held in memory before being written, in milliseconds. This is the dial
|
|
* between how promptly that usage lands and how many writes it costs: every
|
|
* actor active in a window settles once per window, so halving it doubles
|
|
* the write rate. Unset uses the service default; a non-positive value is
|
|
* ignored.
|
|
*/
|
|
meteringUsageBufferFlushMs?: number;
|
|
/**
|
|
* Whether recorded usage is also enforced: an account with nothing left of
|
|
* its budget is turned away from the operations that spend it (file
|
|
* transfers, KV calls) with a 402. Metadata reads and deletions stay open,
|
|
* as does serving a hosted site — those bytes are billed to the account
|
|
* hosting it but requested by visitors who have no say in its balance.
|
|
*
|
|
* - `enabled` — defaults to on, and covers the plan gates below as well. The
|
|
* switch to reach for if enforcement is turning away traffic it
|
|
* shouldn't; usage is still recorded either way.
|
|
* - `workers` — extend enforcement to worker-driven calls. Off by default: a
|
|
* worker has no prompt to show and nobody watching, so being cut off
|
|
* presents as a program that started failing.
|
|
* - `subscriptions` — whether surfaces that declare a plan requirement
|
|
* (`requireSubscription`) enforce it. Defaults to on, and off wherever no
|
|
* paid plan exists to be on (self-hosted installs ship it off). Turning
|
|
* it off opens those surfaces to every account without unpicking the
|
|
* declarations; `enabled: false` turns it off along with everything
|
|
* else.
|
|
*/
|
|
meteringEnforcement?: {
|
|
enabled?: boolean;
|
|
workers?: boolean;
|
|
subscriptions?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Reputation gating: what the tiers named by `requireReputation` on a route
|
|
* or a driver method actually take.
|
|
*
|
|
* - `enabled` — the one switch that stops every declared gate enforcing,
|
|
* without unpicking the declarations. Defaults to on, which by itself
|
|
* gates nothing: a tier only bites once `tiers` gives it a score.
|
|
* - `tiers` — tier name → the minimum score (0-100) an account needs to pass
|
|
* it. The numbers live here rather than in the declarations because what
|
|
* counts as trusted enough is a per-deployment call, retuned far more
|
|
* often than the surfaces it protects. A tier with no entry is inert: an
|
|
* install that doesn't score its accounts must not turn traffic away on a
|
|
* score it never computed.
|
|
*/
|
|
reputationGate?: {
|
|
enabled?: boolean;
|
|
tiers?: Record<string, number>;
|
|
};
|
|
|
|
/**
|
|
* Subscribable events over filesystem and key-value changes.
|
|
*
|
|
* - `enabled` — the master switch for the whole surface. Absent means off:
|
|
* the dispatch hooks short-circuit on a boolean before resolving
|
|
* anything, and the subscribe verbs reject with `events_disabled`. An
|
|
* install that has never heard of events pays nothing on its write path.
|
|
* - `crossAppKv` — whether a subscription may name another app's KV
|
|
* namespace. Absent means off, and subscribing to one rejects with
|
|
* `events_cross_app_disabled`: a standing cross-app push leans entirely
|
|
* on revocation settling, so it turns on only once that has run on the
|
|
* simpler surface. The gate itself is enforced either way.
|
|
* - `notificationsFoldIn` — whether notification delivery runs through events
|
|
* dispatch. Absent means off, and `enabled` gates it: the fold-in has
|
|
* nowhere to dispatch from with the surface itself switched off. The
|
|
* socket wire is identical either way — the flag decides which layer
|
|
* produced the delivery, not what the desktop receives.
|
|
* - `kvHandles` — whether one user may hand another a watchable region of
|
|
* their key-value namespace. Absent means off: minting and subscribing
|
|
* through a handle both reject with `events_kv_handles_disabled`, and a
|
|
* handle row already made stops delivering. Nothing on the write path
|
|
* reads it.
|
|
* - `workerRuntime` — whether publishing handlers deploys a per-app events
|
|
* worker and delivery invokes it. Absent means off: publish stores rows
|
|
* and nothing is deployed, and the invoker keeps its null resolver, so
|
|
* worker-target deliveries stay retriable until something answers.
|
|
*/
|
|
events?: {
|
|
enabled?: boolean;
|
|
crossAppKv?: boolean;
|
|
notificationsFoldIn?: boolean;
|
|
kvHandles?: boolean;
|
|
workerRuntime?: boolean;
|
|
/** How long a handler has to answer an invocation. Default 30 s. */
|
|
invokeTimeoutMs?: number;
|
|
/**
|
|
* Dispatch namespace events workers are deployed into. Must not be the
|
|
* one the public worker dispatcher serves — that namespace is reachable
|
|
* at `<script>.puter.work`, and an events worker must not be. No
|
|
* default: deploys refuse rather than land somewhere public.
|
|
*/
|
|
workerNamespace?: string;
|
|
/**
|
|
* Where the events dispatcher answers `POST /invoke` — its own
|
|
* hostname, since it carries no zone route. Unset means invocations
|
|
* have nowhere to go and stay retriable.
|
|
*/
|
|
dispatcherUrl?: string;
|
|
/**
|
|
* Shared secret between this backend and the events dispatcher, in both
|
|
* directions, and the master the per-script invoke keys derive from.
|
|
* Rotating it rotates every key at the next deploy.
|
|
*/
|
|
internalSecret?: string;
|
|
};
|
|
|
|
/**
|
|
* Display multiplier converting metered amounts into the "credits" clients
|
|
* show. Applied server-side by the usage-reporting endpoints, so raw
|
|
* metered amounts never leave the API; purely presentational, so it can
|
|
* change without a data migration. No default — when unset, the endpoints
|
|
* report raw amounts and clients render dollars.
|
|
*/
|
|
creditMultiplier?: number;
|
|
}
|
|
|
|
/**
|
|
* Extension-augmentable config surface. Extensions add their own config keys
|
|
* via TypeScript declaration merging:
|
|
*
|
|
* declare module '@heyputer/backend/types' {
|
|
* interface IExtensionConfig {
|
|
* myExtension?: { foo: string };
|
|
* }
|
|
* }
|
|
*
|
|
* Augmentations flow into `IConfig`, which is what `this.config` /
|
|
* `extension.config` are typed as everywhere.
|
|
*/
|
|
export interface IExtensionConfig {
|
|
/**
|
|
* Open index signature so config reads of extension-only keys return
|
|
* `unknown` (not a type error). Extensions that declare-merge concrete keys
|
|
* (`myExt?: { foo: string }`) override this for the named key — the
|
|
* concrete property type wins over the index signature.
|
|
*/
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export type IConfig = Partial<IConfigOptional> & {
|
|
extensions: string[];
|
|
port: number;
|
|
} & IExtensionConfig;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types
|
|
export interface WithLifecycle extends Object {
|
|
onServerStart?: () => Promise<void> | void;
|
|
onServerShutdown?: () => Promise<void> | void;
|
|
onServerPrepareShutdown?: () => Promise<void> | void;
|
|
}
|
|
|
|
export interface WithCostsReporting extends WithLifecycle {
|
|
getReportedCosts?: () =>
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
| Promise<Record<string, any>[]>
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
| Record<string, any>[];
|
|
}
|
|
|
|
export interface WithControllerRegistration extends WithCostsReporting {
|
|
registerRoutes: (router: PuterRouter) => void;
|
|
}
|
|
|
|
export type LayerInstances<
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
T extends Record<string, (new (...args: any[]) => any) | any>,
|
|
> = {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
[K in keyof T]: T[K] extends new (...args: any[]) => any
|
|
? InstanceType<T[K]>
|
|
: T[K];
|
|
};
|