Kinotic
Reference

Project Publishing Design

How a project's microservices and UIs are deployed, stored, and served, and how far the build has come.

Overview

This page is the design record for publishing a project's artifacts: every microservice under packages/microservices runs in a VM of its own, and every UI under packages/ui is built by the deployment, uploaded to organization-owned Azure Blob storage, and served on its own hostname through the Cloudflare sites Worker. It describes the target shape, and the Built so far section at the end says which parts exist today. The user-facing view of the same flow is Push to Deploy.

Entities

ProjectDeployment            one per project: checkout dir, sync and publish VMs, job run, status,
  │                          the artifacts the last sync found and the commit it found them in
  ├─ MicroserviceDeployment  one per microservice artifact: its VM, its machine identity, the
  │                          commit it was ensured for, status
  └─ UiDeployment            one per UI artifact: its hostname, live
                             commit, status

"Deployment" names standing deployed state, as ProjectDeployment already does. Both children are plain AbstractRepository entities in management-api beside Workload, not organization-scoped ones. Each child has a published service for the console with findAllForProject and remove, and UIs also have findAllForApplication; removal is the only path that destroys anything.

Module placement follows the two servers the platform is splitting into: a management server running management-api alone, which the portal and every organization machine reach, and a system server running management-api and system-api, which the system console reaches. So the records, their repositories and every service the portal calls live in management-api; everything that touches nodes, workloads or Azure lives in system-api; and the management plane reaches the second only through DeploymentOperationsProxy, a @Proxy interface onto the system zone's DeploymentOperationsService (restartMicroservice, removeMicroservice, removeUiSite), each of which writes intent that the deployment's worker carries out. The management services authorize a request against the caller's organization, then delegate; the operations service trusts its callers. A cluster member that commits to a project's repository on an organization's behalf gets a GitHub installation token with contents write access to that one repository from the system zone's ProjectRepoCredentialService.issueWriteToken(projectId), calling it as the OrganizationParticipant of the organization's user it acts for; the service issues tokens only for that organization's projects. ProjectDeployment keeps the sync identity and loses runtimeWorkloadId and runtimeMachineIdentityId, which move to the per-microservice rows.

Artifacts and their identity

The artifacts a commit contains are found inside the sync VM, over the checkout, by TypeScript in the workload-runner: code that sits in the Bun ecosystem and can later resolve packages the way the project's own tooling (bunup) does. For now the rule is naive and reads no Bun or bunup configuration: a microservice is a package directly under packages/microservices with entry package.json.main, else src/main.ts; a UI is a package directly under packages/ui with a build script that writes dist/index.html. The UI location is the one requirement that stays hard-coded when the resolution improves.

An artifact's identity is the unscoped part of the name in its package.json (@acme/admin is admin), a single zone label, and a UI's without --; a name breaking either rule fails the deploy naming the package. The directory name never matters. The sync VM reports what it found through ProjectArtifactService.recordArtifacts, authenticated as the project's sync machine identity, as one ProjectArtifacts: the commit it was found in, the artifacts, and the dependencyHash of the checkout (a SHA-256 of bun.lock and of the version of the tree the SBOM reads from it). The server records it on ProjectDeployment and applies the same name rule to what it is told.

The deploy job

#TaskWhat it does
1Resolve deployment targetDeployTarget(nodeId, hostDir, syncWorkloadId, uiPublishWorkloadId, sbomWorkloadId), recorded on the ProjectDeployment before any workload runs, since the microservice workers place their VMs on its node
2Sync project sourcegit fetch/checkout · bun install · find artifacts · kinotic sync --publish · build UIs · report artifacts and the dependency hash · write the sentinel (still last)
3Resolve artifactsbinds the ProjectArtifacts(commitSha, microservices, uis, dependencyHash) the sync VM reported for the commit into the run; fails when the record names another commit. Store.state(ARTIFACTS).wire()
4Ensure runtime workloadsone VM per microservice artifact with one machine identity each; running ones restart on the sentinel, ended ones are replaced, missing ones created, vanished ones marked ORPHANED. Store.state(MICROSERVICE_DEPLOYMENTS).wire()
5Publish UIsmints a hostname for each first-published UI → one directory SAS per site → publish VM uploads → finalize rows and wait for each site to serve; vanished UIs marked ORPHANED. Store.state(UI_DEPLOYMENTS).wire()
6Generate SBOMwhile ProjectDeployment.sbomGenerated is false, which recordArtifacts makes it whenever the reported dependencyHash changes: SBOM VM reads the dependency tree of bun.lock and records it through ProjectArtifactService.recordSbom → verify the flag is set; otherwise nothing runs. Store.state(SBOM).wire(), whether the run generated the SBOM

