Lolly
啟動應用程式

Configuration reference

備註: Lolly Work

This guide covers the optional organisation service. Configuration, console and API paths refer to your Work instance. The packaged console keeps its release-matched copy of this guide.

One JSON file plus environment secrets. The standalone server reads ./instance.json, or whatever path LW_CONFIG names. (LW_CONFIG_JSON is a different thing and not interchangeable: it carries the whole config as a string and is read only by the Vercel entrypoint.) Unset keys take the defaults below - instance.example.json is a working starting point, and server/src/config/instance.ts is the authority.

Feature-flag governance - default state and toggle visibility, per usersigned by Lolly Demovector SVG自己驗證取得已簽署檔案11 paths160 nodes83 groups10 images195 KBFeature-flag governance - default state and toggle visibility, per usersigned by Lolly Demovector SVG自己驗證取得已簽署檔案11 paths160 nodes83 groups10 images195 KB

Secrets are never in the config file. Config is safe to keep in git; secrets come from the environment.

deployment

KeyDefaultWhat it does
modeautoproduction enforces production checks regardless of NODE_ENV. evaluation deliberately permits memory storage and ephemeral secrets, including inside a production container. auto inherits production from NODE_ENV for existing installations.
applicationapiapi serves the console and API without requiring an employee shell. web requires a governed shellDir or external appUrl. A configured shellDir is checked in either profile.
requireServerRenderingfalseRequire every advertised tool to have at least one configured server format. Hooked tools need a worker URL and secret. This checks configuration, not a successful worker render.

Production refuses memory storage, development login, open access, missing identity, an unsafe public base URL, signing secrets shorter than 32 bytes, inconsistent worker or signer configuration, and an incompatible active pack. Helm defaults to production; the evaluation overlay and example JSON explicitly select evaluation. An old shell cannot bypass production validation with LW_ALLOW_STALE_SHELL.

Run pnpm run check:setup with the deployment's config and environment to read the same local checks as startup. It exits 1 for failed production checks and 0 otherwise; a zero exit does not turn not-tested integrations into acceptance evidence. It neither connects to the database nor calls identity, renderer or asset providers. The owner console's Customer setup page additionally checks the running store and pending migrations.

instance

