@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.
- package/package.json +37 -0
- package/server/.env +3 -0
- package/server/Containerfile +50 -0
- package/server/package.json +18 -0
- package/server/src/db/client.ts +49 -0
- package/server/src/db/consts.ts +3 -0
- package/server/src/db/migrations.ts +106 -0
- package/server/src/db/types.ts +101 -0
- package/server/src/http/response.ts +82 -0
- package/server/src/http/router.ts +27 -0
- package/server/src/main.ts +37 -0
- package/server/src/modules/app.ts +100 -0
- package/server/src/modules/env.ts +86 -0
- package/server/src/modules/inspect.ts +67 -0
- package/server/src/modules/member.ts +59 -0
- package/server/src/modules/project.ts +98 -0
- package/server/src/modules/release.ts +328 -0
- package/server/src/modules/token.ts +58 -0
- package/server/src/modules/user.ts +74 -0
- package/server/src/shared/auth.ts +58 -0
- package/server/src/shared/bootstrap.ts +29 -0
- package/server/src/shared/consts.ts +10 -0
- package/server/src/shared/env.ts +55 -0
- package/server/src/shared/jsonschema.ts +74 -0
- package/server/src/shared/password.ts +7 -0
- package/server/src/shared/resolve.ts +33 -0
- package/server/src/shared/semver.ts +33 -0
- package/server/src/shared/serialize.ts +84 -0
- package/server/src/shared/token.ts +59 -0
- package/server/src/shared/types.ts +25 -0
- package/server/src/shared/utils.ts +17 -0
- package/server/tsconfig.json +27 -0
- package/src/config.compose.ts +59 -0
- package/src/config.consts.ts +5 -0
- package/src/config.instance.ts +26 -0
- package/src/config.options.ts +58 -0
- package/src/config.service.ts +121 -0
- package/src/config.types.ts +3 -0
- 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,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,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
|
+
}
|