Placement: every VM of a project shares the node holding its checkout. A first deployment's probe wants SYNC_MEMORY_MB + microservices × RUNTIME_MEMORY_MB, but the artifacts are only known after the sync VM has run on the chosen node; how the probe accounts for the microservices is decided with the runtime-workload phase. The runtime workload of each service sets KINOTIC_APP_ENTRY = <dir>/<entry>; the supervisor is unchanged. ProjectDeployIdentityService.issueRuntimeCredentials reads and writes the identity id on the MicroserviceDeployment row; the sync identity stays on ProjectDeployment.

Storage

Published UIs live in the platform's sites account, one per environment (st<env>sites, created by terraform, hierarchical namespace on, anonymous access off, public network open since the edge reads from addresses the storage firewall cannot name, every read signed by a read-only SAS on the container that terraform mints and binds to the Worker as a secret), where every site is one directory of the sites container named by its hostname, which is what the sites Worker derives from a request's host:

sites/orders--admin.acme.kinotic.io/index.html          Cache-Control: no-cache; uploaded last: the atomic switch
sites/orders--admin.acme.kinotic.io/version.json        Cache-Control: no-cache; { "commitSha": "<sha>" }
sites/orders--admin.acme.kinotic.io/assets/…            Cache-Control: public, max-age=31536000, immutable; names carry a content hash
sites/orders--admin.acme.kinotic.io/…                   Cache-Control: no-cache; the rest of dist, e.g. favicon.ico

The server never touches a site's files. SiteStorageService issues the credentials the workloads act with, each a user delegation SAS scoped to the one site's directory (sr=d, signed through the Data Lake SDK): an upload URL (create, write, list, delete) for the publish workload, and a removal URL (list, delete) for the removal workload. dist is uploaded as it is, so a build needs no base path; every blob carries metadata commit=<sha>, and once the index has switched the publish workload deletes every blob of the site stamped with a commit other than the current one, listing its own directory through the blob endpoint. A removal deletes the directory through the account's Data Lake endpoint in one request.

Serving

Nothing at the edge changes when a UI is published. Terraform's modules/cloudflare-sites gives an environment, once: the zone's settings and Total TLS, the proxied records for the app server's hosts, and the sites Worker (deployment/cloudflare/sites-worker/worker.js) bound to every host of the sites domain and to none of the apps-api hosts. The Worker reads the site from the request's host, <app>--<ui>.<org>.<sitesDomain>, or the application's site for a tenant's host, <tenant>--<app>--<ui>.<org>, and the file from the path:

a path whose last segment has an extension   →  sites/<site>/<path>        the file
any other path                               →  sites/<site>/index.html    a route of the single-page application
<org>.<sitesDomain>                          →  302 <portal>/login/<org>

Assets under assets/ carry a content hash and are cached at the edge for a year; the index and version.json for a minute, so a publish needs no purge. The account's container is private: the Worker signs every read with a read-only SAS on the container, which terraform mints for two years and re-mints yearly, so a yearly apply renews it with a year to spare; the browser only ever sees the response.

Per organization the edge needs two proxied records, *.<org>.<sitesDomain> for its sites and <org>.<sitesDomain> for its own host (a wildcard never matches its own label), which the platform creates through OrganizationDnsRecordProvisioner when the organization is created and before its first publish, and which Total TLS issues the organization's certificates from. That is the one thing between a sign-up and a served site, and it completes in minutes with nothing else to propagate; per-site domains, certificates and routes could never make a new UI available within minutes of a publish, and that is why they do not exist. HttpUiDeploymentProvisioner only reports: a site is PROVISIONING from its first publish until https://<hostname>/version.json answers with the deployment's commit and https://<hostname>/ answers with HTML (a file bypasses the spa rule), then READY: the deployment's worker, UiDeployOrchestrator, checks every 30 seconds until the site serves the published commit, recording what the site answered on the row meanwhile, and the reconcile master's resync brings a site back under check after a server restart.

The hostname is {app}--{ui}.{org} under the sites domain (kinotic.io), minted at first publish, stored as UiDeployment.id and looked up by hostname: the UI's label within the organization's own subdomain. No organization id, application id or UI name contains --, and the organization id xn is reserved so no label starts with DNS's xn--, so a hostname names exactly one UI of one application of one organization; a site addressed to one tenant of the application puts the tenant first, {tenant}--{app}--{ui}.{org}, which the edge serves from the application's site. The repository's create enforces uniqueness: a UI name another project of the application already published fails the publish. The first label is at most 37 characters, DNS's 63 less the room a tenant's name (at most 24) and the separator take in front of it, else the publish fails naming the organization and application.

Auth

