Lolly
Deschide aplicația

Operations runbook

Notă: 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.

For managed SSO, use Enterprise identity deployment: provider registration, admission, optional MFA, SCIM/offboarding, logout and upgrade checks.

Day-two work: schema ownership, collaboration drain, backup, limits, monitoring and upgrades. Keep application releases separate from infrastructure relocation.

Broadcast messages - announcements and notices targeted by group, shell and engine versionsigned by Lolly Demovector SVGVerifică singurDescarcă fișierul semnat1 path30 nodes83 groups177 KBBroadcast messages - announcements and notices targeted by group, shell and engine versionsigned by Lolly Demovector SVGVerifică singurDescarcă fișierul semnat1 path30 nodes83 groups177 KB

Store and schema

Two drivers behind one seam, both passing a single shared conformance suite:

DriverWhenNotes
memoryno DATABASE_URLevaluation only - state dies with the process and replicas do not share it
PostgreSQLDATABASE_URL setdurable records and database-backed blobs; use the selected deployment profile's qualified version
pnpm run migrate            # apply pending migrations
pnpm run migrate:status     # exit 1 if anything is pending
lw migrate [--check]       # same, run where the database is reachable (not via LW_BASE)
GET /api/v1/system/migrations      # pending list - owner-gated (instance.config)

