Add microapp platform scaffold

This commit is contained in:
Ruslan Bakiev
2026-03-23 11:47:14 +07:00
parent 3d7e963087
commit d4fc4f66cc
11 changed files with 544 additions and 1 deletions

Submodule backend updated: 32a5f949d9...e8e2fe6f7f

View File

@@ -0,0 +1,80 @@
# ADR-0002: PocketBase + gVisor Platform For Agent-Built Microapps
Дата: 2026-03-23
Статус: accepted
## Контекст
Нужен минимальный платформенный слой, в котором агент может собирать микро-приложения для клиентов по жёсткому шаблону:
- данные и auth живут в PocketBase;
- код приложения исполняется изолированно в Deno runtime;
- delivery идёт только из шаблонного репозитория и после валидации;
- control-plane хранится в основной Prisma-базе проекта.
Ключевые ограничения:
- агент не должен деплоить произвольный код вне шаблона;
- приложения разных клиентов должны быть логически и операционно изолированы;
- база данных и execution host разводятся в разные контуры;
- rollout и rollback должны опираться на versioned deploy records.
## Решение
Принимаем MVP-схему из двух data-plane компонентов и одного control-plane:
1. `PocketBase`
- отдельный сервис данных для микро-приложений;
- каждое приложение получает собственный `pocketbaseInstanceId` и URL;
- доступ из микро-приложения идёт только через разрешённый service account.
2. `Deno runner` в `gVisor`
- каждое приложение исполняется как контейнер с `runsc`;
- runtime получает лимиты по CPU/RAM/PIDs и whitelist по env/network;
- runner запускает только Deno entrypoint из шаблона микро-приложения.
3. `backend` как control-plane
- хранит `MicroAppProject`, `MicroAppRuntimeConfig`, `MicroAppDeployment`;
- выдаёт конфиг для provisioning и delivery;
- знает путь репозитория, ветку, template checksum, preview/prod URL.
## Поток
1. Агент создаёт проект только из `microapps/templates/pocketbase-deno-dashboard`.
2. Валидатор проверяет структуру репозитория и `app.config.ts`.
3. Control-plane создаёт или обновляет запись `MicroAppProject`.
4. Delivery pipeline фиксирует новую версию в `MicroAppDeployment`.
5. Runner поднимает Deno-контейнер через `gVisor`.
6. Публикация идёт через preview domain, а promotion в основной домен выполняется отдельно.
## Delivery
- source of truth: git-репозиторий приложения;
- deploy unit: versioned template-compliant app directory;
- preview создаётся автоматически после успешной валидации;
- production переключается через отдельный promote шаг;
- rollback выполняется redeploy предыдущей записи `MicroAppDeployment`.
## Домены
На первом этапе платформа хранит:
- `primaryDomain`
- `previewDomain`
Привязка доменов и DNS-автоматизация выносятся в отдельный шаг интеграции с Cloudflare и не блокируют запуск control-plane MVP.
## Последствия
Плюсы:
- появляется единый control-plane для клиентских микро-приложений;
- есть жёсткий шаблон и проверяемый runtime policy;
- база данных и execution host разделены;
- есть основа для preview/prod flow и rollback.
Минусы:
- runtime по-прежнему требует отдельного delivery automation поверх git;
- provisioning PocketBase instance пока остаётся внешней операцией;
- домены и TLS-автоматизация ещё не включены в MVP-каркас.

View File

@@ -0,0 +1,22 @@
FROM debian:bookworm-slim
ARG DENO_VERSION=2.6.4
RUN apt-get update \
&& apt-get install --yes --no-install-recommends ca-certificates curl tini unzip \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://dl.deno.land/release/v${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
&& unzip /tmp/deno.zip -d /usr/local/bin \
&& chmod +x /usr/local/bin/deno \
&& rm /tmp/deno.zip
WORKDIR /workspace
COPY microapps/runtime/entrypoint.sh /usr/local/bin/microapp-entrypoint
RUN chmod +x /usr/local/bin/microapp-entrypoint
ENV DENO_DIR=/deno-dir
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/microapp-entrypoint"]

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -eu
APP_DIR="${APP_DIR:-/workspace/app}"
ENTRYPOINT_FILE="${ENTRYPOINT_FILE:-$APP_DIR/main.ts}"
ALLOW_READ="${ALLOW_READ:-$APP_DIR}"
ALLOW_ENV="${ALLOW_ENV:-PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG}"
ALLOW_NET="${ALLOW_NET:-127.0.0.1,localhost}"
exec deno run \
--no-prompt \
--allow-read="$ALLOW_READ" \
--allow-env="$ALLOW_ENV" \
--allow-net="$ALLOW_NET" \
"$ENTRYPOINT_FILE"

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env sh
set -eu
IMAGE_NAME="${IMAGE_NAME:-clientflow/microapp-runtime}"
APP_DIR="${APP_DIR:-$(pwd)/microapps/templates/pocketbase-deno-dashboard}"
ALLOW_NET="${ALLOW_NET:-127.0.0.1,localhost}"
docker run \
--rm \
--runtime=runsc \
--memory=256m \
--cpus=0.5 \
--pids-limit=128 \
-e APP_DIR=/workspace/app \
-e ALLOW_NET="$ALLOW_NET" \
-v "$APP_DIR:/workspace/app:ro" \
"$IMAGE_NAME"