A site, <app>--<ui>.<org>.kinotic.io, is same-site with its application's API host, <org>--<app>.apps-api.kinotic.io, so the app server's session cookie flows, and the app server's CORS pattern admits the sites domain. The app server's session cookie is named __Host-kinotic-app-session (Secure and Path=/ are already set), SameSite=Lax unless kinotic.apiGateway.sessionCookieSameSite says otherwise, as a developer's profile does when the sites and the API sit on unrelated domains. kinotic.io is never put on the Public Suffix List: an entry would make every site cross-site with the API hosts, and the host-only cookie and the origin-bound login are what keep one site from reaching another's login.

Build and upload contracts

The sync step sets, per UI build, VITE_KINOTIC_HOST, VITE_KINOTIC_PORT and VITE_KINOTIC_USE_SSL, split from KINOTIC_UI_SERVER_URL: the application's API host, HostLabelUtil.apiUrl(kinotic.domain.appApiBaseUrl) (https://<org>--<app>.<apps-api domain>), placed on the sync workload by the job factory. The address the workload itself dials is for egress and not usable by a browser. They are the variables the platform's own consoles connect with, and Vite exposes them to the page without configuration. A build that leaves no dist/index.html fails the deploy before the sentinel. artifacts.ts in workload-runner is the one enumeration of the artifacts, shared by the sync and publish entrypoints.

The publish workload is named project-ui-publish-<projectId>, with id DeployTarget.uiPublishWorkloadId decided in resolveTarget like syncWorkloadId. It runs the same image in the foreground with entrypoint bun src/publish-ui.ts, the checkout mounted read-only at /workspace, env KINOTIC_UI_COMMIT, and the secret KINOTIC_UI_UPLOAD_URLS, a JSON object of UI name to <blob endpoint>/sites/<hostname>?<directory SAS, create+write, TTL the run>. Its allowed hosts are the sites account's host only; it carries no Kinotic credentials and no machine identity, and is destroyed by the orchestrator as its run ends, the way every foreground workload is. Its exit check is the one syncSource uses, extracted to one method with two consumers. publish-ui.ts uploads, per UI, everything in dist into the site's directory: the files under assets/ with the immutable header, the rest uncached, then version.json, then index.html, each a PUT with x-ms-blob-type: BlockBlob, x-ms-blob-cache-control, x-ms-meta-commit, and a Content-Type from Bun.file().type; small concurrency, one retry on 5xx, non-zero exit on failure.

Orphans and removal