KeyDefaultWhat it does
nameLolly Workthe deploy's display name, also called the workspace name: console rail, sign-in card, /healthz, instanceName in /api/auth/config, and the invite, refusal and request pages, notices and copied invite messages ("Andy invited you to lolly.ing"). Put the name people know first: lolly.ing uses lolly.ing
baseUrlhttp://localhost:8787the URL this deploy answers on. Drives OIDC redirect URIs and the Secure cookie flag - must match reality
pack./packs/demothe brand pack mount: catalog, tools, design tokens, fonts, logos. The default is the small demo pack committed at packs/demo; the server warns at boot if the path does not exist, and the catalog is empty until it does
catalogPublicationGeneration0operator-provisioned, nonnegative safe integer identifying an immutable mounted release. Production requires a positive generation, supplied here or by a verified signed source envelope. Advance it when changing an unsigned pack or its engine pin; never decrease it or reuse it for different bytes.
shellDirunsetpath to a built Lolly shells/web/dist. Set ⇒ the shell is served at / on one origin (session cookies work, the shell's org/ governance seam activates)
appUrlunsetwhere the Lolly app lives when it is not same-origin (a Vite dev server, a split deploy). The console routes "Open Lolly" and deep links through it
modelOriginhttps://lolli.liwhere the served shell's on-device models come from when the shell dist does not carry them. The public shell build does not: its 1.2 GB of model weights live on the project's release host. A request for /models/... that shellDir cannot answer is passed to this exact origin, so the browser stays on one origin and an interrupted download resumes (Range, If-Range and the validators go through). null keeps every model request on this server, for a dist built with its models or a deployment that offers none. HTTPS only, without a path (a loopback http:// origin is accepted for local testing)
connectorOriginsunsetexact HTTPS origins (no paths, wildcards or credentials; at most 32) that the served shell's custom connectors may reach. Added to the shell's connect-src only; every other directive and header is unchanged. Use it where the ingress cannot replace response headers, for example a shared cluster with snippet annotations disabled. The shell build's VITE_CONNECTOR_ORIGINS must list the same origins, and each provider's CORS must allow the workspace origin
brandTokensunsetmap source IDs (mounted or profile:<name>) to explicit tokens asset IDs; required when a source has multiple independent heads
connectPackunseta .lolly instance pack to HOST from boot - a path relative to pack (or absolute). Seeded only before any durable branding decision, while no download is suppressed or hosted, so an ephemeral deploy offers /connect/pack.lolly without an owner ever uploading; an owner's own upload always wins, and a file naming a different instance base is refused loudly at seed time
inviteNoteunsetone plain-text line shown on the invite page and at the end of copied invite messages, such as which sign-in to use when an organisation blocks one. Trimmed, at most 240 characters, no line breaks; an empty line is the same as unset. See invite links
homeViewunsetthe view a signed-in member's Lolly opens on when they arrive at the bare address: tools (the tools gallery) or projects (their Projects). Unset means tools. A link to a tool, a team project or a view still opens where it points, and choosing Tools later still shows the tools. Sent to members' shells in GET /api/v1/org-config as home; a shell that predates it ignores it
homeUrlunsetoptional destination for the Home button and arrival at the bare address, overriding homeView. A root-relative URL such as /#/p?team=project-id, or an HTTPS URL for an organisation's own landing page. Credentials, protocol-relative URLs and executable schemes are refused. Explicit document and view links keep their destinations

pack supports materialized trees, modern profiles.json roots and legacy brand layouts. Selection lives in the Store; mounted files are never rewritten. See Design-system administration for persistence requirements and explicit tokens selection.

The publication fence records both the generation and the mounted release digest. Startup verifies this identity before /readyz succeeds; /healthz remains a liveness check. The engine pin is normalized as canonical JSON in both source and bundled servers. When upgrading a server that recorded the earlier raw-file pin identity, provision a higher positive catalogPublicationGeneration for the reviewed release once. Keep the existing fence and sequence history; deleting them would remove rollback protection.

Under a non-open access mode, a shellDir that is missing or predates the org/ governance module stops boot. In evaluation, LW_ALLOW_STALE_SHELL=1 downgrades the legacy shell guard to a warning; production validation still refuses that legacy shell.

For generated deployment files and a staged owner cutover, use Customer setup.

idp

KeyDefaultWhat it does
issuer""Exact OIDC issuer URL; see the supported profile in the enterprise identity guide
clientId""the client this deploy authenticates as
displayName""human name on the sign-in button ("Keycloak", "SUSE ID", "ZITADEL"). Empty ⇒ "SSO"
groupsClaimgroupsthe claim carrying group membership
claimMapgiven_name / family_name / email / titlewhich claims fill firstname, lastname, email, title. email_verified vouches only for the email claim: a remapped email counts as verified only when it equals email, otherwise only under emailVerification: trusted
roleGroups{}exact groups for owner/admin/approver/author/member/viewer. Highest match wins; unmatched accounts are members. Omitted high roles retain literal legacy names; omitted member/viewer have no mapping. An explicit empty array disables that role mapping. Each group can appear once. Applies to IdP and local groups, including existing accounts after restart.
additional[]further IdPs beside the primary - each { id, kind?, issuer, clientId, displayName, groupsClaim?, claimMap?, clientSecretRef? }. kind is oidc (default), github or password. A GitHub entry takes no issuer, needs clientSecretRef, refuses scopes, authParams, hostedDomain and tenantId, and accepts only emailVerification: "claim" (see the GitHub recipe). A password entry is described below. Unset claims inherit the primary's; the secret rides the env var clientSecretRef names; subs store namespaced <id>:<sub>. With several houses, plain /api/auth/login serves a chooser. Each entry may also set the per-IdP keys below (hostedDomain, tenantId, emailVerification, scopes, authParams); those are never inherited from the primary. See identity
admissionabsentwho may sign in, checked on every OIDC, GitHub, password and proxy sign-in before a user row is written: { emails?: string[], domains?: string[], invitations?: boolean (default true) }. Absent = every verified sign-in is admitted (production setup warns). {} admits invitations only. Admission by email, domain or invitation needs a verified email (see emailVerification). See who may sign in
linkedStandingDays30whole days, 1-365. How long one sign-in's IdP groups, and the account's own sign-in's standing under admission, carry over to the person's other linked sign-ins. See one person, several sign-ins
bootstrapOwners[]emails that get the owner group (first roleGroups.owner name, else owner) on first account creation once admitted with a verified email. The group remains in local membership until an owner removes the group. Audited as auth.bootstrap-owner. Each must be admitted by admission.emails or admission.domains; refused when roleGroups.owner is []
hostedDomainabsentper IdP. Google Workspace domain: the hd claim must equal it (a mismatch or missing claim refuses), and it is sent as the hd authorization parameter
tenantIdabsentper IdP. Microsoft Entra directory id (GUID): the tid claim must equal it
emailVerificationclaimper IdP. claim: email-based admission needs email_verified: true. trusted: every email this IdP sends counts as verified (tenant-pinned IdPs that omit the claim)
linkByEmailprimary: true under claim; additional: falseper IdP. Whether a sign-in from this IdP may join an existing person whose email matches. Additional providers require an explicit choice. Owner and admin accounts never join by email. The primary default requires a verified address; set true to link from a trusted IdP (refused unless that IdP has a hostedDomain or tenantId pin), or false to never link from this one
scopes["openid", "profile", "email"]per IdP. Requested scopes, a list or a space-separated string; must include openid
authParams{}per IdP. Extra authorization request parameters, allowlisted: prompt, hd, domain_hint, login_hint, acr_values. Any other key is refused; hd must equal hostedDomain when both are set

The following settings apply to each OIDC provider independently and are not inherited by additional entries. GitHub and password entries refuse these keys. See Enterprise identity deployment for registration, claim semantics, acceptance checks and lifecycle procedures.

KeyDefaultWhat it does
tokenEndpointAuthMethodclient_secret_post with a secret, otherwise noneclient_secret_basic, client_secret_post or none; must match provider registration and advertised metadata
signingAlgorithmRS256Exactly one of RS256, ES256, PS256; other algorithms are refused
trustedAudiencesabsentExplicit additional audience strings; the Work client must still be present and azp, when present, must match it
assurance.acrValuesabsentNonempty string list; returned acr must equal one entry
assurance.amrValuesabsentNonempty string list; returned amr must include every entry
assurance.maxAgeSecondsabsentInteger 0-86400; requires auth_time within this age, with sixty seconds of tolerance

Gated access needs idp.issuer, proxyAuth.enabled or a password entry - or dev.enabled for local work. The server refuses to start otherwise. See identity.

Email and password (kind: "password")

For people who cannot use the instance's other sign-ins, for example because their own organisation blocks unreviewed OAuth apps. Add one entry to idp.additional:

{ "id": "email", "kind": "password", "label": "Email and password" }
KeyDefaultWhat it does
id-the slug in /api/auth/login?idp=<id>. password is reserved for this kind
kind-password
labelEmail and passwordthe chooser button and the sign-in list in a profile; displayName is accepted instead
linkByEmailtruewhether a password sign-in joins the existing person who holds the same verified email. The other way round never happens: a password sign-in is stored unverified (an admin typed the address, no mailbox proved it), so no later sign-in through another provider joins an account by it

See email and password for the flow.

policy

KeyDefaultWhat it does
defaultAccessModegatedopen (anonymous catalog), gated (sign-in required), per-tool
ai.enabledfalseapproval ceiling for managed AI; the audited ai flag must also be explicitly On. See managed AI.
ai.capabilities[]explicit capability list; required and nonempty when enabling AI. Unknown or duplicate capabilities are rejected.
telemetrystandardoff, aggregate, standard
telemetryAttributionopt-inopt-in strips the user id until the user consents; default attributes at standard
guestLinks.enabledtruewhether guest-edit links may be minted at all
guestLinks.maxTtlHours168hard cap on any guest link's lifetime
guestLinks.defaultTtlHours72the default offered when minting
invites.allowadminswho may invite new people by email, from inside Lolly or from the console and lw invite add: owners (instance owners), admins (holders of user.invite: admins and owners by default) or members (any member not denied user.invite). Inviting through a project also needs manager on that project
invites.domains[]when not empty, a new address must be at one of these domains (a leading @ is dropped, matching is case-insensitive)
invites.maxTtlHours720how long an invitation made from a project stays open; at most 8784 (366 days)
invites.projectRolesall fourwhich project roles may be given by invitation, role change or group grant: any of viewer, commenter, editor, manager. A commenter reads and comments but never changes artwork
invites.passwordDomains[]domains whose people usually sign in with email and password, the same rule as domains. When every address on a console or project invite is at one of them, "Can set a password" starts ticked, so the invite link also sets the password. It only suggests: the tick stays an admin's choice. See setting a password from the link
requests.projecttruemembers may ask for access to a project or session link they cannot open, and viewers may ask to edit. See asking for access
requests.joinfalsea person who signed in but is not admitted may ask the admins to let them in, from the refusal page. Off by default, because it lets anyone who can sign in somewhere reach the admins. See asking to join
requests.ttlDays14whole days, 1 to 60. How long a request stays open before it expires, with its notice
requests.joinOpenMax50whole number, 1 to 1000. The most requests to join and to use another account for an invitation that may be open at once across the instance; past it, new ones are held (the person sees the same page, nothing is stored)
nearby.enabledtrueinstance-mediated "nearby" presence: the collab.nearby capability bit and both /api/v1/collab/nearby routes. false keeps the whole surface dark fleet-wide
comments.enabledtruedurable canvas review; false disables reading and writing comment threads without changing session access
comments.mentionstrue@ mentions in comments and the comment-people list behind them; false stores no mentions and refuses that list. See canvas comments
comments.noticestrueinbox notices for mentions and replies; false writes none and hides the ones already written (they are still removed at 30 days)
comments.emailTitlesfalsename the document in mention email. Off, a private document's title is not sent to a mail provider. Mention email itself is a seam that is off in this release
sharing.instance.enabledtruewhether a project may be shared with everyone signed in to this instance (never guests or service tokens). false also stops shares made before the change
sharing.instance.maxRolecommenterthe highest role that audience may get: viewer, commenter or editor. Lowering it lowers existing shares too
sharing.customGroupstruewhether members may make their own groups (group.create) and share projects with them. These groups never count as directory groups for grants
sharing.maxGrantDaysunsetthe longest end date a membership or group grant may carry, in whole days (1 to 3660). Unset means no limit
versions.maxBytes1073741824bytes of saved-version content all documents together may hold (1 GiB); one document may hold at most 100 MiB. Over it the oldest automatic versions are removed first, then a named save or a restore answers 409 VERSION_SPACE. See data lifecycle
sessionTtlHours12member session lifetime (token exp and cookie Max-Age); must be > 0 and ≤ 720
sessionMaxHoursunsetthe longest a session chain may run after the sign-in that started it, with sliding renewal on. Unset equals sessionTtlHours, which turns renewal off. When set it must be ≥ sessionTtlHours and ≤ 720. A session past half its TTL gets a fresh cookie on the next API write or poll of the session, org-config or inbox, never past authAt + sessionMaxHours. A device approved at /activate, and the browser kept signed in after removing a sign-in, continue the approving chain under the same cap; with renewal off that cap is sessionTtlHours from the sign-in, so a device approved late in a browser session gets only that session's remaining time. See identity
submit.maxBytes67108864per-file cap on a catalog submission (64 MiB, matching publish-out). Over it: 413 PAYLOAD_TOO_LARGE
submit.chainunsetapproval chain id gating submissions. Unset means no review: a submitted asset is live the moment it is stored. Set to a chain that does not exist, submissions are refused (503 SUBMIT_CHAIN_MISSING) rather than published unreviewed
submit.quota.bytes0cumulative byte ceiling per group; 0 is unlimited
submit.quota.count0cumulative submission-count ceiling per group; 0 is unlimited
catalog.versionKeep0how many versions of one instance asset to keep, head included. 0 keeps every version; a positive number trims oldest-first and deletes the trimmed bytes. The served version is never trimmed, and a held asset is never trimmed at all
fleet.minEngineunsetadvisory engine version floor (dotted, e.g. "1.140.0"): below-floor engines are highlighted in the Fleet view with an upgrade nudge; nothing is blocked or force-upgraded
activity.modedelegatedoff disables the activity timeline and agent activity dashboard; owner-only restricts them to owners; delegated also admits principals holding audit.export. Audit reads of those feeds record the reader and filter names, without query values. Security audit endpoints retain their own permissions
activity.auditSessionUpdatestruerecord routine API document saves as session.update. Set false to omit those new events; other security events and shared document history remain
retention.telemetryDays0delete telemetry events older than this; 0 keeps everything
retention.auditDays0trim audit rows older than this. The chain stays verifiable (the boundary's seq + hash are anchored before anything is deleted, and the head row is never trimmed), and a trim never passes the SIEM delivery cursor - an unreachable receiver pauses audit retention rather than losing events. See operations
projectFiles.enabledtrueshared files inside team projects. They also need a durable store: on the memory store they are off whatever this says. See sharing
projectFiles.maxFileBytes26214400largest single file (25 MiB); at most 4294967296 (4 GiB, migration 0075). Over it: 413 PROJECT_FILE_TOO_LARGE
projectFiles.projectBudgetBytes134217728bytes of files one project may hold (128 MiB). Over it: 413 PROJECT_FILE_BUDGET
projectFiles.instanceBudgetBytes268435456bytes of files all projects together may hold (256 MiB). Over it: 413 INSTANCE_FILE_BUDGET
projectFiles.uploadTtlHours24the longest an unfinished upload stays open; at most 720. It expires sooner, 15 minutes after its begin or its last accepted part. An expired upload counts toward no budget; once it is an hour past expiry it is removed at the next upload or retention run
uploads.enabledfalsescoped uploads: private, resumable uploads a person or a service token names by reference as a catalog submission original, an automation input or a file reference. Off by default, and upgrading leaves them off; turning them on adds uploads.instanceBudgetBytes to what the blob store may hold (see below). See resumable uploads
uploads.maxFileBytes67108864largest single upload (64 MiB); at most 4294967296 (4 GiB). A catalog original is also capped at submit.maxBytes. Over it: 413 UPLOAD_TOO_LARGE
uploads.ownerBudgetBytes134217728bytes one person or service token may hold in uploads that have not expired (128 MiB). Over it: 413 UPLOAD_BUDGET
uploads.instanceBudgetBytes268435456bytes all owners together may hold (256 MiB). Over it: 413 INSTANCE_UPLOAD_BUDGET
uploads.uploadTtlHours24the longest an unfinished upload stays open; at most 720. It expires sooner, 15 minutes after its begin or its last accepted part
uploads.readyTtlHours24how long a finished upload can be named after it finished; at most 720. A catalog submission removes its upload sooner
uploads.fileTtlHours168how long a finished file reference can be read and named after it finished (a week); at most 720. Its owner can delete it sooner

policy.invites is optional; leaving it out keeps the defaults above. Unknown keys and values outside the lists are refused at startup. The tier and the domain list govern invitations, which let someone new sign in; sharing a project with someone who already has an account needs manager on the project and an allowed role only. The console does not edit this block (its configuration document covers grants, overlays, chains, providers, flags and catalog fields), so change it in the configuration and redeploy. Lolly reads the limits from org-config. See sharing.

"policy": {
  "invites": { "allow": "members", "domains": ["example.com"], "maxTtlHours": 168, "projectRoles": ["viewer", "editor"] }
}

policy.requests is optional too, and unknown keys in it are refused at startup. Requests answer to the same rules as inviting: an approver may only grant what they could grant directly. lolly.ing runs with join requests on, the invitation lifetime at its 30-day default, and the password tick suggested for SUSE addresses:

"instance": {
  "name": "lolly.ing",
  "inviteNote": "If your organisation blocks Google sign-in (for example @suse.com), use GitHub or email and password."
},
"policy": {
  "invites": { "passwordDomains": ["suse.com"] },
  "requests": { "join": true, "project": true, "ttlDays": 14, "joinOpenMax": 50 }
}

policy.projectFiles values are whole numbers above zero, with maxFileBytes no larger than projectBudgetBytes and projectBudgetBytes no larger than instanceBudgetBytes; anything else is refused at startup. Budgets count finished files and unfinished uploads that have not expired, each at its size plus 4096 bytes for its database rows, so many tiny files fill a budget too. One person's unfinished uploads may declare twice maxFileBytes in all, and one person may download twice instanceBudgetBytes a day (counted per server process). The defaults are sized for a small hosted Postgres that also holds the file bytes (the pg blob driver); Neon's free plan, for example, has 1 GB of storage in all and stops writes past that storage limit. Raise them only with the storage to match.

"policy": {
  "projectFiles": { "maxFileBytes": 10485760, "projectBudgetBytes": 67108864, "instanceBudgetBytes": 268435456 }
}

policy.uploads follows the same rules: whole numbers above zero, with maxFileBytes no larger than ownerBudgetBytes and ownerBudgetBytes no larger than instanceBudgetBytes. Budgets count every upload that has not expired, finished or not, at its size plus 4096 bytes, and a file reference also at the size of its derivedFrom and report as JSON. One owner may hold 16 unfinished uploads at once.

Scoped uploads are off until policy.uploads.enabled is true, and an upgrade does not turn them on. Their bytes live in the same blob store as project files, under a separate instance budget, so the two instance budgets add up: with both defaults the blob store may hold 512 MiB of uploads instead of 256 MiB. A begin needs tool.use or catalog.submit, and members hold tool.use by default, so most members and service tokens can use that room. Size both budgets against the storage before turning uploads on. To keep the total at 256 MiB, split it:

"policy": {
  "projectFiles": { "instanceBudgetBytes": 134217728 },
  "uploads": { "enabled": true, "ownerBudgetBytes": 67108864, "instanceBudgetBytes": 134217728 }
}

To take a 500 MiB recording into a project, for example, lift the project file cap and its budgets together:

"policy": {
  "projectFiles": { "maxFileBytes": 629145600, "projectBudgetBytes": 1073741824, "instanceBudgetBytes": 2147483648 }
}

Submit is open to authors by default: anyone holding catalog.submit submits and the asset goes live immediately. Name a submit.chain when the org wants review. Quota scopes are group names, and a submission is charged to every group its submitter belongs to, so extra memberships only tighten a member's budget. See catalog.

catalog.versionKeep keeps everything by default; set a ceiling only when you deliberately want to bound blob growth - see operations and catalog.

Shorter sessionTtlHours bounds token lifetime if a directory change has not yet reached Work. With sessionMaxHours set, a cookie in use is renewed up to that cap, but each renewal checks the account, its session epoch and admission again first, so a person who left the lists stops at the end of their current cookie. Once Work receives a group/role change, authorization uses the live record on each request. Account disable and a session-epoch bump revoke existing member tokens on their next authenticated request. Token expiry does not replace the offboarding integration.

render

KeyDefaultWhat it does
allowHooksInFastPathfalsewhether the in-process jsdom path may run a tool's hooks.js. Default refuses hooked tools with 501 HOOKED_TOOL_NEEDS_CHROMIUM. When on, a tool whose manifest requires identifies a host.* API the in-process host lacks is refused with 501 TOOL_REQUIRES_UNMET before any hook runs. Hooks run in a node:vm context with the DOM and host bridge but no process, require or fetch - contained, not isolated. Turn on only for a pack you curate end to end; the server warns at boot when it is on for any pack other than the bundled packs/demo
worker.url""the Chromium render worker. Set (with LW_RENDER_WORKER_SECRET) ⇒ hooked/HTML-heavy tools dispatch there instead of 501. This pair is the render-topology switch - the deployment's capability set is advertised to shells via org_config's render block either way (deployment)
worker.timeoutMs20000per-job timeout
c2pa.certFile""signing-cert chain PEM (leaf first), public
c2pa.claimGenerator""producer label in the signed manifest

Production worker and SIEM service URLs require HTTPS with verified server certificates. Requests cannot follow redirects or include URL credentials, query strings or fragments. HTTP is accepted only for explicit loopback evaluation fixtures. For an internal CA, mount its public PEM read-only and set NODE_EXTRA_CA_CERTS before starting Node. Keep hostname verification enabled; NODE_TLS_REJECT_UNAUTHORIZED=0 is refused in production. The worker request HMAC continues to authenticate the control plane. TLS certificates and trust mounts need their own deployment qualification.

policy.serviceTokens.allowLegacyUnbounded defaults to true for existing credentials without expiry. New service credentials always use a bounded expiry. Rotate the legacy integrations before deliberately setting the policy to false.

Every tool in the committed packs/demo ships hooks, so instance.example.json and values-eval.yaml both set allowHooksInFastPath: true - that pack is curated in this repo end to end. Repoint instance.pack at a pack you do not fully control and this goes back to false, with a Chromium worker for the hooked tools.

The Chromium worker always enables its browser sandbox. A bounded launch begins at startup, and /readyz stays unavailable until the browser connects. Failed startup or recovery stops the worker without an unsandboxed fallback. LW_RENDER_BROWSER_STARTUP_TIMEOUT_MS controls that budget (default 30000, integer from 1000 to 120000); Helm uses renderWorker.browserStartupTimeoutMs. Qualify the exact Linux image and target runtime with the existing non-root, seccomp and container permissions before rollout. A host that refuses the sandbox must remain out of service until its runtime is qualified. The separate evaluation allowHooksInFastPath setting runs curated demo hooks in node:vm and has no browser isolation; keep that setting false when preparing production packs.

The worker itself takes LW_RENDER_MAX_CONCURRENT (default 4, Helm renderWorker.maxConcurrent): its per-pod render/rasterise cap. At capacity it answers 503 RENDER_BUSY + Retry-After immediately - no internal queueing, so saturation stays visible to the HPA - and drops out of readiness (/readyz) until a slot frees. Its own timeouts are LW_RENDER_NAV_TIMEOUT_MS, LW_RENDER_EXPORT_TIMEOUT_MS and LW_RENDER_TS_SKEW_MS (HMAC clock skew between plane and worker) - see workers/render/README.md.

Worker HMAC key and C2PA private key are secrets - see below and c2pa.

audit

KeyDefaultWhat it does
headLog.onBoottrueprint the audit-chain head hash at boot
headLog.intervalMinutes60print it periodically (0 = off). The timer is unref'd, so it never holds the process open

The defaults print the head; operators must forward and retain it outside this deployment's control to establish an external anchor. Verify collection, retention and restricted deletion access in the receiving system - see audit.

dev

KeyDefaultWhat it does
enabledfalseenables /api/auth/dev?email=…, a passwordless local provider
users[]{ email, name?, groups? } entries the dev provider will admit

Keep this off in production. It bypasses OIDC entirely, and setting idp.issuer does not turn it off - the two coexist happily and the passwordless route stays live. The server warns at boot when it finds both.

proxyAuth

Reverse-proxy sign-in: an authenticating proxy in front of the instance (YunoHost's SSOwat, Authelia, oauth2-proxy) states who the person is in request headers. See identity for the contract and the security posture.

KeyDefaultWhat it does
enabledfalseenables GET /api/auth/proxy?returnTo=…. Satisfies the gated-access requirement on its own (no idp.issuer needed)
displayName""the sign-in button copy ("YunoHost"). Required when enabled
secretRefLW_PROXY_AUTH_SECRETenv var NAME holding the shared secret; the header x-lw-proxy-auth must equal its value or the sign-in is 403
headers.userynh_userheader carrying the stable login (required). Names are matched case-insensitively
headers.emailynh_user_emailheader carrying the mail address
headers.nameynh_user_fullnameheader carrying the display name; split into first and last name when the directory gives neither
headers.groups""header carrying a comma-separated group list; empty = the proxy sends none
groups{}static grants unioned in at sign-in: { "<login>": ["owner"] }
directorynulloptional LDAP read of the person's own entry, once per sign-in. Fail-closed: configured but unreachable means 502 and no session
directory.urlldap://127.0.0.1:389plain ldap:// over TCP; ldaps is refused
directory.bindDn""simple-bind DN; empty = anonymous
directory.bindPasswordRef""env var NAME holding the bind password
directory.userDnuid={user},ou=users,dc=yunohost,dc=orgDN template; {user} is the RFC 4514-escaped login
directory.attributesmail / givenName / sn / cnwhich attributes fill email, firstname, lastname, name when the headers left them blank (empty string skips one)
directory.groupMap[]{ attribute, pattern } rules: every value of attribute matching pattern contributes its first capture group as a group name
directory.timeoutMs5000the whole bind + search must finish inside this

rateLimit

KeyDefaultWhat it does
enabledtrueper-IP token buckets on the auth, telemetry and link surfaces
trustedProxyHops0how many reverse proxies to trust in X-Forwarded-For. 0 reads only the socket peer. Behind one ingress, set 1
maxBuckets50000bucket table cap
authcapacity 10, refillPerSec 0.2sign-in attempts, including each email and password guess and each use of a password link, and the invite page's and refusal page's forms (/api/auth/invite, /api/auth/request)
telemetrycapacity 120, refillPerSec 4event ingest
linkcapacity 30, refillPerSec 1signed-link resolution, including invite pages (/l/invite/)

blobs

Where instance-owned catalog bytes live (materialized copies, published assets).

KeyDefaultWhat it does
driverpgpg (bytes in Postgres, zero extra moving parts) or s3 (any S3-compatible store: AWS, MinIO, Ceph RGW). Any other value is a startup error
s3.bucket-required when driver is s3
s3.regionunsetAWS region, when the endpoint needs one
s3.endpointunsetfor a non-AWS S3-compatible store
s3.prefixunsetkey prefix inside the bucket

With no database at all, pg falls back to a memory blob store (evaluation only). The S3 credential is a secret, LW_BLOBS_S3_CREDENTIAL, formatted <accessKeyId>:<secretAccessKey>. This is the exit route for media-sized estates and the air-gap story: see off-boarding.

With pg, the server compares the bytes stored and read each month against the offload thresholds and alerts administrators when one is reached: see blob offload trigger.

Blob limits and integrity

DriverHow a blob is written and readLimits
pgOne bytea value per blob. Every write and read holds the whole blob in Work's memory, and a read briefly needs several times the blob's size because PostgreSQL returns bytea as hex text.At most 500 MiB per blob, because PostgreSQL could store a larger value but not return a blob of that size. Blobs share the database's storage, backups and a pool of four connections, so large files slow the database for everyone.
s3Streams in 8 MiB parts. A blob that fits in one part is a single PUT; a larger one is a multipart upload. A failed or refused upload is aborted, and the object already under that key stays until an upload completes. Reads stream from the store.10,000 parts per upload. When the caller gives the length in advance, parts grow past 8 MiB so the blob still fits. A part that fails with a server error or a dropped connection is retried twice.

Each S3 request signs the SHA-256 of its own body, so the store refuses a part that changed in transit. Work also computes the SHA-256 of the whole blob as the parts go out, and records it on the object as x-amz-meta-sha256 when it is known before the object is created: always for a single PUT or a blob already in memory, and for a streamed multipart upload whose caller declared the digest, which Work checks before completing the upload. Objects written by earlier versions have no recorded digest. For those, Work reports the S3 ETag, which is not a content digest, and reads the bytes back wherever it needs the SHA-256.

Every caller in this release hands the driver a file it already holds in memory, within that path's own byte cap: catalog submission, materialization, render and automation outputs, design-system pack downloads, staged deliveries and project file parts. The S3 driver sends such a file in parts without copying it again, but an upload streams from the client to the bucket only once those paths stream too.

For s3, give the blob credential s3:GetObject, s3:PutObject, s3:DeleteObject and s3:AbortMultipartUpload on the prefix, and s3:ListBucket on the bucket so that a missing blob reads as missing rather than refused. Where the store supports it, add a lifecycle rule (AbortIncompleteMultipartUpload) that removes incomplete multipart uploads after a day: a server that stops during an upload leaves its parts behind, and the store keeps them, and bills for them, until they are removed.

delivery

Fixed organization-owned destinations for intentional outbound delivery. This is separate from catalogProviders (assets coming in) and from a person's device-owned send targets. No destination exists by default, so an unconfigured instance sends nothing anywhere.

KeyDefaultWhat it does
maxBytes67108864instance-wide per-delivery cap (64 MiB)
destinations[]fixed, config-managed outbound targets
destinations[].id-stable lowercase/dash id; also the destination:<id> RBAC resource
destinations[].kind-s3, webdav, or https
destinations[].label-member-visible name
destinations[].credentialRef-environment variable containing the write credential; never sent to a shell
destinations[].enabledfalsekill switch; only explicitly enabled targets are visible or writable
destinations[].groups**/absent for all members, or an array of groups
destinations[].formats-required non-empty format allowlist
destinations[].maxBytesglobal capnarrower target-specific byte ceiling
destinations[].approvalChainunsetoptional existing approval-chain id; stages bytes and withholds provider egress until approved
destinations[].options-provider-specific fixed-target options below; never sent to a shell

Provider options and credential forms:

KindOptionsCredential in credentialRef
s3bucket (required), region, endpoint, prefix, publicBaseUrl<accessKeyId>:<secretAccessKey>
webdavurl (required existing writable collection), prefix (existing subdirectory), publicBaseUrl<username>:<password> or bearer:<token>
httpsurl (required exact HTTPS receiver; query allowed, redirects refused)non-empty HMAC secret

For S3, endpoint selects MinIO, Ceph, Garage, UpCloud or another compatible store; omitting it uses AWS. For S3 and WebDAV, publicBaseUrl means the resulting object has a public URL; omit it for private storage. WebDAV permits HTTP for an explicitly private/air-gap deployment, but credentials then depend entirely on that network boundary - use HTTPS anywhere else. The signed-HTTPS adapter always requires TLS.

Give every destination its own write-limited credential even when the same service is already configured as a read-only catalog provider or as blobs.driver. Restrict it to the configured bucket, collection or receiver. A destination credential is not a person's connected-service credential and must not be reused as one.

See outbound delivery for the lifecycle, API and freedom boundaries.

submit

The instance-side half of catalog submit: a single optional pre-store scan hook. Everything an org tunes about submit lives under policy.submit above; this block is operator-only and never reaches the policy-as-code document or any shell.

KeyDefaultWhat it does
scanHookunsetno hook, and no bundled antivirus - an unconfigured deploy stores what it is sent
scanHook.kind-exec (bytes on stdin, exit code is the verdict) or http (bytes POSTed, status is the verdict)
scanHook.target-executable path for exec; an http(s) URL for http
scanHook.args[]extra argv for exec
scanHook.timeoutMs10000wall-clock budget for one scan
scanHook.onErrorrejectwhat an unanswered scan means; allow opts out of failing closed

Wiring ClamAV or an ICAP gateway is written up in operations.

notify

Notification egress. Absent (the default) means dormant: zero egress. Sends only to endpoints you configure, never phone-home; delivery is fire-and-forget, so an unreachable relay never fails or slows a request. What gets sent, and to whom, is written up in operations.

KeyDefaultWhat it does
smtp.host-the org's SMTP relay
smtp.port587submission port; 465-style implicit TLS wants secure: true
smtp.securefalseimplicit TLS from the first byte. Off, STARTTLS is taken whenever the relay offers it
smtp.from-the From address on every notification
smtp.userunsetAUTH PLAIN user; the password rides LW_SMTP_PASSWORD, never this file
webhook.urlunsetone JSON POST per event, signed with LW_WEBHOOK_SECRET (required - an unsigned webhook is refused at boot)
people.emailfalsealso email people notices: access requests and their answers and accepted invitations, which always reach the inbox of anyone with an account, and invitations and approved requests to join, for addresses with no account yet. Needs smtp. See below
people.fromNameinstance.namethe sender's display name on those emails, 1 to 60 characters on one line. An invitation is sent as "Andy via lolly.ing"

Emailing people notices

People notices go through one function (server/src/notify/people.ts), which writes the inbox message and, once email is on, also mails each recipient's verified address, or the address of an invitee or a person asking to join who has no account yet. Email is off by default, and every page and answer says so: the request page says the instance does not send email yet, and the console never shows "Emailed" for a mail that did not go.

Email is on only when all three hold: people.email is true, smtp is set, and the server's mail sender can confirm that the relay accepted each message. The sender in this release sends and forgets, so it cannot confirm, and people notices stay inbox-only even with the first two set. Still to be built: that confirming sender; plain-text mails sent as "Andy via lolly.ing" from the smtp.from address, which never carry text a person wrote (such as a request note) and end with "Not expecting this? You can ignore this email." and a link to stop emails from the instance; and caps of 50 invitation emails a day per inviter, one email per address a day, and one per request to each approver. Once a release carries them, switching email on takes these steps:

  1. Pick a provider that gives you an SMTP relay on port 587 with STARTTLS and its own DNS records, such as Postmark, or Amazon SES. The sender address needs no mailbox.
  2. Configure the relay in instance.json and apply it through your instance's reviewed deployment route. lolly.ing uses UpCloud/K3s; its old deploy/vm/push.sh route is historical (see current hosted production):
    "notify": {
      "smtp": { "host": "<relay host>", "port": 587, "secure": false, "from": "no-reply@lolly.ing", "user": "<relay user or API token name>" },
      "people": { "email": true, "fromName": "lolly.ing" }
    }

Put the relay password or token in LW_SMTP_PASSWORD in the server's environment through your deployment's secret store, never in the configuration file. On Kubernetes, use the existing instance Secret; the former /opt/lolly-ing/.env is not the current lolly.ing configuration. secure: false with port 587 takes STARTTLS when the relay offers it; use secure: true only for port 465.

  1. Publish the DNS records the provider shows, in the domain's DNS. For lolly.ing that is Namecheap, Domain List > Manage > Advanced DNS, zone lolly.ing:
RecordHostValue
SPF (TXT)@v=spf1 include:<provider SPF host> ~all. A domain has one SPF record: if a v=spf1 record exists already (Namecheap's mail forwarding adds include:spf.efwd.registrar-servers.com), add the provider's include: to that record, never publish a second
DKIM (TXT or CNAME)<selector>._domainkeyexactly what the provider shows
Bounce domainthe provider's return-path host, for example pm-bounces (Postmark, a CNAME) or mail (SES custom MAIL FROM, an MX and a TXT)what the provider shows; it lets SPF align with the From domain
DMARC (TXT)_dmarcv=DMARC1; p=none; rua=mailto:andyfitz@gmail.com; adkim=s; aspf=r. Move to p=quarantine after a clean week of reports

No MX record is needed to send from no-reply@.

  1. Check. Wait until the provider shows the domain as verified. Then approve a request to join from a spare address, or invite one, and read the delivered mail's headers: they should show spf=pass, dkim=pass and dmarc=pass.

Keep using Copy invite message after email is on: a message sent from your own account is the one a new person is most likely to trust.

siem

Audit events pushed to the org's own receiver in signed batches (plans/35). url absent (the default) means off. Loss-free by construction: the audit log is the outbox, a durable cursor records the highest seq the receiver confirmed, and a refused batch replays whole. Long-lived server only - on a serverless deploy, poll GET /api/v1/audit with a service token instead. Delivery lag is the lw_siem_lag gauge on /metrics.

Batches use lolly.audit-projection.v1 with privacy-filtered bodies, original source hash references and separate projection proofs. Qualify the receiver's adapter before rollout. Original rows and their retention policy remain unchanged; see account lifecycle operations.

KeyDefaultWhat it does
urlunsetthe receiver; batches POST as JSON signed with LW_SIEM_SECRET (same header scheme as notify webhooks)
batchSize200events per POST (1-1000)
intervalSeconds30forwarding cadence (>= 5)

catalogProviders

Server-side catalog connectors use network.providers for outbound requests. Initial destinations must resolve entirely to public addresses unless their exact origin appears in allowedPrivateOrigins. Private deployments list only reviewed origins, including the intended scheme and port; paths and wildcards are refused. Native connections use a checked DNS answer while retaining the original Host and TLS hostname. For internal HTTPS services, use the same read-only CA trust setup described for other service connections.

Cross-origin redirects require redirectOrigins, and only GET/HEAD reads may follow them. Credential and custom headers are removed on a cross-origin hop. Every hop is checked again, with at most three redirects. Explicit error/manual redirect modes retain their stricter caller behavior. The default private and cross-origin redirect lists are empty. maxBytes defaults to 64 MiB per request and decoded response, and accepts 1 to 1 GiB; review the required media sizes before changing that limit. Requests have a 30-second budget. These controls apply to catalog federation, setup/consent checks, preview, hosted catalog bytes and publish requests. They do not define the identity provider or delivery destination policy.

Deploy-time (GitOps / air-gap) provider entries, upserted at boot as managedBy: 'config' and read-only in the API. Each entry: id (lowercase, dash-separated), kind, label, optional credentialRef (the name of the env var holding the secret), enabled, options, mapping, exposure, sync. Duplicate ids, unknown kinds and missing labels are startup errors. A provider's sync.maxAssets (a whole number) caps how many assets one sync federates from that provider. See catalog.

catalogServing

Sizing for catalogs with tens of thousands of assets. Every key has a working default.

KeyDefaultWhat
maxProviderAssets100000most assets one provider sync federates, unless the provider sets sync.maxAssets. A walk that stops here is marked truncated
pagedProviderThreshold2000providers larger than this leave assets/index.json?paged=1 and are listed under pagedProviders
extCache.maxBytes67108864 (64 MiB)memory for cached federated bytes; 0 turns the cache off
extCache.maxItemBytes2097152 (2 MiB)larger files stream through uncached

See large catalogs.

mapping.hiddenTags lists labels this provider's assets never show: exact tags or prefixes ending in *, matched without regard to case. It is applied when the index is served, so a change takes effect at the next restart with no re-sync; the console's Tags panel shows these as set in instance.json. A database-managed provider takes the same list from the console instead (hiding tags).

Environment variables

Secrets

VarWhenWhat
LW_SESSION_SECRETrequired in prodmember/guest/state token HMAC key; the audit log's MAC key is derived from it
LW_SESSION_SECRET_PREVIOUSduring a rotationverification accepts it beside the current key; minting never uses the previous key. Drop after the longest session TTL. The audit MAC does not use it: run lw audit retire-key after a rotation, with or without the old value (audit)
LW_LINK_SECRETrequired in prodlink signature key
LW_LINK_SECRET_PREVIOUSduring a rotationsame window contract for outstanding signed links
LW_IDP_CLIENT_SECRETif your IdP issues oneOIDC confidential client secret
LW_LOG_FORMATtextjson writes one JSON object per log line (and turns the http access line on) for a log pipeline
LW_LOG_HTTPoff1 writes an access line per request in text mode: request id, method, route label, status, ms
LW_PROXY_AUTH_SECRETwith proxyAuth.enabledthe shared secret the reverse proxy injects as x-lw-proxy-auth. The name is proxyAuth.secretRef; proxyAuth.directory.bindPasswordRef identifies the LDAP bind password the same way
LW_CREDENTIAL_SECRETonce a provider credential is storedmaster key sealing credentials at rest (AES-256-GCM)
LW_METRICS_TOKENto scrape remotelybearer token for /metrics. Required in production and proxied deployments; unset permits loopback only in an unproxied evaluation instance
LW_SMTP_PASSWORDwith notify.smtp.userthe relay password (AUTH PLAIN)
LW_WEBHOOK_SECRETwith notify.webhookHMAC key signing every outbound webhook event
LW_SIEM_SECRETwith siem.urlHMAC key signing every forwarded audit batch
LW_RENDER_WORKER_SECRETwith a render workershared HMAC key; must match the worker
LW_C2PA_SIGNING_KEYto sign exportsPKCS#8 private-key PEM
LW_CATALOG_SIGNING_KEYwhen the shell pins a catalog keyECDSA P-256 private key (PKCS#8 PEM or private JWK JSON) that signs each caller's tool index, tool file digests and verified optional media resources at /catalog/tools/index.sig.json. Unset: the catalog is served unsigned and that path answers 404, even when the pack carries a build-time signature, because that envelope can never match the index served per caller
LW_BLOBS_S3_CREDENTIALwith blobs.driver: s3<accessKeyId>:<secretAccessKey> for the blob bucket
<credentialRef>per config-managed provider or delivery destinationresolved at boot, never persisted

Optional media proofs come from the mounted publisher's verified catalog envelope. Original bytes must match its full SHA-256 digest before Work signs a caller-scoped resource map. Visible tool and asset entries and the verified, deterministically filtered preview bundle select that map. Hidden entries never enter its bundle or tool file map; guest tool metadata is limited to the shared tool while the public asset feed retains its normal visibility. /info/shots/ and fallback share cards can use exact verified bytes from the mounted instance.shellDir. Absent proofs, missing files or changed original bytes omit the optional resource and require the shell's safe preview fallback. Resource work is bounded to 4,096 referenced paths, 64 MiB per file and 256 MiB of checked bytes per view. A source pack without signed media proofs still supports tool rendering; qualify preview behavior with the paired shell release.

In development, LW_SESSION_SECRET and LW_LINK_SECRET fall back to ephemeral randoms, so sessions die on restart. In production (NODE_ENV=production) their absence throws.

Everything else

VarDefaultWhat
LW_CONFIG./instance.jsonconfig file path. The one the standalone server, Compose and Helm read
LW_CONFIG_JSON-the whole config as a string. Read only by the Vercel entrypoint (api/_lib/bootstrap.ts); the standalone server ignores it
DATABASE_URL-Postgres. Unset ⇒ in-memory store (evaluation only)
LW_AUTO_MIGRATEtrue when unsetboot-time DDL. false/0/off/no/empty ⇒ no DDL and refuse to start on a pending schema (the HA invariant)
LW_SEED_CONFIG-path to a governance document applied at boot; trusted and idempotent (governance)
LW_ALLOW_STALE_SHELL-1 downgrades the stale-shell boot refusal to a warning
PORT8787listen port
NODE_ENV-production makes secret checks fail-closed
LW_TEST_DATABASE_URL-enables the Postgres conformance leg in pnpm test
LW_TEST_S3_ENDPOINT, LW_TEST_S3_CREDENTIAL-with LW_TEST_DATABASE_URL, run the process-level automation lease acceptance (tests/automation-leases-process.test.ts) against a disposable S3-compatible store. The credential is <accessKeyId>:<secretAccessKey>; the bucket is LW_TEST_S3_BUCKET (default lolly-work-test) and is created when missing
LOLLY_OSS_DIR../lollywhere pnpm run demo finds the built OSS web shell
LW_DEMO_MOCK_ASSETS-pnpm run demo only: also seeds a mock provider, demo-dam-large, with this many synthetic assets (half SVG, half PNG, up to 200000) for checking the shell against a large catalog

Changing configuration

Config is read at boot: edit and restart (Helm: update the ConfigMap and roll). Anything you want to change without a restart belongs in governance - grants, overlays, chains, feature flags, provider exposure - which is live-editable in the console and exportable as code. See governance.