Migrations are migrations/*.sql, applied in filename order, tracked in schema_migrations, each file in its own transaction.

Who applies migrations

Collaboration ownership and replacement

The current combined HTTP/WebSocket application keeps one writable collaboration owner. The chart refuses replicaCount other than one until room routing and operation ordering are supported. PostgreSQL persistence, a multi-node cluster or ingress affinity does not make writable replicas interchangeable or establish application HA. Independent render workers can scale after workload testing.

The default Recreate strategy and 60-second termination grace period allow the old owner to drain sockets and release room leases before replacement. Expect a brief reconnect window and qualify reconnect with an existing client. Acknowledged edits are durable; unacknowledged edits remain in client outboxes. Stable LW_SESSION_SECRET and LW_LINK_SECRET values must be retained across restarts and releases. They are not generated per Pod. The render cache is per-process and warms again after replacement.

Secret rotation

SecretRotation cost
LW_SESSION_SECRETsign-ins: none inside the window. Deploy the new value with LW_SESSION_SECRET_PREVIOUS=<old>, then drop PREVIOUS once the longest session TTL has passed. Verification accepts both; every new session signs with current. The audit log's MACs are keyed from it as well, so every older row stops verifying: run lw audit retire-key once after the rotation, while PREVIOUS is still set (audit)
LW_LINK_SECRETnone inside the window: same two-step recipe with LW_LINK_SECRET_PREVIOUS. Keep PREVIOUS as long as your longest-lived outstanding links (embed links default to 90 days), then drop it - links signed under the old key die at that moment, not before
LW_CREDENTIAL_SECRETstored provider credentials can no longer be unsealed; re-enter them
LW_METRICS_TOKENupdate the scraper
LW_RENDER_WORKER_SECRETrotate app and worker together
LW_C2PA_SIGNING_KEYnew signatures use the new identity; old exports stay verifiable against the old chain

Generate once (openssl rand -hex 32), store in your platform's secret manager, rotate deliberately.

When the old value is gone

The PREVIOUS variables need the old value. A platform that stores secrets write-only (a Vercel "sensitive" variable, say) cannot give it back, so a rotation there is a clean break:

Set the same new values on every host that serves this database (each replica, and a rollback deployment kept on another platform) before any of them takes traffic.

Console headers

The console loads its assets and API calls from the same origin. Its HTML uses a Content Security Policy that permits the two preference scripts by their content hashes and blocks other inline scripts, script evaluation and framing. Console responses also send nosniff, no-referrer and X-Frame-Options: DENY. The policy allows inline styles for the console's existing controls and theme.

Backup and restore

The database covers durable records and, under blobs.driver: "pg", stored asset bytes. It is one part of a complete restore:

Recovery inputPreserve and verify
PostgreSQLConsistent dump or tested point-in-time recovery, checksum, schema/release identity and an independent restore
S3-compatible blobsThe selected bucket's referenced bytes/versions and separately tested recovery; a database dump does not include them
Configuration and governanceCurrent instance.json and lw export, with the destination URL, identity/provider and permission mappings reviewed
Keys and credentialsProtected recovery copies of signing, provider-sealing and other required keys, separately from non-secret configuration
Pack, shell and engineExact release manifests/digests, engine contract, configured private pack and any artifact/source bytes needed to reproduce the signed release
External sourcesReconnect permissions and required provider-held originals; references are not copies of external bytes

Keep off-host custody and the decryption keys independently recoverable. A successful upload or Kubernetes Job is not a tested restore. The optional encrypted PostgreSQL backup workflow starts suspended and requires independent GET/decryption and a disposable-database restore before enabling its schedule. Its bounded archive size must fit your actual database. YunoHost's package backup includes its app/data directories and database; protect that archive and qualify its separate install/upgrade/restore lifecycle.

Published build artifacts can be rebuilt only while the exact source, dependencies, signing custody and private assets remain available. Preserve required private pack bytes and any release that cannot be reproduced. The server prints the audit head to stdout at boot and hourly. Configure and verify collection into an independently retained external log system; printing alone does not preserve it outside this deployment. Keep a snapshot beside the backup too (audit).

Restore into a distinct empty target, verify the database/blob bytes, apply the reviewed schema and release, restore configuration/keys and mount the matching pack/shell. Preserve or explicitly rebind identity callbacks and provider access. Stored provider credentials need their original LW_CREDENTIAL_SECRET; without it they must be re-entered. Check owner and viewer/editor sign-in, shared uploaded assets, document/agent permissions, live editing/reconnect and representative exports. Record the restore scope and elapsed time. A new-host drill and HA qualification are separate from recovering a dump on an existing cluster.

Rate limiting

Per-IP token buckets on three surfaces: auth, telemetry, link. Behind a reverse proxy set rateLimit.trustedProxyHops to the number of proxies you actually terminate - 0 means never trust X-Forwarded-For, and getting this wrong either rate-limits the whole world as one client or lets a header spoof the limiter. Authenticated console and API paths are not throttled.

Pre-store scan hook for submissions

No scanner ships and none runs by default: an unconfigured deploy stores whatever a member with catalog.submit sends, exactly as it stores whatever a federated source hands back. This project ships the hook, never a scanner - wiring one, and keeping it current, is yours.

Wire one in instance.json under submit.scanHook. It is instance config, not org policy: it never appears in the policy-as-code document, in org-config, or in anything a shell can read.

{
  "submit": {
    "scanHook": {
      "kind": "exec",
      "target": "/usr/bin/clamdscan",
      "args": ["--no-summary", "-"],
      "timeoutMs": 10000,
      "onError": "reject"
    }
  }
}
FieldMeaning
kindexec (bytes on stdin, exit code is the verdict) or http (bytes POSTed, status is the verdict)
targetthe executable path, or the gateway URL
argsextra argv for exec; the bytes always ride stdin
timeoutMswall-clock budget for one scan, default 10000
onErrorwhat an unanswered scan means: reject (default) or allow

The hook runs before anything is stored, which is the whole reason it exists: a veto means the bytes were never written to the BlobStore and no record was created. exec reads exit 0 as clean and anything else as a veto, with whatever the command printed carried back as the reason (the clamdscan - pattern). http reads any 2xx as clean and any other status as a veto, with the response body as the reason; the request carries x-lolly-submit-sha256 so a gateway can cache its own verdicts.

onError covers the third case, which is neither clean nor infected: the scanner did not answer at all - a timeout, a refused connection, a missing binary. The default is reject, so an unreachable scanner refuses submissions rather than quietly turning the gate off. Set allow only if you would rather take the bytes than block contributions during an outage.

exec suits a single-node install (one config block, no new service); http is for serverless shapes and ICAP gateways your security team already runs.

The verdict is audited either way, under catalog.submit: a refusal carries the code and the scanner's reason, and there is nothing else for it to hang off, since no asset was created. An accepted submission records what the hook did as scan - clean when it answered and passed the bytes, unavailable when it could not answer and allow let the bytes through anyway, and absent when no hook is configured at all. An outage you chose to ride out never reads as a clean scan, so "which files went in unscanned last Tuesday" stays an answerable question.

Notifications

Without a notify block the instance sends nothing, ever - approvals and reviews live in the in-product inbox alone. With one, the same moments that write an inbox message also reach people where they actually are:

MomentMail goes toWebhook event
Approval requestedthe step's eligible approvers + nominees (never the requester)approval.requested
Approval decidedthe requesterapproval.decided
Submission enters reviewthe review step's approverssubmission.queued
Submission decidedthe submittersubmission.decided
Broadcast message sent(nobody - mail would double the inbox it is)message.sent
Blob offload threshold reachedadministrators (admin and owner roles), once a month per thresholdblob.offload.threshold

Mail is plain text through the org's own relay (notify.smtp - see configuration); a user without an email address is skipped. Webhook events POST to notify.webhook.url as JSON with x-lolly-signature: sha256=<hmac(timestamp.body)> under LW_WEBHOOK_SECRET and an x-lolly-timestamp header - verify both, refuse stale timestamps, and forgeries and replays are dead on arrival. One retry, then the failure is counted (lw_notify_total{outcome="failed"}) and logged; delivery never blocks or fails the request that triggered the delivery. Neither channel is phone-home: both targets are the org's own, named in its config, reached only when its members act.

Blob growth and version retention

Every version of an instance asset keeps its own bytes, and the default (policy.catalog.versionKeep: 0) keeps every version forever. That is the right default - an org that has just materialized its brand history out of a DAM should not find the product deleting the originals it moved - but it does mean the blob store grows with contribution, not with the number of assets.

The arithmetic is worth doing before it surprises you. A brand team replacing 200 hero images four times a year at 8 MiB apiece adds roughly 6 GiB a year, on top of whatever the originals weigh. Where the bytes live decides what that costs: with blobs.driver: "pg" the history is stored in your database and in every database backup, which is the number that usually matters first; with "s3" it goes to object storage, where it is cheap but is still yours to lifecycle. See configuration.

Two ways to bound it, and they compose:

Neither is a substitute for watching the store. GET /metrics carries the stored and read blob totals, and with blobs.driver: "pg" the server raises its own alert at the thresholds in the next section.

Blob offload trigger

With blobs.driver: "pg", project files and instance assets are bytea rows in PostgreSQL. That keeps an instance to one database to run and back up. Moving the bytes to object storage pays off only once the database carries enough of them, so the server measures two figures and raises an alert when either reaches its threshold:

FigureThresholdMetric
Bytes stored in PostgreSQL10 GiBlw_blob_stored_bytes{driver="pg"}
Bytes read from PostgreSQL in one calendar month (UTC)100 GiBlw_blob_read_bytes_total{driver="pg"}

Both thresholds are provisional and will be reviewed before object storage offload is built.

The alert needs no monitoring stack:

The monthly read figure is kept in the same table. Each server writes the bytes it has read there about once a minute while reads continue, whenever the figures are read, and when it shuts down, so the month's total survives restarts and adds up across replicas. A crash loses only the reads not yet written. lw_blob_read_bytes_total counts from the process start, like any Prometheus counter; compare increase(lw_blob_read_bytes_total[30d]) with the threshold.

The s3 driver reports reads but no stored total, because totalling a bucket means listing every object, and the store's own metrics already show its size. Neither the s3 driver nor the memory store raises the alert.

An alert changes nothing by itself: it says the move to object storage is due for planning. Do not switch blobs.driver on a live instance to clear the banner. Changing the driver does not copy the existing blobs, and the step that copies and verifies them is not available yet.

Monitoring

GET /healthz     unauthenticated, cheap - liveness (the process answers)
GET /readyz      unauthenticated - readiness: the store answers `select 1` (503 while it cannot); the Helm chart's readinessProbe
GET /metrics     Prometheus; loopback-only unless LW_METRICS_TOKEN is set

Gauges worth alerting on:

GaugeAlert when
lw_audit_chain_intact0 - investigate immediately
lw_provider_last_error1 for a provider you depend on
lw_provider_assetsdrops sharply (an exposure or upstream change)
lw_provider_credential_expiry_days<= 14 - rotate before the vendor's schedule does the telling (emitted only where an expiry was stated at credential entry)
lw_siem_laggrows while forwarding is configured - the receiver stopped confirming
lw_rate_limit_bucketsapproaching rateLimit.maxBuckets
lw_process_resident_memory_bytestrending up across a render-heavy day
lw_blob_stored_bytesapproaching 10 GiB with driver="pg": see blob offload trigger
lw_blob_read_bytes_totalincrease() over 30 days approaching 100 GiB with driver="pg"

lw_origin_bytes_total{class} counts the response body bytes the server wrote, after compression, by the route's delivery class (public-immutable, public-revalidate, public-bounded, link, instance-private, person-private). It does not alert; it shows how much traffic a CDN in front could take, since a CDN may serve only the public classes. HEAD, 204 and 304 answers count nothing.

The Helm chart's ServiceMonitor needs metricsToken, because /metrics is loopback-only without one.

Retention and erasure

policy.retention bounds the two tables that grow with use - see configuration for the keys and their invariants (anchored audit trims, delivery before deletion, 0 keeps everything). The long-lived server applies the stated policy at boot and daily; on serverless, cron lw retention run (a service token works) - the route and the interval run the same code.

Account erasure is deliberately limited. Run lw users erase-preview <id> first: it reports retained references and affected telemetry without changing data. lw users erase <id> atomically removes the account's identity row and de-attributes telemetry, or leaves both unchanged if a retained reference or database error blocks erasure. Archiving a project retains its foreign key; ownership transfer or an approved lifecycle action is needed. Sessions, approvals, links and message acknowledgements can also block removal. The memory store now matches PostgreSQL on these conditions. The account's unfinished project-file uploads and its scoped uploads are removed first; if a scoped upload remains, for example because the person is still uploading, the erasure answers 409 ERASE_UPLOADS_REMAIN with nothing else changed. Run it again.

Audit actor IDs and other retained references can remain linkable. Neither the account operation nor turning analytics off deletes all personal content, historical copies, external deliveries or backups. Complete the scoped rights and restore-handling process in data lifecycle; a successful account operation is not proof that the entire request has been fulfilled.

SIEM forwarding

siem.url streams the audit log to your Splunk/Sentinel/collector as signed JSON batches - see configuration for the block and the loss-free cursor design, and verify x-lolly-signature + x-lolly-timestamp receiver-side exactly as with notify webhooks. Alert on lw_siem_lag (events not yet confirmed): a healthy forwarder holds it near zero, a dead receiver grows it without losing anything, and on a serverless deploy - where the forwarding loop cannot run - a service token polling GET /api/v1/audit is the supported path.

Upgrades

Choose the release operation before changing anything:

ChangeWorkflow
Compatible web frontend, with unchanged backend, engine and toolsFast frontend update: build the qualified main revision and publish with the protected instance inputs; reuse the current image
Qualified application image only, with compatible schema/configurationGuarded app-only update: explicit target, before/after digests, dry-run plan and reviewed apply
Database schemaIndependent backup and migration review; Helm's pre-upgrade migration Job must succeed before the new application serves traffic
Mounted shell/pack, engine contract or instance configurationSeparate content/configuration release and matching compatibility/signature checks; an application image cannot replace mounted files
Database, storage, DNS, cluster or hostInfrastructure/migration runbook and independent recovery; the app-only helper does not perform this move
YunoHost packageIts package upgrade/backup lifecycle, with review of custom configuration and actual Linux qualification

For every release, retain the previous qualified artifacts and inspect pending migrations with pnpm run migrate:status. The image-only helper never runs the Helm migration Job or database migrations. Complete any required schema operation before applying that image plan. Record the same digest in Helm/GitOps desired values so reconciliation cannot restore a stale image.

After replacement, check Deployment readiness and /readyz, then sign-in, collaboration reconnect and the enabled exports. /healthz proves process liveness, not database or deployment readiness. Check lw audit head and the chain gauge. A failed or ambiguous update needs current-resource inspection and a fresh reviewed forward fix or application rollback; do not replay a stale plan or restore an older database over new writes.

Migration 0075 and scoped uploads

Migration 0075 adds the uploads table for scoped uploads and lifts the project file size check to 4 GiB. It changes no budget: the configured project file caps stay as they are, and scoped uploads stay off until policy.uploads.enabled is set. Turning them on adds a second instance budget on the same blob store; see configuration before you do.

Migration 0076 and file references

Migration 0076 adds the file scope, three nullable columns (role, derived_from, report) and metadata_bytes to the uploads table, and checks that only a file reference uses them. metadata_bytes is the size of a reference's derived_from and report as JSON, which the budgets charge on top of its bytes; it is 0 on every existing row, so existing rows keep their values and no budget changes. File references share the scoped uploads' switch and budgets, so they stay off until policy.uploads.enabled is set; once it is, every member or service token with tool.use can keep and read back its own references for policy.uploads.fileTtlHours (a week by default).

The engine pin

The open-source engine is vendored, pinned and unmodified; engine-pin.json records the pin and pnpm run verify:engine-pin (which runs automatically before pnpm test) fails if the vendored tree and the pin disagree. Re-pinning is a deliberate act: bump the pin, run the suite, check the bridge-contract version label, commit. Letting the pin drift far behind is a known maintenance risk - see status.

Re-pin cadence

pnpm run repin-engine reports drift against the sibling OSS checkout (LOLLY_OSS_DIR, or ../lolly next to this repo): commits behind OSS HEAD and pinned vs current engine/core versions. It is read-only and cheap - run it in CI or before a release to see how stale the pin is.

pnpm run repin-engine --apply performs the re-pin: it backs up vendor/ and the pin to a temp dir, runs the OSS repo's scripts/pack-engine.ts, extracts the fresh tarballs into vendor/, adopts the new manifest as engine-pin.json, syncs the lockfile, then proves coherence with pnpm run verify:engine-pin and pnpm test. Any failure restores the previous vendor tree and pin, so the working copy is never left half-vendored. After a successful apply, review the diff (including the bridge-contract version label) and commit.

The shell dist

If you serve the web shell (instance.shellDir), its freshness is part of the upgrade. Under a non-open access mode the server refuses to boot on a missing or pre-governance dist, because a stale shell would serve employees without the session gate and locked-input UX while the deploy looks governed. LW_ALLOW_STALE_SHELL=1 downgrades it to a loud warning - use it knowingly, briefly.

Changing the deployment design system

Use the console or lw brand to preview selection, retirement and download changes. Apply migration 0037 before enabling mutations. Decisions and their audit records commit atomically in Postgres, and every replica reads the shared revision. All replicas need the same mounted source contents and blob store. Read-only production deployments expose inventory and the operator path.

Design-system administration covers restart behaviour, missing-source recovery, reference impact and reversible rollback. A stopped download remains suppressed after restart; a mismatched download is withdrawn after a source change. Update source files by validating and redeploying, never by deleting them through a local Lolly Remove action.

Connecting apps to this instance

A Lolly client arrives as a neutral download - the app-store shell, a desktop build, or the public PWA - and everything organizational reaches it by pointing that client at this deployment. Three routes exist, in friction order:

  1. A signed .lolly instance pack. The zero-typing path: importing the pack sets the client's instance base after the signature verdict and installs the brand alongside. The pack is cut by the OSS repo's build-instance-pack.ts (the tool that owns the signed format, with its own size and licence guards) with this deployment as its instance base - then hosted HERE: lw instance pack <file.lolly> (owner), or the Connect card on the console's Fleet view. The instance serves it at /connect/pack.lolly (public on an open instance, member-gated otherwise), advertises it in the manifest's connect.packUrl, and refuses at upload any pack whose instance base is not this deployment - hosting a pack that enrolls devices somewhere else is the one mistake an operator must not be able to make silently. A key-pinned build refuses an unsigned or wrongly-signed pack on import. A deploy can also carry the pack in its own mount and host it from boot (instance.connectPack - how the demo sandbox offers its SUSE pack) - the same inspection applies, and an owner's upload still wins.
  2. The first-run instance sheet (desktop and mobile shells): the person types this deployment's URL; the shell probes GET /api/v1/instance and GET /api/auth/config and takes it from there.
  3. Profile → Lolly instance → Change, on an already-running shell.

Two realities shape the setup:

Enrollment, and leaving

Users enroll from their client; the instance never reaches out to a device. While signed in, org policy applies and work saved here (projects, sessions, submissions, audit) lives server-side. Either side can end it unilaterally: the person leaves from the client (Profile → Lolly instance → Leave removes the org brand, tools, cached catalog and install id; personal work is untouched), the organization disables the user (every live session dies on its next request). Afterwards: no remote wipe, no export block, no exit toll - and a departed device keeps no org catalog. Exports made while enrolled keep their Content Credentials.

Checks and artefacts

pnpm test                     # node:test over tests/
LW_TEST_DATABASE_URL=… pnpm test   # adds the Postgres conformance leg
pnpm run typecheck
pnpm run sbom                 # regenerate sbom.cdx.json (CycloneDX)

CI workflows live in .github/workflows/. The Postgres leg only runs when LW_TEST_DATABASE_URL is set - if your pipeline does not set it, that driver is effectively untested.