View File

@@ -0,0 +1,28 @@
export default {
manifestVersion: 1,
name: "PocketBase Deno Dashboard",
slug: "pocketbase-deno-dashboard",
runtime: "deno-gvisor",
entrypoint: "./main.ts",
pocketbase: {
collections: ["dashboard_cards"],
collectionPrefix: "dashboard",
},
permissions: {
allowEnv: [
"PORT",
"POCKETBASE_URL",
"POCKETBASE_SERVICE_EMAIL",
"POCKETBASE_SERVICE_PASSWORD",
"APP_OWNER_TEAM_ID",
"APP_SLUG",
],
allowNet: ["pocketbase.internal"],
allowRead: ["./"],
allowWrite: [],
},
libraries: [
"internal/pocketbase-client",
"internal/dashboard-ui",
],
} as const;

View File

@@ -0,0 +1,17 @@
{
"tasks": {
"dev": "deno run --watch --allow-read=. --allow-env=PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG --allow-net=127.0.0.1,localhost,pocketbase.internal main.ts",
"check": "deno check main.ts pocketbase/client.ts app.config.ts"
},
"fmt": {
"lineWidth": 100,
"singleQuote": false
},
"lint": {
"rules": {
"tags": [
"recommended"
]
}
}
}

View File

@@ -0,0 +1,162 @@
import appConfig from "./app.config.ts";
import { createPocketBaseClient } from "./pocketbase/client.ts";
const port = Number(Deno.env.get("PORT") || "8080");
const pocketbaseUrl = mustGetEnv("POCKETBASE_URL");
const pocketbaseEmail = mustGetEnv("POCKETBASE_SERVICE_EMAIL");
const pocketbasePassword = mustGetEnv("POCKETBASE_SERVICE_PASSWORD");
const ownerTeamId = mustGetEnv("APP_OWNER_TEAM_ID");
const appSlug = Deno.env.get("APP_SLUG") || appConfig.slug;
const pocketbase = createPocketBaseClient({
baseUrl: pocketbaseUrl,
email: pocketbaseEmail,
password: pocketbasePassword,
});
type DashboardCard = {
id: string;
title: string;
metric: string;
description: string | null;
};
function renderHtml(cards: DashboardCard[]) {
const cardsMarkup = cards.length === 0
? `<p class="empty">No dashboard cards yet.</p>`
: cards.map((card) => `
<article class="card">
<p class="label">${escapeHtml(card.title)}</p>
<strong>${escapeHtml(card.metric)}</strong>
<span>${escapeHtml(card.description || "No description")}</span>
</article>
`).join("");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(appConfig.name)}</title>
<style>
:root {
color-scheme: light;
--bg: #f4efe5;
--panel: #fffaf2;
--ink: #17231f;
--muted: #52615a;
--accent: #0d8a62;
--border: #d7cbb8;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(13, 138, 98, 0.2), transparent 32%),
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
color: var(--ink);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 48px 20px 80px;
}
.hero {
padding: 28px;
border: 1px solid var(--border);
border-radius: 28px;
background: rgba(255, 250, 242, 0.92);
backdrop-filter: blur(10px);
}
.eyebrow {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
color: var(--accent);
}
h1 {
margin: 0;
font-size: clamp(32px, 5vw, 56px);
line-height: 0.96;
}
.meta {
margin-top: 18px;
color: var(--muted);
font-size: 15px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 24px;
}
.card {
border-radius: 20px;
padding: 20px;
border: 1px solid rgba(23, 35, 31, 0.08);
background: var(--panel);
box-shadow: 0 10px 24px rgba(23, 35, 31, 0.08);
}
.label {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
strong {
display: block;
font-size: 34px;
margin-bottom: 8px;
}
span, .empty {
color: var(--muted);
}
</style>
</head>
<body>
<main>
<section class="hero">
<p class="eyebrow">PocketBase + Deno</p>
<h1>${escapeHtml(appConfig.name)}</h1>
<p class="meta">App slug: ${escapeHtml(appSlug)} • Team: ${escapeHtml(ownerTeamId)}</p>
</section>
<section class="grid">${cardsMarkup}</section>
</main>
</body>
</html>`;
}
function mustGetEnv(name: string) {
const value = Deno.env.get(name);
if (!value) {
throw new Error(`Missing required env: ${name}`);
}
return value;
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function handleRequest() {
const cards = await pocketbase.listRecords<DashboardCard>({
collectionName: `${appConfig.pocketbase.collectionPrefix}_cards`,
filter: `teamId = "${ownerTeamId}"`,
});
return new Response(renderHtml(cards), {
headers: {
"content-type": "text/html; charset=utf-8",
},
});
}
Deno.serve({ port }, () => handleRequest());

View File

@@ -0,0 +1,73 @@
type PocketBaseClientOptions = {
baseUrl: string;
email: string;
password: string;
};
type PocketBaseListOptions = {
collectionName: string;
filter?: string;
};
type AuthResponse = {
token: string;
};
type RecordListResponse<TRecord> = {
items: TRecord[];
};
export function createPocketBaseClient(options: PocketBaseClientOptions) {
let cachedToken: string | null = null;
async function authenticate() {
if (cachedToken) return cachedToken;
const response = await fetch(
`${options.baseUrl}/api/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
identity: options.email,
password: options.password,
}),
},
);
if (!response.ok) {
throw new Error(`PocketBase auth failed with status ${response.status}`);
}
const payload = await response.json() as AuthResponse;
cachedToken = payload.token;
return cachedToken;
}
return {
async listRecords<TRecord>(input: PocketBaseListOptions) {
const token = await authenticate();
const url = new URL(
`${options.baseUrl}/api/collections/${input.collectionName}/records`,
);
if (input.filter) {
url.searchParams.set("filter", input.filter);
}
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`PocketBase list failed with status ${response.status}`);
}
const payload = await response.json() as RecordListResponse<TRecord>;
return payload.items;
},
};
}