An artifact missing from a deploy marks its deployment ORPHANED; it keeps running or serving and nothing is deleted. An artifact that returns is adopted (status back to DEPLOYED or READY), never re-provisioned. MicroserviceDeploymentService.remove destroys the VM, removes the identity, and deletes the row. UiDeploymentService.remove runs a removal workload (remove-ui.ts, on the node the project deploys to, holding nothing but a removal URL for the site's directory) that deletes the directory, then deletes the row; a project never deployed has no node and its site no files, and a removal that fails leaves the files for a later publish of the same label to adopt. Both confirm in the console when the deployment is not orphaned.

Properties and dependencies

kinotic.domain.publishedSites.domain, the sites domain every server shares; kinotic.systemApi.uiDeployment.sitesStorageEndpoint; and kinotic.systemApi.disableAzureStorage, which turns off the sites account's uses for an environment without it. Nothing else is a property; an organization has no storage of its own, and nothing is provisioned when one is created.

Managed artifacts, each a *Version in gradle.properties and one line in the conventions dependencyManagement block: com.azure:azure-storage-file-datalake, which signs the directory SAS, and com.azure:azure-storage-blob, which only the integration test reads with. azure-identity is already managed; no management-plane SDK is.

Deferred

Not part of this design: customer domains, an Environment entity, customer file storage, and push notification of publishes.

Built so far

  • Artifact discovery. The sync VM finds the commit's artifacts (src/artifacts.ts in the workload-runner, the naive rule above) and reports them through ProjectArtifactService.recordArtifacts, which records artifacts, the commit they were found in included, on ProjectDeployment. The deploy job's third task, Resolve artifacts, binds them into the run as a ProjectArtifacts (its MicroserviceArtifacts and UiArtifacts) under the artifacts store name, wired to watchers. The job run page of the portal and of the system console lists what the commit contains on that task's row, and the sync task's row expands into the build VM's log in both.
  • One VM per microservice. MicroserviceDeployment rows, keyed <projectId>:<name> so the store enforces one per microservice, record each microservice's workload, machine identity, entry point and commit. The deploy job's fourth task, Ensure runtime workloads, keeps running VMs, replaces ended ones and ones whose entry point moved, creates missing ones, orphans vanished ones, records failures per row, and stores the rows under microserviceDeployments. ProjectDeployment no longer carries a runtime workload or identity; findProjectMachines lists the sync identity then one per microservice.
  • Microservices in the console. MicroserviceDeploymentService (findAllForProject, restart, remove), published from management-api and reaching the VM through DeploymentOperationsProxy, and the portal's deployment page: a microservices table with logs, restart and remove, and the machines labelled by the deployment that records them.
  • UIs built in the sync VM. After the entity sync, sync.ts runs bun run build in every UI artifact with VITE_KINOTIC_HOST, VITE_KINOTIC_PORT and VITE_KINOTIC_USE_SSL, split from the KINOTIC_UI_SERVER_URL placed on the sync workload from HostLabelUtil.apiUrl(kinotic.domain.appApiBaseUrl), and fails the run naming a UI whose build leaves no dist/index.html.
  • UI deployments and the storage credentials. UiDeployment rows keyed by the site's hostname label, SiteStorageService (issueUploadUrl, issueRemovalUrl) signing directory-scoped SAS through the Data Lake SDK on the sites account, each site's directory named by its hostname, and the UiDeploymentProvisioner contract with a mock that marks sites ready at once. AzureProvisioningIntegrationTest in system-api publishes a site and checks the scope of the URLs against a developer's subscription, from the local profile and .env.local, and skips elsewhere.
  • The publish task. The deploy job's fifth task, Publish UIs, mints the hostname {app}--{ui}.{org} under the sites domain for each UI published for the first time, then runs project-ui-publish-<projectId> under DeployTarget.uiPublishWorkloadId with publish-ui.ts, the checkout read-only, KINOTIC_UI_COMMIT, the secret KINOTIC_UI_UPLOAD_URLS (one one-hour directory SAS per site, by UI name) and the sites account's hosts as its only egress, then finalizes: waits for new sites to serve, adopts returning ones and orphans vanished ones; the workload itself deletes the files of other commits once the index has switched. Without Azure storage (kinotic.systemApi.disableAzureStorage) nothing serves, so nothing is uploaded. The exit check is shared with the sync task, and the previous publish workload is retired by the next run's target resolution. SiteWorkloadFactory builds the publish workload and the removal workload alike.
  • The project SBOM. A project has one commit and one dependency hash, both on ProjectDeployment.artifacts; ProjectDeployment.sbomGenerated says whether the project's ProjectDependencies list those dependencies, and recordArtifacts clears it when a sync reports another hash. The deploy job's sixth task, Generate SBOM, runs project-sbom-<projectId> under DeployTarget.sbomWorkloadId only while the flag is clear: generate-sbom.ts reads the dependency tree of the read-only checkout's bun.lock, resolving each dependency the way bun loads it (for a package at key a/b, the first of a/b/d, a/d and d the lockfile holds), and records it with recordSbom as the sync machine, whose secret is reissued for it; the workload's only egress is the server, and the record is refused unless its hash is the one the sync reported. The server stores the tree as one document per project in kinotic_project_dependencies: packages, a keyword list of package URLs, so the projects holding a package are one term query, and direct, development, optional and edges as unindexed positions in it. The portal's SBOM page reads the tree through ProjectService.findDependencies and builds the CycloneDX document it downloads in the browser. A project's deletion deletes the tree with its deployment.
  • Sites on Cloudflare. Terraform's modules/cloudflare-sites owns everything a site needs, once per environment: the zone's settings and Total TLS, the app server's proxied hosts, and the sites Worker that serves sites/<hostname>/ from the sites account, which modules/sites owns with the server identity's Storage Blob Data Contributor on it. An organization owns no storage; its one provisioned thing is the record *.<org>.<sitesDomain>, which OrganizationDnsRecordProvisioner creates at sign-up and before the first publish. HttpUiDeploymentProvisioner creates nothing: it answers whether a published site serves its commit through version.json and its index through the edge, and the deployment's worker keeps asking until it does, then reports READY.
  • UI deployments in the console. UiDeploymentService (findAllForProject, findAllForApplication, remove), published from management-api and delegating to the system server through DeploymentOperationsProxy: remove asks for the deployment's removal, and its worker takes the site down, deletes the UI's prefix in storage and deletes the row. Deleting a project removes its UI deployments the same way. @kinotic-ai/management-api exposes it as Kinotic.uiDeployments with the UiDeployment model.
  • Sites in the console. UiDeployment.url is minted with the label, so the portal's deployment page lists each UI with a link to its site, the phase it reports and the commit it serves, with removal. Each server's session cookie is __Host-kinotic-<server>-session, named once in ApiGatewayVertcleFactory from the server's ZonePartitioningService and handed to the gateway's session handler and to the SessionBinding that resolves each page's login from it.
Copyright © 2026