36 KiB
TODO
Deferred work and known gaps. Items are grouped by area; each carries enough
context to be picked up cold. Anchors are file:line at time of writing —
re-grep if they drift.
Every item below was verified against the tree on 2026-08-20. Shipped work was removed, and items that mis-described the code were rewritten rather than left to mislead. If you add an item, say what would make it removable.
SSL / ACME
Decision: ship on certbot, switch to lua-resty-acme later
Current state (re-verified): certbot is baked into the edge image
(apps/edge/Dockerfile) and issuance runs inside the edge container —
certbot certonly --standalone --http-01-port 49180 (packages/adapters/src/infra/nginx.ts),
with the edge proxying /.well-known/acme-challenge/ to that loopback port. No
host certbot, no port-80 fight, no webroot. Renewal is driven only by
ssl-scheduler → manageDomainSsl("renew"); there is deliberately no cron
inside the image. Nothing in the tree references resty-acme — the switch has
not started, so every blocker below is untouched rather than partly done.
This is release-safe and does not foreclose the Lua switch, because the ACME implementation is already behind a seam:
SslProvider(packages/adapters/src/infra/types.ts) is four methods —provisionCert/renewCert/installCert/verifyCert. certbot appears in the doc comments only, never in a signature.apps/api/src/lib/domain-ssl.tsis the single service-layer entrypoint. It owns the per-hostname issue lock, thetlsIssuedElsewheregate, and status persistence, and talks only to the interface.- A second implementation already exists (
packages/adapters/src/infra/cloud.ts), so the seam is exercised, not theoretical. (Note its route/SSL methods are stubs — see "Open TODO markers".)
Why we'd switch: no subprocess, no output scraping, no Python in the image, on-demand issuance and auto-renew handled by the edge that already owns :443. Why not now: it moves cert storage out of the standard layout, and several subsystems read that layout directly (below).
What the switch actually costs
The interface is clean, but the cert location leaked past it. These read or
write /etc/letsencrypt/... directly and must be handled before or during the
migration:
packages/adapters/src/infra/nginx.ts—certsExist/readCertInfo, and thessl_certificatedirectives in the generated vhost.packages/adapters/src/system/proxy/cert-material.ts— cert reuse / carry.packages/adapters/src/system/proxy/import/caddy-certs.ts,import/traefik-certs.ts— adopt certs from a foreign proxy on takeover.packages/adapters/src/system/proxy/ensure-container-edge.ts,docker-edge-executor.ts,edge-container-executor.ts,installer.ts— the bind mount that keeps certs on the host.apps/api/src/modules/migration/migration.orchestrator.ts— cross-server cert carry.apps/api/src/modules/domains/domain.service.ts,apps/api/src/lib/edge-image.ts,apps/api/src/modules/mail/mail.service.ts,packages/adapters/src/infra/mail-container.ts(mail reuses the web cert — it is a/etc/letsencryptbind mount into the mail container now),apps/cli/src/lib/compose.ts,packages/db/src/schema/domain.ts.
Cheapest path: point lua-resty-acme at a filesystem storage backend using
the same live/<domain>/{fullchain,privkey}.pem layout, so every reader above
keeps working and the change stays inside the provider. Do not adopt the default
shared-dict storage without first abstracting cert reads behind the provider.
Other blockers to resolve in the same change:
- Status feedback.
sslStatus/sslExpiresAt/sslIssuerare written by the API from the provider's return value (resolveSslPatchinapps/api/src/lib/domain-ssl.ts). Lazy on-handshake issuance has no API-side moment to write those — needs a Lua→API callback, or keep issuance API-triggered and use resty-acme only as the ACME client. - Per-hostname issue lock. The
ssl:issue:<host>lock indomain-ssl.tsexists to stop concurrent HTTP-01 orders burning Let's Encrypt budget. resty-acme does its own in-process locking; decide which one is authoritative rather than keeping both silently. installCertmust keep working. Operator-uploaded / Cloudflare Origin CA certs (manualSsl) are written to the same path with no ACME involved, andtlsIssuedElsewheremust keep excluding them.- Delete the certbot-specific workarounds once nothing shells out:
--cert-namelineage pinning and the-0001self-heal, thesummarizeCertbotFailurehelper +certbot-summary.test.ts,ensureIssued's "exit 0 but no cert" backstop, and the adopted-non-lineage branch inrenewCert— all innginx.ts. - Migration story for boxes already issued by certbot — existing
lineages and
/etc/letsencrypt/renewalconfigs. With filesystem storage this is a no-op; with any other backend it is a data migration.
If the goal is only "get Python out of the image", lego (single Go binary)
is a much smaller change than resty-acme: same executor, same on-disk layout,
structured exit codes, contained entirely to nginx.ts.
Auth
SSO login for self-hosted (OIDC first, SAML only if asked for)
Not started. Goal: an operator points the instance at their IdP (Okta / Entra / Keycloak / Authentik / Google Workspace) and staff sign in with that instead of email + password.
What's already there:
- better-auth with the org plugin in
packages/platform/src/engine/lib/auth.ts. The installed plugin set includesgeneric-oauth— arbitrary OIDC/OAuth2 issuers, no new dependency. That's the cheap path. - Not
better-auth/plugins/oidc-provider— that makes Openship an IdP, the opposite direction. Per-org IdPs and SAML live in the separate@better-auth/ssopackage, which is not installed; only pull it in if per-org IdPs or SAML are genuinely required. - Providers are already registered only when their creds exist, so an SSO provider should follow the same env-gated shape.
The prerequisite nobody expects:
- Advertise configured login providers. Shared system info and
GET /health/envexposeauthProviders(IDs and kinds only). The engine'sauth-providers.tssupplies the same credential predicate to Better Auth; login and registration render only supported, advertised providers. This completes discovery for existing GitHub/Google login. OIDC/SAML integration and its account policies remain separate work.
Decisions to settle before coding:
- Account linking.
accountLinking.trustedProvidersis["github", "google"]withallowDifferentEmails: trueinauth.ts. An SSO provider left out of that list forks a second user row on first login for an email that already exists. Decide whether IdP-asserted email is trusted (it usually is — but say so deliberately). - Where an SSO user LANDS. The invite-only gate itself is already
shipped, so this is narrower than it looks:
databaseHooks.user.create.before(auth.ts:262-306) enforces invite-only sign-up on self-hosted for the OAuth/social path, and a fresh user gets a personal-org owner membership with roleuser. What is undecided is the SSO-specific half — an IdP-authenticated user currently lands in a personal workspace, not the team org, and there is no email-domain allowlist and no default team role. - Zero-auth interaction.
authMode === "none"instances (apps/api/src/lib/auth-mode.ts) have no login at all. SSO must be inert there, not a second door into a box that deliberately has none. - Deprovisioning. Removing someone from the IdP does not end their
Openship session or membership.
session.expiresInis 30 days — either shorten it when SSO is on, or document the gap honestly. There's no SCIM and shouldn't be one for v1. - Gate on explicit env vars validated in
apps/api/src/config/env.ts, and show the resolved state in Settings → security so an operator can tell SSO is actually live.
Git providers
Provider-agnostic git: GitLab, self-managed GitLab/Gitea/Forgejo, dumb remotes
Not started — no second provider exists anywhere on disk. Today "connect a
repo" means GitHub, and gitProvider is a column written "github" and then
read as an assumption. Goal: make it a real dimension — GitHub, GitLab (SaaS +
self-managed), and a dumb-remote tier (any HTTPS/SSH remote + a credential,
no provider API) — with GitHub as one implementation behind the seam rather than
the seam itself.
What's already a seam (reuse, don't rebuild):
GitHubSource+createGitHubSource(ctx)(apps/api/src/modules/github/sources/types.ts,sources/index.ts) — one interface, three impls (App / gh-CLI / merged local), already THE place source selection happens. Generalize this toGitSourcewith a provider dimension and most controllers don't change.github.http.tsis the singleapi.github.comprimitive, so a siblinggitlab.http.tsis additive rather than surgery.resolveBuildGitToken(modules/github/clone-auth.ts) is the one clone credential issuer andtokenFor(github.token.ts) the one minter — provider dispatch belongs there, once, and stays unit-testable.- Only three places branch on the column today:
project.controller.ts("local"),clone-plan.ts(repoIsGithub),project-source.ts("release").
What actually hardcodes GitHub — each is a decision, not a rename:
- The clone URL is BUILT, not stored:
projectGitUrl(owner, repo)atapps/api/src/modules/projects/project-crud.service.ts:409returnshttps://github.com/<owner>/<repo>.git, called at :434, :851, :869, :921. Any non-GitHub project needs its remote persisted (or a per-provider URL builder). Smallest diff, widest blast radius — do it first. - Webhooks:
x-hub-signature-256HMAC + GitHub's push body (github.webhook.ts,webhook-push.ts,webhook-changed-files.ts,webhook-check-run.ts). GitLab sendsX-Gitlab-Token— a plain shared secret, not an HMAC — and a different payload. Extend the unifiedwebhook_deliverytable that already absorbed GitHub dedup; don't fork it. - Per-repo permissions are keyed to GitHub: resource type
"github"(lib/permission.ts,lib/route-permission.ts) andassertGitHubRepoAccess(github-access.ts). Decide between onereporesource with a provider-qualified id (a grant migration) or a second resource type (no migration, two gates to keep in sync forever). - Tarball fast path is GitHub-only —
githubTarballUrl(packages/adapters/src/runtime/source-tarball.ts). GitLab and Gitea/Forgejo each expose a different archive endpoint. It already falls back togit clone, so this is per-provider optional, not blocking. - The desktop credential relay pins the host: a non-https or
non-
github.comrequest is rejected (lib/git-forwarding/relay.ts). That pin is a security control, not an oversight. Widening it means an explicit per-provider allowlist — never a wildcard, and never a user-supplied host without validation. - SSH known-hosts are GitHub's keys (
github-known-hosts.ts). A self-managed remote needs operator-supplied host keys or a deliberate, documented TOFU decision. - Generalize the server-side credential RESOLVER (narrower than it used
to read here).
server-git-ambient.tsis already provider-agnostic — it takes an opaquerepoUrland runsgit ls-remotethrough the sharedassembleGitClone, with no API call and no key written; it sits inmodules/github/by file placement only. Inserver-github.service.tsonlyensureDeployKeypushes a key to an API;ensureServerKeyjust mints an ed25519 pair. So the work is the resolver + the mode→field map (drop theapi.github.com/usertoken validation for a non-GitHub token).github-deploy-key.repo.tsand the deploy-key path stay GitHub-only by construction — a dumb remote has no deploy-key endpoint, so that tier is credential-only. - Release sources:
ReleaseSource.mode: "github" | "url"(packages/core/src/project-source.ts) plus thereleases/latestcalls inlib/release-resolver.tsandlib/release-download.ts.mode: "url"already covers the generic case; GitLab releases would be a third mode. - Dashboard speaks GitHub throughout:
ServerGitHubConnect,GithubPermissionModal,DeployCredentialModal, the deploy wizard's import step,ResourcePicker. Git-provider capabilities still need a server-advertised list; the existingauthProvidersfield covers login providers only and must not be used as a list of repository integrations. ghCLI as an ambient identity (sources/gh-cli-source.ts,github.local-auth.tsparsesoauth_tokenundergithub.com:in hosts.yml) has no equivalent worth matching.glabexists; decide deliberately whether to support it or require a PAT for GitLab.
Decisions to settle before coding:
- Scope: GitLab.com only, or self-managed too (custom base URL, possibly a private CA)? Self-managed is the harder half and the one operators actually ask for.
- Is the dumb-remote tier first-class? "Any remote + PAT" is cheap and covers Gitea/Forgejo/Bitbucket on day one, but it silently loses auto-deploy, repo listing, and per-repo grants. Ship it only if the UI says plainly what it can't do.
- Make
gitProvidera checked union. The canonical set already exists —SOURCE_PROVIDERS+SourceProviderinpackages/core/src/project-source.ts, andpackages/db/src/schema/project.ts:113-114documents it as canonical — but nothing validates against it. The column is still free text defaulting to"github"atproject.ts:47(project-level) and:123(environment-level), with no CHECK in any migration, and the API accepts any string (project.schema.tsgitProvider: Type.Optional(Type.String(...))). No second provider writes rows yet, so this is still the cheap moment — retrofitting a union over mixed data is the expensive order. - Naming trap:
apps/api/src/modules/github/is 29 top-level files (35 includingsources/) and the module path is load-bearing in imports across the API. Prefer addingmodules/git/for the provider-agnostic seam and leaving GitHub as one implementation behind it, over a rename that touches every call site.modules/git/does not exist yet.
The end state is sketched in apps/email/ARCHITECTURE.md ("target state, not
current"): openship provisions and talks to the mail server over an HTTP admin
API, and stops reaching into the box. Fold changes into that doc rather than
writing a competing blueprint.
Already shipped — do not re-plan it
Mail is no longer a host takeover; it ships as an image and the API no longer
transfers the engine tree or shells iRedMail.sh. openship-mail + a pinned
postgres:16-alpine sidecar, launched by raw docker run on --network host
(infra/mail-container.ts, system/mail/ensure-container-mail.ts), in the
docker-images.yml matrix alongside webmail. Host ports, host-bind-mount state
and /etc/letsencrypt cert sharing are all settled there. Two consequences remain:
Engine image: the debt the shipped path took on
- The image still runs iRedMail's installer at BUILD time and keeps the
full upstream daemon set (postfix, dovecot, amavis, clamav, spamd, iredapd,
fail2ban under
apps/email/docker/supervisord.conf). The narrow ambition — own only Postfix + Dovecot + rspamd against thevmailDB we already own viapackages/db-email, and drop the installer — is untouched, and it is now a rewrite of a shipped image, not a choice of how to ship one. There is no rspamd anywhere outside the vendored engine tree. This is the daemon-config surface we said we wouldn't own; decide whether we ever pay it down or accept it. - Remove the dead
MAIL_SERVER_ENGINE_DIRplumbing. Nothing reads it any more;apps/cli/src/commands/up.tsandapps/desktop/src/main/services.tsstill set it for nothing. (The stale file headers it came with are fixed.) - No host→container migration exists. Legacy
iRedMail.shboxes are first-class instead:detect-engine.tsdefines flavor"container" | "host" | "none"as the one topology probe, andmail-engine.tsmakes SQL transport, daemon probes, actions and config paths a pure function of it — so the admin panel, health tab and backups all still drive a host-native install. Scan-and-adopt (mail.routes.ts:33-34) is flavor-agnostic but adopts a DB row; it does not convert a topology. Needed: either a real migration (move maildirs +vmailinto the container/sidecar) or a stated product position that containerized mail is new-installs-only. Today that position exists only as a code comment indetect-engine.ts:11-13, not in the setup UI.
Standalone mail admin dashboard
The admin panel is currently inseparable from openship:
apps/api/src/modules/mail/admin/* drives the box over SSH and psql
(admin/psql-runner.ts — "All vmail.* reads and writes go through this module"),
and the UI lives at apps/dashboard/src/app/(dashboard)/emails/. So the mail
server has no administration at all without an openship control plane attached —
we deleted iRedAdmin and put nothing self-contained in its place.
Goal: mail ships as server image + admin image, administrable on its own.
- The admin API is the prerequisite, not the UI. The mail-side HTTP
server (
apps/email/server/src/main.ts) mounts exactly three route families:/auth(IMAP-backed mailbox sign-in),/mail(IMAP IDLE SSE), and/admin— which is branding only (routes/branding-admin.ts, token-gated byBRANDING_ADMIN_TOKEN). None of mailboxes, domains, DNS, relay, stats or test-send has a mail-side endpoint; every one is still an SSH+psql call from openship.ARCHITECTURE.mdstill states the as-built position: "There is intentionally no public admin subdomain … No HTTP admin surface to firewall, no token to rotate" — so this item is a reversal of a deliberate decision, and should be argued as one. - Then openship's
/emailsbecomes a client of that API, not a second implementation. (Today there is exactly ONE implementation — openship's SSH+psql — so the drift this warns about is prospective, not current.) - Auth for the standalone case. Mail-side auth is mailbox auth only
("the IMAP server is the identity provider"); there is no admin identity,
no admin login, and
vmail.mailboxaccounts are explicitly not admins (ARCHITECTURE.mdidentity table). Decide: bootstrap postmaster credential, or refuse to run standalone without an operator-supplied admin secret. TheBRANDING_ADMIN_TOKENpattern is the nearest precedent and covers branding text only. - Scoping note, still accurate:
apps/dashboardis already a standalone published image andapps/email/already produces TWO images (engine + webmail) — but there is no mail-admin Dockerfile and no matrix entry. This is context for the three items above, not work to check off.
Migrate / edge
Adopted static roots: the mount option, and the dashboard
Shipped: unreachableStaticRoots()
(packages/adapters/src/system/proxy/import/index.ts) reports adopted static
sites whose docroot sits outside EDGE_CONTAINER_MOUNTS, and
remediateUnreachableStaticRoots() (apps/cli/src/lib/edge-preflight.ts:711) is
the one remediation presenter shared by the compose preflight and the bare wizard
(#456): it lists every host→root pair, warns they would 500 after cutover, and
makes the operator choose. The chosen root is substituted at the single writer
(proxy/takeover.ts:155) and persisted into <slug>.route.json
(infra/nginx.ts:1969), so it survives the provisionCert replay on renewal.
Original symptom, for context: on a live 15-site migration two sites
(root /home/App.Front/dist/site/browser) came up 500 —
rewrite or internal redirection cycle while internally redirecting to "/index.html" — because try_files can't find an index in a directory that
isn't mounted, and nothing warned.
- The
mountoption is not offered. The select has exactly two branches, copy or leave, and "leave" tells the operator to mount the directory by hand. Copy works immediately but is a snapshot — rebuilding the frontend needs a re-copy. Mounting is the correct long-term answer and depends on the item below. - A way to add an edge bind mount without hand-editing
docker-compose.yml. The edge is the one container whose mounts depend on what the box was serving before us, and adding one costs an edge recreate (which blips every site). This blocks themountbranch above. - No dashboard equivalent. The prompt exists only in the CLI wizard;
grepping
unreachableStaticRoots/staticRootacrossapps/dashboard/srcreturns nothing, so a dashboard-driven migration still hits the 500 silently.
SSL provisioning: the reassurance line is still missing
A new project's route is registered with tls: true, but the 443 block is only
emitted once the cert exists (nginx.ts, route.tls && certsExist(domain)), so
there is a ~1 minute window where the site answers HTTP and nothing on HTTPS.
Verified end-to-end on a live box: cert written 01:00:13.137, vhost re-rendered
with TLS 01:00:13.661, https=200 right after — the pipeline is correct, but
during the gap it is indistinguishable from broken, and two people have now
reported it as an SSL bug.
Shipped since: the outcome half. createTrackedSslProvider
(apps/api/src/lib/routing-domains.ts) logs Requesting SSL certificate for <host>…, then SSL certificate active — <host> is Live. or SSL not issued for <host> — marked Action Required … Reason: … into the deploy log.
- Say the gap is expected. Nothing tells the operator the route is
ALREADY serving on HTTP and that HTTPS is ~1 minute away — which is the
whole point, since issuance is best-effort by design ("domains never fail a
deploy"). "Requesting SSL certificate for X…" does not convey it. The only
text with that framing is a
console.log(server stdout, not the deployment log) on the cloud domain-verify path.
Runtime roles: release phase, queue workers, scheduler (#231)
An app stack declares exactly ONE defaultStartCommand
(packages/core/src/stacks.ts StackDefinition), so a framework whose production
shape is several processes can't express it. Laravel is the clearest case — web +
queue:work + schedule:run — and the same shape appears in Rails
(Sidekiq/Solid Queue + cron) and Django (Celery worker + beat).
What landed (from #231, so the gap below is narrower than the issue describes):
the PHP recipe now runs on FrankenPHP, which supervises correctly and propagates
SIGTERM (packages/adapters/src/runtime/docker-build-plan.ts
generatePhpDockerfile) — a worker can now be added without inheriting the old
"container stop kills the job mid-flight" problem. Persistence, PHP extensions and
the JS asset stage also landed; the storage section below has what's left of those.
A generic release phase
Commands that run ONCE per deploy, after build and before cutover, failing the deploy on error.
Implemented (v1). Optional ordered releaseCommands are stored on the project,
frozen on deployment snapshots, and run by the shared engine after the candidate build
and before activation. Configure them in the existing build wizard, openship.json, or
API/SDK. Nothing is injected per framework.
Docker uses temporary containers with the candidate image, shared runtime env/mount helpers, and connected-service networks. Bare links persistent paths before executing against the staged artifact. Failure, cancellation, or timeout prevents activation. Commands run outside the server's port-allocation lock, under the project's execution lease. Code rollbacks skip them and do not undo database changes; commands must be idempotent and compatible with the previous app.
- Shared configuration through source scans, native/HTTP SDK, and dashboard.
- Runtime and pipeline behavior tests, including failure/cancellation cleanup.
- Real Docker release tests in the release-gated E2E suite.
- Unsupported runtimes and full multi-service/static deployments fail before activation.
- Per-service release commands for Compose/monorepo deployments.
- Worker/scheduler roles remain separate from one-off release commands.
Multi-role stacks
- Decide the shape: roles declared in
StackDefinition(worker + scheduler exist automatically on detection, preserving zero-config, at the cost of a real runtime-model change that has to compose with multi-node plans) vs. keeping apps single-process and pushing extras to--type services.StackDefinitionstill has exactly onedefaultStartCommandand no roles field. - Whichever way it goes, ANSWER IT in the docs. Auto-detection currently reads as "Laravel supported" while quietly omitting queues and scheduling, and that's the part the issue is actually complaining about.
- Cheap intermediate available today: an app project can already gain a
second source-built unit (a
monorepo-kind service row carries its ownstartCommand), and the #231 materialization keeps the web app in the fan-out. A "add a worker" button could write exactly that row. laravelandsymfony(stacks.ts) now differ inname,detectionandpersistentPaths(laravel declares["storage"]; symfony declares none, becausevar/is regenerated cache+logs and it has no upload convention). Everything else — language, category,outputDirectory: "public", port, empty build command, the byte-identical FrankenPHP start command — is shared. A per-framework divergence has therefore already begun, which is the point: it stops being correct to share the recipe the moment roles are per-framework.
Persistent storage — remaining gaps (#231, #163, #188)
Volumes for single apps shipped (packages/core/src/volumes.ts,
project.volumes), and object storage bindings shipped
(apps/api/src/modules/projects/project-storage.service.ts). Re-verified
2026-08-20 — every item below is still open:
- Backups don't cover a single app's volumes. The backup subsystem targets
servicerows: the project-default fan-out isrepos.service.listByProject(...)and throws"Project has no services to back up"on zero services (apps/api/src/modules/backups/backup.orchestrator.ts:163-165), with the execute path re-asserting it. A true single-app project has ZERO service rows (the #231 app row is only materialized once acomposesidecar exists), so a project withproject.volumesset has no policy that can back it up — and a policy CAN be saved withserviceId: null, so the failure lands at first run, not at save. It is purely a TARGETING gap: the docker executor already reads mounts off the live container. Fix needs a project-level/app source kind, or a fan-out that synthesizes the app unit (project.volumesresolves viaappRowVolumes). - Cloud has no volume primitive.
CloudRuntime.deploystill warns and drops a declared mount (packages/adapters/src/runtime/cloud.ts:1637-1648, "Oblien has no volume primitive"). Neither alternative shipped: the API acceptsvolumeson any project with no target check, and the dashboard'sStorageSettingshas no cloud gating. Either give Oblien workspaces a durable attach or make the UI refuse the field on a cloud target instead of warning at deploy time. - #163 multi-node volumes. A stack- or project-declared path assumes the
workload lands on the same host next time. There is no scheduler or cluster
primitive to constrain yet, and volume resolution is single-host by
construction (
volume-namespace.tsscopes per project slug, no node dimension). The public roadmap marks "Shared volumes across nodes" as planned — intent, not a settled story. Settle it before any scheduler can move a container between boxes. - Non-root for the other stacks.
docker-build-plan.tshas exactly oneUSERdirective —USER www-dataat :309, insidegeneratePhpDockerfile.generateStaticDockerfileand the genericgenerateDockerfile(Node/Python/Ruby/JVM/C#/Elixir/Go/Rust) emit none, so those all run as root with no writable-path prep (npm cache,.next,__pycache__, nginx temp dirs). Do them one stack at a time, not in one sweep. - A default healthcheck. No stack ships one:
docker-build-plan.tshas zeroHEALTHCHECKoccurrences,StackDefinitionhas no health field, and the only write site isdeployServiceWorkloadgated entirely on operator-authoredadvanced.healthcheck— the compose/service path only. The single-app container path cannot express one at all. Laravel ships/up(configurable inbootstrap/app.php), which would make a real healthcheck nearly free for that stack. - Bare PHP: the start command, not the toolchain. (Item corrected — the
previous version blamed a missing installer and cited the wrong file.) The
real toolchain catalog is
packages/adapters/src/toolchain/catalog.tsand it DOES havephpandcomposer, with per-distro plans (apt/dnf/yum/apk/brew) and agetcomposer.orgfallback, matchingstacks.ts'srequiredTools: ["php", "composer"]. (system/modules/catalog-embedded.tsis the embedded infra-module catalog and holds onlyopenresty.) The actual blocker is that laravel and symfony hard-codefrankenphp run --config /etc/frankenphp/Caddyfile, a binary and path that exist only in thedunglas/frankenphpruntime image — so PHP is docker-only. Fine, but the actionable half is still unshipped: say so when a user picks bare (packages/adapters/src/runtime/bare.tshas no PHP handling and no refusal message).
Open TODO markers in code
Verified present 2026-08-20; listed so they aren't lost.
apps/api/src/middleware/better-auth-shield.ts— audit the shield flow end-to-end for correctness/security.apps/api/src/lib/route-permission.ts— per-routeauditOnReadflag.apps/api/src/modules/projects/transfer.service.ts:342-347— the business-logic phase of project transfer: destroy the cloud workspace RUNTIME, kick the local deploy pipeline, re-bind the GitHub installation to the local org, write theaudit_eventrow. Part of the deferred logic HAS landed — the promote path tears down the source project — and the file header now says so.apps/api/src/modules/system/migration/migrate-instance.service.ts— trigger the actual deploy once deploy-engine integration lands (see the control-plane migration item below).packages/adapters/src/infra/cloud.ts— Oblien route + SSL endpoints are stubs (POST /routes,DELETE /routes/:domain,POST /ssl/provision,POST /ssl/renew,GET /ssl/status). Cloud is the source of truth for managed certs; until these exist, cloud SSL status is not readable.
(Removed: active-organization.ts — the file has no TODO marker and the fallback
decision is made and argued in the block comment at :78-99. And
cloud-resources.ts — its stale "resize intentionally disabled" NOTE was deleted
rather than logged, since the resize shipped and is mandatory.)
Known gaps
Re-verified against the tree 2026-08-20.
- Migrate control plane → server: phases 0+1 shipped, and sealed
transfer shipped too (
sealedRemoteImport()insystem/migration/db-migrate-remote.service.ts, writing the bundle + a 0600 env-file over SSH, withapps/api/scripts/import-instance.tsas the target-side runner). What remains: bare-server SSH provisioning, the deploy trigger (step 3 is still a comment), and the unmounted/migration/probeendpoint. SSE was a deliberate v1 non-goal, not pending work. - Edge loopback-port routing: shipped and DEFAULT, not "pending
activation" —
resolveRouteStrategy(apps/api/src/lib/upstream-url.ts) returns loopback-port for anything but an explicitcontainer-ip, and every upstream site routes through it. What's left: real-box E2E (single-app + multi-service compose), the commented-outRoutePreferencescard in settings, and the still-live container-IP fallback for routed services. - Static sandbox build: build ⟂ serve split landed; the #640 lifecycle
closures are confirmed on disk (doc-root paths classified as an
artifactand destroyed rather than handed toremoveImage; the compose static path promoting intoreleases/<dep>-<svc>; the output audit making one real HTTP request through the edge). Still unverified on a real box: the promote+audit over an SSH executor, and the docker-edge static serve path — which is a shared host bind mount, NOT anopenship_staticvolume (the volume survives only as a one-time legacy migration incompose.ts). - ARM64: the Linux arm64 AppImage IS built (
release.ymladds anubuntu-24.04-armmatrix entry producingOpenship-arm64.AppImage, with x64 keeping the legacy name for auto-update compatibility, and both the updater and the CLI installer are arch-aware). What remains: CI verification that the arm runner's AppImage actually works, plus Windows arm64 desktop is not built andrenderAssetNamestill defaults{arch}→amd64. - Cloud compose incremental add: wired, not live-validated. The decoupled
add path has no cloud refusal (
services/service.service.tscallsdeployComposeServiceswithstrictScopeand resolves resources withisCloud), and the cloud mesh gap is closed (carried peers are seeded viaregisterExistingWorkload). What's missing is running the incremental-add mesh rewrite (/etc/hosts+ private_link) against the real Oblien API. - Unified app settings: BUILT end to end, not "planned, not started" —
apps/api/src/modules/apps/app-settings.routes.tsmounted inapp.ts, with the generic rendererapps/dashboard/src/components/app-settings/AppSettingsForm.tsx. What remains: enforce the declared-but-unenforcedrequiredfield gate, runtime E2E of the install route, and native i18n review ofprojectSettings.appInstallbeyond EN. - Device auth for CLI cloud-connect requires a SaaS redeploy to go live.
(Removed: the cloud/self-host isolation audit — all five named leaks are fixed
in the tree: the dump.ts FK gap plus a same-org assertDestinationOrg guard in
the restore orchestrator, MS Teams SSRF via safeFetch with no redirects, the
verify-pending sweep, the stale notification subscription, and the GitHub push
fan-out. And the mail DKIM/SES clobber — stepDkimKeys's one runner now re-lays
the relay records immediately after rebuilding dnsRecords.)