View File

@@ -0,0 +1,69 @@
const projectDir = Deno.args[0];
if (!projectDir) {
throw new Error("Usage: deno run --allow-read microapps/validator/validate_microapp_repo.ts <project-dir>");
}
const requiredFiles = new Set([
"app.config.ts",
"deno.json",
"main.ts",
"pocketbase/client.ts",
]);
const allowedTopLevelEntries = new Set([
"app.config.ts",
"deno.json",
"main.ts",
"pocketbase",
]);
const rootEntries = new Set<string>();
for await (const entry of Deno.readDir(projectDir)) {
rootEntries.add(entry.name);
}
for (const file of requiredFiles) {
const path = `${projectDir}/${file}`;
await Deno.stat(path);
}
for (const entry of rootEntries) {
if (!allowedTopLevelEntries.has(entry)) {
throw new Error(`Unexpected top-level entry: ${entry}`);
}
}
const configModule = await import(`file://${Deno.realPathSync(`${projectDir}/app.config.ts`)}`);
const config = configModule.default as {
manifestVersion?: number;
runtime?: string;
libraries?: string[];
permissions?: {
allowEnv?: string[];
allowNet?: string[];
};
};
if (config.manifestVersion !== 1) {
throw new Error("app.config.ts must export manifestVersion = 1");
}
if (config.runtime !== "deno-gvisor") {
throw new Error("app.config.ts must declare runtime = 'deno-gvisor'");
}
if (!Array.isArray(config.libraries) || config.libraries.length === 0) {
throw new Error("app.config.ts must declare at least one approved library");
}
if (!Array.isArray(config.permissions?.allowEnv) || config.permissions.allowEnv.length === 0) {
throw new Error("app.config.ts must declare allowEnv permissions");
}
if (!Array.isArray(config.permissions?.allowNet) || config.permissions.allowNet.length === 0) {
throw new Error("app.config.ts must declare allowNet permissions");
}
console.log(`Microapp repo at ${projectDir} matches the enforced template contract.`);

View File

@@ -0,0 +1,60 @@
---
name: pocketbase-microapp-builder
description: Use when creating or updating regulated microapps that must run on the PocketBase plus Deno gVisor platform. This skill scaffolds only from the approved template, validates the repo contract, and prepares control-plane metadata for delivery.
---
# PocketBase Microapp Builder
Build only template-compliant microapps for the internal PocketBase + Deno + gVisor platform.
## When to use
Use this skill when the user asks to:
- create a new regulated microapp;
- update an existing microapp that must stay inside the allowed platform contract;
- prepare a repo for preview/prod delivery on the microapp platform.
Do not use this skill for arbitrary apps, unrestricted package installation, or custom runtime wiring outside the approved platform.
## Approved platform contract
- Template source: `../../microapps/templates/pocketbase-deno-dashboard`
- Validator: `../../microapps/validator/validate_microapp_repo.ts`
- Runtime image: `../../microapps/runtime/Dockerfile`
- Control-plane persistence: `../../backend/src/microapps/service.ts`
The app must keep:
- `app.config.ts`
- `deno.json`
- `main.ts`
- `pocketbase/client.ts`
Only the approved top-level entries are allowed.
## Workflow
1. Copy the template from `../../microapps/templates/pocketbase-deno-dashboard`.
2. Edit only the app name, slug, collection names, UI copy, and business logic that still fits the template.
3. Keep runtime as `deno-gvisor`.
4. Keep PocketBase access through the local wrapper in `pocketbase/client.ts`.
5. Run:
```bash
deno run --allow-read ../../microapps/validator/validate_microapp_repo.ts <app-dir>
```
6. Prepare control-plane metadata:
- repository path
- branch
- PocketBase URL
- PocketBase instance id
- preview/prod domains
- allowed hosts/env keys/libraries
## Delivery expectations
- Preview should be created from a validated repo version.
- Production should be promoted from a previously validated version.
- Rollback should target a previous deployment record instead of ad hoc hotfixing in runtime.