@atomservice/config 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +37 -0
  2. package/server/.env +3 -0
  3. package/server/Containerfile +50 -0
  4. package/server/package.json +18 -0
  5. package/server/src/db/client.ts +49 -0
  6. package/server/src/db/consts.ts +3 -0
  7. package/server/src/db/migrations.ts +106 -0
  8. package/server/src/db/types.ts +101 -0
  9. package/server/src/http/response.ts +82 -0
  10. package/server/src/http/router.ts +27 -0
  11. package/server/src/main.ts +37 -0
  12. package/server/src/modules/app.ts +100 -0
  13. package/server/src/modules/env.ts +86 -0
  14. package/server/src/modules/inspect.ts +67 -0
  15. package/server/src/modules/member.ts +59 -0
  16. package/server/src/modules/project.ts +98 -0
  17. package/server/src/modules/release.ts +328 -0
  18. package/server/src/modules/token.ts +58 -0
  19. package/server/src/modules/user.ts +74 -0
  20. package/server/src/shared/auth.ts +58 -0
  21. package/server/src/shared/bootstrap.ts +29 -0
  22. package/server/src/shared/consts.ts +10 -0
  23. package/server/src/shared/env.ts +55 -0
  24. package/server/src/shared/jsonschema.ts +74 -0
  25. package/server/src/shared/password.ts +7 -0
  26. package/server/src/shared/resolve.ts +33 -0
  27. package/server/src/shared/semver.ts +33 -0
  28. package/server/src/shared/serialize.ts +84 -0
  29. package/server/src/shared/token.ts +59 -0
  30. package/server/src/shared/types.ts +25 -0
  31. package/server/src/shared/utils.ts +17 -0
  32. package/server/tsconfig.json +27 -0
  33. package/src/config.compose.ts +59 -0
  34. package/src/config.consts.ts +5 -0
  35. package/src/config.instance.ts +26 -0
  36. package/src/config.options.ts +58 -0
  37. package/src/config.service.ts +121 -0
  38. package/src/config.types.ts +3 -0
  39. package/src/index.ts +3 -0
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@atomservice/config",
3
+ "version": "0.1.5",
4
+ "description": "配置中心原子服务:基于 Bun 的高性能配置管理服务",
5
+ "type": "module",
6
+ "author": "openorson",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/openorson/atomservice.git",
10
+ "directory": "services/config"
11
+ },
12
+ "bugs": "https://github.com/openorson/atomservice/issues",
13
+ "homepage": "https://github.com/openorson/atomservice/tree/main/services/config#readme",
14
+ "keywords": [
15
+ "atomservice",
16
+ "config",
17
+ "self-hosted",
18
+ "podman",
19
+ "bun"
20
+ ],
21
+ "engines": {
22
+ "bun": ">=1.3.14"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "server"
27
+ ],
28
+ "exports": {
29
+ ".": "./src/index.ts"
30
+ },
31
+ "dependencies": {
32
+ "@atomservice/gateway": "0.1.5"
33
+ },
34
+ "peerDependencies": {
35
+ "@atomservice/core": "0.1.5"
36
+ }
37
+ }
package/server/.env ADDED
@@ -0,0 +1,3 @@
1
+ CONFIG_ADMIN_NAME=admin
2
+ CONFIG_ADMIN_EMAIL=admin@example.com
3
+ CONFIG_ADMIN_PASSWORD=admin123
@@ -0,0 +1,50 @@
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # ── Stage 1: Build standalone binary ─────────────────────────────────────────
4
+ FROM oven/bun:1 AS builder
5
+ WORKDIR /build
6
+
7
+ # Install dependencies first (cache layer)
8
+ COPY package.json ./
9
+ RUN bun install
10
+
11
+ # Copy source and compile
12
+ COPY tsconfig.json ./
13
+ COPY src/ ./src/
14
+ RUN bun build --compile --minify --sourcemap --bytecode \
15
+ --target=bun-linux-x64 \
16
+ ./src/main.ts \
17
+ --outfile ./dist/config-server
18
+
19
+ # ── Stage 2: Minimal runtime image ───────────────────────────────────────────
20
+ FROM debian:bookworm-slim
21
+
22
+ # Install curl for HEALTHCHECK (binary is ~100MB anyway, curl adds negligible overhead)
23
+ RUN apt-get update \
24
+ && apt-get install -y --no-install-recommends curl \
25
+ && rm -rf /var/lib/apt/lists/*
26
+
27
+ # Create non-root user
28
+ RUN groupadd --system --gid 1001 app \
29
+ && useradd --system --uid 1001 --gid app --no-create-home app
30
+
31
+ WORKDIR /app
32
+
33
+ # Copy compiled binary
34
+ COPY --from=builder --chown=app:app /build/dist/config-server ./config-server
35
+
36
+ # Prepare data directory (mount a volume here for persistence)
37
+ RUN mkdir -p /data && chown app:app /data
38
+
39
+ USER app
40
+
41
+ # Default data path — compose will bind-mount host:${containerName}/data here
42
+ ENV CONFIG_DB_PATH=/data/config.db
43
+ VOLUME ["/data"]
44
+ EXPOSE 4000
45
+
46
+ # Health check against /v1/health; respects CONFIG_PORT if overridden
47
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
48
+ CMD curl -sf http://localhost:${CONFIG_PORT:-4000}/v1/health
49
+
50
+ CMD ["./config-server"]
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@atomservice/config-server",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "bun --watch src/main.ts",
7
+ "start": "bun src/main.ts",
8
+ "build": "bun build --compile --minify --sourcemap --bytecode src/main.ts --outfile dist/config-server",
9
+ "build:linux-x64": "bun build --compile --minify --sourcemap --bytecode --target=bun-linux-x64 src/main.ts --outfile dist/config-server",
10
+ "build:linux-arm64": "bun build --compile --minify --sourcemap --bytecode --target=bun-linux-arm64 src/main.ts --outfile dist/config-server"
11
+ },
12
+ "dependencies": {
13
+ "typebox": "1.1.39"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^6"
17
+ }
18
+ }
@@ -0,0 +1,49 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { mkdirSync } from "node:fs"
3
+ import path from "node:path"
4
+ import { getEnv } from "../shared/env.ts"
5
+ import { DB_PRAGMAS, MIGRATIONS_TABLE } from "./consts.ts"
6
+ import { migrations } from "./migrations.ts"
7
+
8
+ let database: Database | null = null
9
+
10
+ export function openDb(): Database {
11
+ if (database) return database
12
+
13
+ const dbPath = getEnv().dbPath
14
+ mkdirSync(path.dirname(dbPath), { recursive: true })
15
+
16
+ database = new Database(dbPath, { create: true, strict: true })
17
+ for (const pragma of DB_PRAGMAS) database.run(pragma)
18
+
19
+ return database
20
+ }
21
+
22
+ export function getDb(): Database {
23
+ if (!database) throw new Error("database not opened, call openDb() first")
24
+ return database
25
+ }
26
+
27
+ export function closeDb(): void {
28
+ if (!database) return
29
+ database.close(false)
30
+ database = null
31
+ }
32
+
33
+ export function migrate(): void {
34
+ const db = getDb()
35
+ db.run(`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`)
36
+
37
+ const rows = db.query(`SELECT id FROM ${MIGRATIONS_TABLE}`).all() as { id: string }[]
38
+ const applied = new Set(rows.map((r) => r.id))
39
+ const insert = db.query(`INSERT INTO ${MIGRATIONS_TABLE} (id, applied_at) VALUES (?, ?)`)
40
+
41
+ for (const m of migrations) {
42
+ if (applied.has(m.id)) continue
43
+ const apply = db.transaction(() => {
44
+ db.run(m.up)
45
+ insert.run(m.id, Date.now())
46
+ })
47
+ apply()
48
+ }
49
+ }
@@ -0,0 +1,3 @@
1
+ export const DB_PRAGMAS = ["PRAGMA journal_mode = WAL", "PRAGMA foreign_keys = ON", "PRAGMA busy_timeout = 5000"]
2
+
3
+ export const MIGRATIONS_TABLE = "_migrations"
@@ -0,0 +1,106 @@
1
+ import type { Migration } from "./types.ts"
2
+
3
+ export const migrations: Migration[] = [
4
+ {
5
+ id: "001_init",
6
+ up: `
7
+ CREATE TABLE "User" (
8
+ id TEXT PRIMARY KEY,
9
+ email TEXT NOT NULL UNIQUE,
10
+ name TEXT NOT NULL,
11
+ passwordHash TEXT NOT NULL,
12
+ isSuperAdmin INTEGER NOT NULL DEFAULT 0,
13
+ createdAt INTEGER NOT NULL DEFAULT (unixepoch()),
14
+ updatedAt INTEGER NOT NULL DEFAULT (unixepoch())
15
+ );
16
+
17
+ CREATE TABLE "Token" (
18
+ id TEXT PRIMARY KEY,
19
+ userId TEXT NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
20
+ name TEXT,
21
+ tokenHash TEXT NOT NULL UNIQUE,
22
+ tokenPrefix TEXT NOT NULL,
23
+ expiresAt INTEGER,
24
+ lastUsedAt INTEGER,
25
+ revoked INTEGER NOT NULL DEFAULT 0,
26
+ createdAt INTEGER NOT NULL DEFAULT (unixepoch())
27
+ );
28
+ CREATE INDEX tokenUserIdx ON "Token"(userId);
29
+
30
+ CREATE TABLE "Project" (
31
+ id TEXT PRIMARY KEY,
32
+ slug TEXT NOT NULL UNIQUE,
33
+ name TEXT NOT NULL,
34
+ description TEXT,
35
+ createdBy TEXT NOT NULL REFERENCES "User"(id),
36
+ createdAt INTEGER NOT NULL DEFAULT (unixepoch()),
37
+ updatedAt INTEGER NOT NULL DEFAULT (unixepoch())
38
+ );
39
+
40
+ CREATE TABLE "ProjectMember" (
41
+ projectId TEXT NOT NULL REFERENCES "Project"(id) ON DELETE CASCADE,
42
+ userId TEXT NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
43
+ role TEXT NOT NULL,
44
+ joinedAt INTEGER NOT NULL DEFAULT (unixepoch()),
45
+ PRIMARY KEY (projectId, userId)
46
+ );
47
+
48
+ CREATE TABLE "Environment" (
49
+ id TEXT PRIMARY KEY,
50
+ projectId TEXT NOT NULL REFERENCES "Project"(id) ON DELETE CASCADE,
51
+ slug TEXT NOT NULL,
52
+ name TEXT NOT NULL,
53
+ description TEXT,
54
+ sortOrder INTEGER NOT NULL DEFAULT 0,
55
+ sensitive INTEGER NOT NULL DEFAULT 0,
56
+ createdBy TEXT NOT NULL REFERENCES "User"(id),
57
+ createdAt INTEGER NOT NULL DEFAULT (unixepoch()),
58
+ updatedAt INTEGER NOT NULL DEFAULT (unixepoch()),
59
+ UNIQUE (projectId, slug)
60
+ );
61
+
62
+ CREATE TABLE "App" (
63
+ id TEXT PRIMARY KEY,
64
+ projectId TEXT NOT NULL REFERENCES "Project"(id) ON DELETE CASCADE,
65
+ slug TEXT NOT NULL,
66
+ name TEXT NOT NULL,
67
+ description TEXT,
68
+ schema TEXT NOT NULL,
69
+ createdBy TEXT NOT NULL REFERENCES "User"(id),
70
+ createdAt INTEGER NOT NULL DEFAULT (unixepoch()),
71
+ updatedAt INTEGER NOT NULL DEFAULT (unixepoch()),
72
+ UNIQUE (projectId, slug)
73
+ );
74
+
75
+ CREATE TABLE "Release" (
76
+ id TEXT PRIMARY KEY,
77
+ appId TEXT NOT NULL REFERENCES "App"(id) ON DELETE CASCADE,
78
+ environmentId TEXT NOT NULL REFERENCES "Environment"(id) ON DELETE CASCADE,
79
+ semver TEXT NOT NULL,
80
+ changeKind TEXT NOT NULL,
81
+ status TEXT NOT NULL DEFAULT 'unpublished',
82
+ notes TEXT,
83
+ snapshot TEXT NOT NULL,
84
+ schemaSnapshot TEXT NOT NULL,
85
+ createdBy TEXT NOT NULL REFERENCES "User"(id),
86
+ createdAt INTEGER NOT NULL DEFAULT (unixepoch()),
87
+ UNIQUE (appId, environmentId, semver)
88
+ );
89
+ CREATE INDEX releaseLookupIdx ON "Release"(appId, environmentId, createdAt);
90
+
91
+ CREATE TABLE "AppToken" (
92
+ id TEXT PRIMARY KEY,
93
+ appId TEXT NOT NULL REFERENCES "App"(id) ON DELETE CASCADE,
94
+ environmentId TEXT NOT NULL REFERENCES "Environment"(id) ON DELETE CASCADE,
95
+ name TEXT,
96
+ tokenHash TEXT NOT NULL UNIQUE,
97
+ tokenPrefix TEXT NOT NULL,
98
+ revoked INTEGER NOT NULL DEFAULT 0,
99
+ lastUsedAt INTEGER,
100
+ createdBy TEXT NOT NULL REFERENCES "User"(id),
101
+ createdAt INTEGER NOT NULL DEFAULT (unixepoch())
102
+ );
103
+ CREATE INDEX appTokenLookupIdx ON "AppToken"(appId, environmentId);
104
+ `,
105
+ },
106
+ ]
@@ -0,0 +1,101 @@
1
+ export interface Migration {
2
+ id: string
3
+ up: string
4
+ }
5
+
6
+ export type ProjectRole = "admin" | "collaborator" | "viewer"
7
+
8
+ export type ChangeKind = "major" | "minor" | "patch"
9
+
10
+ export type ReleaseStatus = "unpublished" | "published"
11
+
12
+ export interface UserRow {
13
+ id: string
14
+ email: string
15
+ name: string
16
+ passwordHash: string
17
+ isSuperAdmin: number
18
+ createdAt: number
19
+ updatedAt: number
20
+ }
21
+
22
+ export interface TokenRow {
23
+ id: string
24
+ userId: string
25
+ name: string | null
26
+ tokenHash: string
27
+ tokenPrefix: string
28
+ expiresAt: number | null
29
+ lastUsedAt: number | null
30
+ revoked: number
31
+ createdAt: number
32
+ }
33
+
34
+ export interface ProjectRow {
35
+ id: string
36
+ slug: string
37
+ name: string
38
+ description: string | null
39
+ createdBy: string
40
+ createdAt: number
41
+ updatedAt: number
42
+ }
43
+
44
+ export interface ProjectMemberRow {
45
+ projectId: string
46
+ userId: string
47
+ role: ProjectRole
48
+ joinedAt: number
49
+ }
50
+
51
+ export interface EnvironmentRow {
52
+ id: string
53
+ projectId: string
54
+ slug: string
55
+ name: string
56
+ description: string | null
57
+ sortOrder: number
58
+ sensitive: number
59
+ createdBy: string
60
+ createdAt: number
61
+ updatedAt: number
62
+ }
63
+
64
+ export interface AppRow {
65
+ id: string
66
+ projectId: string
67
+ slug: string
68
+ name: string
69
+ description: string | null
70
+ schema: string
71
+ createdBy: string
72
+ createdAt: number
73
+ updatedAt: number
74
+ }
75
+
76
+ export interface ReleaseRow {
77
+ id: string
78
+ appId: string
79
+ environmentId: string
80
+ semver: string
81
+ changeKind: ChangeKind
82
+ status: ReleaseStatus
83
+ notes: string | null
84
+ snapshot: string
85
+ schemaSnapshot: string
86
+ createdBy: string
87
+ createdAt: number
88
+ }
89
+
90
+ export interface AppTokenRow {
91
+ id: string
92
+ appId: string
93
+ environmentId: string
94
+ name: string | null
95
+ tokenHash: string
96
+ tokenPrefix: string
97
+ revoked: number
98
+ lastUsedAt: number | null
99
+ createdBy: string
100
+ createdAt: number
101
+ }
@@ -0,0 +1,82 @@
1
+ import type { Server } from "bun"
2
+
3
+ export class HttpError extends Error {
4
+ statusCode: number
5
+ code: string
6
+
7
+ constructor(statusCode: number, code: string, message: string) {
8
+ super(message)
9
+ this.statusCode = statusCode
10
+ this.code = code
11
+ }
12
+ }
13
+
14
+ export function ok(data: unknown, status = 200): Response {
15
+ return Response.json(data, { status })
16
+ }
17
+
18
+ export function params(req: Request): Record<string, string> {
19
+ return (req as unknown as { params?: Record<string, string> }).params ?? {}
20
+ }
21
+
22
+ export function param(req: Request, key: string): string {
23
+ const value = params(req)[key]
24
+ if (value === undefined || value === "") throw new HttpError(404, "not_found", "缺少路径参数")
25
+ return value
26
+ }
27
+
28
+ export function fail(status: number, code: string, message: string): Response {
29
+ return Response.json({ error: { code, message } }, { status })
30
+ }
31
+
32
+ export function route(handler: (req: Request) => Response | Promise<Response>) {
33
+ return async (req: Request): Promise<Response> => {
34
+ try {
35
+ return await handler(req)
36
+ } catch (err) {
37
+ if (err instanceof HttpError) return fail(err.statusCode, err.code, err.message)
38
+ console.error(err)
39
+ return fail(500, "internal", "服务器内部错误")
40
+ }
41
+ }
42
+ }
43
+
44
+ export async function readBody(req: Request): Promise<Record<string, unknown>> {
45
+ let raw: unknown
46
+ try {
47
+ raw = await req.json()
48
+ } catch {
49
+ throw new HttpError(400, "invalid_json", "请求体不是合法的 JSON")
50
+ }
51
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
52
+ throw new HttpError(400, "invalid_body", "请求体必须是 JSON 对象")
53
+ }
54
+ return raw as Record<string, unknown>
55
+ }
56
+
57
+ export function requireString(body: Record<string, unknown>, key: string, label: string): string {
58
+ const value = body[key]
59
+ if (typeof value !== "string" || value.trim() === "") {
60
+ throw new HttpError(400, "invalid_field", `缺少必填字段:${label}`)
61
+ }
62
+ return value.trim()
63
+ }
64
+
65
+ export function optionalString(body: Record<string, unknown>, key: string): string | undefined {
66
+ const value = body[key]
67
+ if (value === undefined || value === null) return undefined
68
+ if (typeof value !== "string") throw new HttpError(400, "invalid_field", `字段 ${key} 必须是字符串`)
69
+ return value.trim()
70
+ }
71
+
72
+ export function sseRoute(handler: (req: Request, server: Server<undefined>) => Response) {
73
+ return (req: Request, server: Server<undefined>): Response => {
74
+ try {
75
+ return handler(req, server)
76
+ } catch (err) {
77
+ if (err instanceof HttpError) return fail(err.statusCode, err.code, err.message)
78
+ console.error(err)
79
+ return fail(500, "internal", "服务器内部错误")
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,27 @@
1
+ import { getDb } from "../db/client.ts"
2
+ import { appRoutes } from "../modules/app.ts"
3
+ import { envRoutes } from "../modules/env.ts"
4
+ import { inspectRoutes } from "../modules/inspect.ts"
5
+ import { memberRoutes } from "../modules/member.ts"
6
+ import { projectRoutes } from "../modules/project.ts"
7
+ import { releaseRoutes } from "../modules/release.ts"
8
+ import { tokenRoutes } from "../modules/token.ts"
9
+ import { userRoutes } from "../modules/user.ts"
10
+ import { ok, route } from "./response.ts"
11
+
12
+ function health(): Response {
13
+ getDb().query(`SELECT 1`).get()
14
+ return ok({ status: "ok" })
15
+ }
16
+
17
+ export const routes = {
18
+ "/v1/health": { GET: route(health) },
19
+ ...userRoutes,
20
+ ...projectRoutes,
21
+ ...memberRoutes,
22
+ ...envRoutes,
23
+ ...appRoutes,
24
+ ...inspectRoutes,
25
+ ...releaseRoutes,
26
+ ...tokenRoutes,
27
+ }
@@ -0,0 +1,37 @@
1
+ import { closeDb, migrate, openDb } from "./db/client.ts"
2
+ import { routes } from "./http/router.ts"
3
+ import { bootstrapAdmin } from "./shared/bootstrap.ts"
4
+ import { getEnv, validateEnv } from "./shared/env.ts"
5
+
6
+ async function main(): Promise<void> {
7
+ validateEnv()
8
+
9
+ openDb()
10
+
11
+ migrate()
12
+
13
+ await bootstrapAdmin()
14
+
15
+ const port = getEnv().port
16
+
17
+ const server = Bun.serve({
18
+ port,
19
+ routes,
20
+ fetch() {
21
+ return Response.json({ error: { code: "not_found", message: "未找到该接口" } }, { status: 404 })
22
+ },
23
+ })
24
+
25
+ console.log(`config-server 已启动 http://localhost:${server.port}`)
26
+
27
+ const shutdown = () => {
28
+ server.stop()
29
+ closeDb()
30
+ process.exit(0)
31
+ }
32
+
33
+ process.on("SIGTERM", shutdown)
34
+ process.on("SIGINT", shutdown)
35
+ }
36
+
37
+ main()
@@ -0,0 +1,100 @@
1
+ import { getDb } from "../db/client.ts"
2
+ import type { AppRow } from "../db/types.ts"
3
+ import { HttpError, ok, optionalString, param, readBody, requireString, route } from "../http/response.ts"
4
+ import { requireProjectRole, requireUser } from "../shared/auth.ts"
5
+ import { SLUG_RE } from "../shared/consts.ts"
6
+ import { findApp, findProject } from "../shared/resolve.ts"
7
+ import { publicApp } from "../shared/serialize.ts"
8
+ import { isPlainObject, newId, nowSec } from "../shared/utils.ts"
9
+
10
+ function parseSchema(body: Record<string, unknown>): string {
11
+ if (body.schema === undefined || body.schema === null) return "{}"
12
+ if (!isPlainObject(body.schema)) throw new HttpError(400, "invalid_field", "schema 必须是 JSON Schema 对象")
13
+ return JSON.stringify(body.schema)
14
+ }
15
+
16
+ function requireSchema(body: Record<string, unknown>): string {
17
+ if (body.schema === undefined || body.schema === null)
18
+ throw new HttpError(400, "missing_field", "应用必须提供 schema")
19
+ if (!isPlainObject(body.schema)) throw new HttpError(400, "invalid_field", "schema 必须是 JSON Schema 对象")
20
+ return JSON.stringify(body.schema)
21
+ }
22
+
23
+ function listApps(req: Request): Response {
24
+ const user = requireUser(req)
25
+ const project = findProject(param(req, "project"))
26
+ requireProjectRole(user, project.id, "viewer")
27
+ const rows = getDb().query(`SELECT * FROM "App" WHERE projectId = ? ORDER BY createdAt`).all(project.id) as AppRow[]
28
+ return ok({ apps: rows.map(publicApp) })
29
+ }
30
+
31
+ async function createApp(req: Request): Promise<Response> {
32
+ const user = requireUser(req)
33
+ const project = findProject(param(req, "project"))
34
+ requireProjectRole(user, project.id, "admin")
35
+ const body = await readBody(req)
36
+ const slug = requireString(body, "slug", "应用标识")
37
+ const name = requireString(body, "name", "应用名称")
38
+ const description = optionalString(body, "description") ?? null
39
+ const schema = requireSchema(body)
40
+
41
+ if (!SLUG_RE.test(slug))
42
+ throw new HttpError(400, "invalid_field", "应用标识需以小写字母开头,仅含小写字母、数字、连字符")
43
+
44
+ const db = getDb()
45
+ if (db.query(`SELECT 1 FROM "App" WHERE projectId = ? AND slug = ?`).get(project.id, slug)) {
46
+ throw new HttpError(409, "conflict", "应用标识已存在")
47
+ }
48
+
49
+ const id = newId()
50
+ db.query(
51
+ `INSERT INTO "App" (id, projectId, slug, name, description, schema, createdBy) VALUES (?, ?, ?, ?, ?, ?, ?)`,
52
+ ).run(id, project.id, slug, name, description, schema, user.id)
53
+ const created = db.query(`SELECT * FROM "App" WHERE id = ?`).get(id) as AppRow
54
+ return ok({ app: publicApp(created) }, 201)
55
+ }
56
+
57
+ async function updateApp(req: Request): Promise<Response> {
58
+ const user = requireUser(req)
59
+ const project = findProject(param(req, "project"))
60
+ requireProjectRole(user, project.id, "admin")
61
+ const app = findApp(project.id, param(req, "app"))
62
+ const body = await readBody(req)
63
+ const name = optionalString(body, "name") ?? app.name
64
+ const description = body.description === undefined ? app.description : (optionalString(body, "description") ?? null)
65
+ const schema = body.schema === undefined ? app.schema : parseSchema(body)
66
+
67
+ const db = getDb()
68
+ db.query(`UPDATE "App" SET name = ?, description = ?, schema = ?, updatedAt = ? WHERE id = ?`).run(
69
+ name,
70
+ description,
71
+ schema,
72
+ nowSec(),
73
+ app.id,
74
+ )
75
+ const updated = db.query(`SELECT * FROM "App" WHERE id = ?`).get(app.id) as AppRow
76
+ return ok({ app: publicApp(updated) })
77
+ }
78
+
79
+ function deleteApp(req: Request): Response {
80
+ const user = requireUser(req)
81
+ const project = findProject(param(req, "project"))
82
+ requireProjectRole(user, project.id, "admin")
83
+ const app = findApp(project.id, param(req, "app"))
84
+ getDb().query(`DELETE FROM "App" WHERE id = ?`).run(app.id)
85
+ return ok({ deleted: true })
86
+ }
87
+
88
+ function getSchema(req: Request): Response {
89
+ const user = requireUser(req)
90
+ const project = findProject(param(req, "project"))
91
+ requireProjectRole(user, project.id, "viewer")
92
+ const app = findApp(project.id, param(req, "app"))
93
+ return ok({ schema: app.schema ? JSON.parse(app.schema) : null })
94
+ }
95
+
96
+ export const appRoutes = {
97
+ "/v1/projects/:project/apps": { GET: route(listApps), POST: route(createApp) },
98
+ "/v1/projects/:project/apps/:app": { PATCH: route(updateApp), DELETE: route(deleteApp) },
99
+ "/v1/projects/:project/apps/:app/schema": { GET: route(getSchema) },
100
+ }