@atomservice/config 0.1.15 → 0.1.17
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 +3 -3
- package/server/Containerfile +2 -11
- package/server/src/db/migrations.ts +3 -3
- package/server/src/db/types.ts +1 -1
- package/server/src/main.ts +3 -5
- package/server/src/modules/app.ts +9 -1
- package/server/src/modules/env.ts +9 -1
- package/server/src/modules/inspect.ts +3 -3
- package/server/src/modules/release.ts +20 -20
- package/server/src/modules/setup.ts +7 -1
- package/server/src/modules/token.ts +12 -12
- package/server/src/shared/auth.ts +4 -4
- package/server/src/shared/consts.ts +1 -1
- package/server/src/shared/env.ts +1 -7
- package/server/src/shared/serialize.ts +9 -6
- package/server/src/shared/token.ts +10 -10
- package/src/config.compose.ts +9 -27
- package/src/config.consts.ts +2 -2
- package/src/config.instance.ts +1 -1
- package/src/config.options.ts +15 -18
- package/src/config.service.ts +16 -17
- package/src/config.types.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atomservice/config",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "配置中心原子服务:基于 Bun 的高性能配置管理服务",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "openorson",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
".": "./src/index.ts"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@atomservice/gateway": "0.1.
|
|
32
|
+
"@atomservice/gateway": "0.1.17"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@atomservice/core": "0.1.
|
|
35
|
+
"@atomservice/core": "0.1.17"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/server/Containerfile
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
# syntax=docker/dockerfile:1
|
|
2
2
|
|
|
3
|
-
# ── Stage 1: Build standalone binary ─────────────────────────────────────────
|
|
4
3
|
FROM oven/bun:1 AS builder
|
|
5
4
|
WORKDIR /build
|
|
6
5
|
|
|
7
|
-
# Install dependencies first (cache layer)
|
|
8
6
|
ARG NPM_REGISTRY=https://registry.npmjs.org
|
|
9
7
|
COPY package.json ./
|
|
10
8
|
RUN bun install --registry ${NPM_REGISTRY}
|
|
11
9
|
|
|
12
|
-
# Copy source and compile
|
|
13
10
|
COPY tsconfig.json ./
|
|
14
11
|
COPY src/ ./src/
|
|
15
12
|
RUN bun build --compile --minify --sourcemap --bytecode \
|
|
@@ -17,34 +14,28 @@ RUN bun build --compile --minify --sourcemap --bytecode \
|
|
|
17
14
|
./src/main.ts \
|
|
18
15
|
--outfile ./dist/config-server
|
|
19
16
|
|
|
20
|
-
# ── Stage 2: Minimal runtime image ───────────────────────────────────────────
|
|
21
17
|
FROM debian:bookworm-slim
|
|
22
18
|
|
|
23
|
-
# Optionally switch apt mirror (pass --build-arg APT_MIRROR=mirrors.aliyun.com for China)
|
|
24
19
|
ARG APT_MIRROR=deb.debian.org
|
|
25
20
|
RUN if [ "${APT_MIRROR}" != "deb.debian.org" ]; then \
|
|
26
21
|
sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources; \
|
|
27
22
|
fi
|
|
28
23
|
|
|
29
|
-
# Install curl for HEALTHCHECK (binary is ~100MB anyway, curl adds negligible overhead)
|
|
30
24
|
RUN apt-get update \
|
|
31
25
|
&& apt-get install -y --no-install-recommends curl \
|
|
32
26
|
&& rm -rf /var/lib/apt/lists/*
|
|
33
27
|
|
|
34
28
|
WORKDIR /app
|
|
35
29
|
|
|
36
|
-
# Copy compiled binary
|
|
37
30
|
COPY --from=builder /build/dist/config-server ./config-server
|
|
38
31
|
|
|
39
|
-
# Prepare data directory (mount a volume here for persistence)
|
|
40
32
|
RUN mkdir -p /data
|
|
41
33
|
|
|
42
34
|
ENV CONFIG_DB_PATH=/data/config.db
|
|
43
35
|
VOLUME ["/data"]
|
|
44
|
-
EXPOSE
|
|
36
|
+
EXPOSE 52010
|
|
45
37
|
|
|
46
|
-
# Health check against /v1/health; respects CONFIG_PORT if overridden
|
|
47
38
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|
48
|
-
CMD curl -sf http://localhost
|
|
39
|
+
CMD curl -sf http://localhost:52010/v1/health
|
|
49
40
|
|
|
50
41
|
CMD ["./config-server"]
|
|
@@ -114,9 +114,9 @@ export const migrations: Migration[] = [
|
|
|
114
114
|
`,
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
|
-
id: "
|
|
117
|
+
id: "008_access_tokens",
|
|
118
118
|
up: `
|
|
119
|
-
CREATE TABLE "
|
|
119
|
+
CREATE TABLE "AccessToken" (
|
|
120
120
|
"id" TEXT PRIMARY KEY,
|
|
121
121
|
"appId" TEXT NOT NULL REFERENCES "App"("id") ON DELETE CASCADE,
|
|
122
122
|
"environmentId" TEXT NOT NULL REFERENCES "Environment"("id") ON DELETE CASCADE,
|
|
@@ -128,7 +128,7 @@ export const migrations: Migration[] = [
|
|
|
128
128
|
"createdBy" TEXT NOT NULL REFERENCES "User"("id"),
|
|
129
129
|
"createdAt" INTEGER NOT NULL DEFAULT (unixepoch())
|
|
130
130
|
);
|
|
131
|
-
CREATE INDEX
|
|
131
|
+
CREATE INDEX access_token_lookup_idx ON "AccessToken"("appId", "environmentId");
|
|
132
132
|
`,
|
|
133
133
|
},
|
|
134
134
|
]
|
package/server/src/db/types.ts
CHANGED
package/server/src/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { closeDb, migrate, openDb } from "./db/client.ts"
|
|
2
2
|
import { routes } from "./http/router.ts"
|
|
3
3
|
import { bootstrapAdmin } from "./shared/bootstrap.ts"
|
|
4
|
-
import {
|
|
4
|
+
import { validateEnv } from "./shared/env.ts"
|
|
5
5
|
|
|
6
6
|
async function main(): Promise<void> {
|
|
7
7
|
validateEnv()
|
|
@@ -12,17 +12,15 @@ async function main(): Promise<void> {
|
|
|
12
12
|
|
|
13
13
|
await bootstrapAdmin()
|
|
14
14
|
|
|
15
|
-
const port = getEnv().port
|
|
16
|
-
|
|
17
15
|
const server = Bun.serve({
|
|
18
|
-
port,
|
|
16
|
+
port: 52010,
|
|
19
17
|
routes,
|
|
20
18
|
fetch() {
|
|
21
19
|
return Response.json({ error: { code: "not_found", message: "未找到该接口" } }, { status: 404 })
|
|
22
20
|
},
|
|
23
21
|
})
|
|
24
22
|
|
|
25
|
-
console.log(
|
|
23
|
+
console.log(`原子配置服务 已启动 http://localhost:${server.port}`)
|
|
26
24
|
|
|
27
25
|
const shutdown = () => {
|
|
28
26
|
server.stop()
|
|
@@ -76,6 +76,14 @@ async function updateApp(req: Request): Promise<Response> {
|
|
|
76
76
|
return ok({ app: publicApp(updated) })
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function getApp(req: Request): Response {
|
|
80
|
+
const user = requireUser(req)
|
|
81
|
+
const project = findProject(param(req, "project"))
|
|
82
|
+
requireProjectRole(user, project.id, "viewer")
|
|
83
|
+
const app = findApp(project.id, param(req, "app"))
|
|
84
|
+
return ok({ app: publicApp(app) })
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
function deleteApp(req: Request): Response {
|
|
80
88
|
const user = requireUser(req)
|
|
81
89
|
const project = findProject(param(req, "project"))
|
|
@@ -95,6 +103,6 @@ function getSchema(req: Request): Response {
|
|
|
95
103
|
|
|
96
104
|
export const appRoutes = {
|
|
97
105
|
"/v1/projects/:project/apps": { GET: route(listApps), POST: route(createApp) },
|
|
98
|
-
"/v1/projects/:project/apps/:app": { PATCH: route(updateApp), DELETE: route(deleteApp) },
|
|
106
|
+
"/v1/projects/:project/apps/:app": { GET: route(getApp), PATCH: route(updateApp), DELETE: route(deleteApp) },
|
|
99
107
|
"/v1/projects/:project/apps/:app/schema": { GET: route(getSchema) },
|
|
100
108
|
}
|
|
@@ -70,6 +70,14 @@ async function updateEnv(req: Request): Promise<Response> {
|
|
|
70
70
|
return ok({ environment: publicEnv(updated) })
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function getEnv(req: Request): Response {
|
|
74
|
+
const user = requireUser(req)
|
|
75
|
+
const project = findProject(param(req, "project"))
|
|
76
|
+
requireProjectRole(user, project.id, "viewer")
|
|
77
|
+
const env = findEnv(project.id, param(req, "env"))
|
|
78
|
+
return ok({ environment: publicEnv(env) })
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
function deleteEnv(req: Request): Response {
|
|
74
82
|
const user = requireUser(req)
|
|
75
83
|
const project = findProject(param(req, "project"))
|
|
@@ -82,5 +90,5 @@ function deleteEnv(req: Request): Response {
|
|
|
82
90
|
|
|
83
91
|
export const envRoutes = {
|
|
84
92
|
"/v1/projects/:project/envs": { GET: route(listEnvs), POST: route(createEnv) },
|
|
85
|
-
"/v1/projects/:project/envs/:env": { PATCH: route(updateEnv), DELETE: route(deleteEnv) },
|
|
93
|
+
"/v1/projects/:project/envs/:env": { GET: route(getEnv), PATCH: route(updateEnv), DELETE: route(deleteEnv) },
|
|
86
94
|
}
|
|
@@ -3,7 +3,7 @@ import type { AppRow, EnvironmentRow, ReleaseRow } from "../db/types.ts"
|
|
|
3
3
|
import { ok, param, route } from "../http/response.ts"
|
|
4
4
|
import { requireProjectRole, requireUser } from "../shared/auth.ts"
|
|
5
5
|
import { findProject } from "../shared/resolve.ts"
|
|
6
|
-
import { publicApp, publicEnv, publicProject,
|
|
6
|
+
import { publicApp, publicEnv, publicProject, publicReleaseDetail } from "../shared/serialize.ts"
|
|
7
7
|
|
|
8
8
|
function inspectProject(req: Request): Response {
|
|
9
9
|
const user = requireUser(req)
|
|
@@ -42,14 +42,14 @@ function inspectProject(req: Request): Response {
|
|
|
42
42
|
envRows = envRows.filter((e) => slugs.has(e.slug))
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
const releases: Array<{ app: string; env: string; releases: ReturnType<typeof
|
|
45
|
+
const releases: Array<{ app: string; env: string; releases: ReturnType<typeof publicReleaseDetail>[] }> = []
|
|
46
46
|
for (const app of appRows) {
|
|
47
47
|
for (const env of envRows) {
|
|
48
48
|
const rows = db
|
|
49
49
|
.query(`SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? ORDER BY createdAt DESC LIMIT ?`)
|
|
50
50
|
.all(app.id, env.id, limit) as ReleaseRow[]
|
|
51
51
|
if (rows.length > 0) {
|
|
52
|
-
releases.push({ app: app.slug, env: env.slug, releases: rows.map((r) =>
|
|
52
|
+
releases.push({ app: app.slug, env: env.slug, releases: rows.map((r) => publicReleaseDetail(r, true)) })
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -6,8 +6,8 @@ import { assertEnvAccess, requireAppPrincipal, requireProjectRole, requireUser }
|
|
|
6
6
|
import { validateAgainstSchema } from "../shared/jsonschema.ts"
|
|
7
7
|
import { findApp, findEnv, findProject } from "../shared/resolve.ts"
|
|
8
8
|
import { bumpSemver, diffChangeKind } from "../shared/semver.ts"
|
|
9
|
-
import { publicRelease } from "../shared/serialize.ts"
|
|
10
|
-
import {
|
|
9
|
+
import { publicRelease, publicReleaseDetail } from "../shared/serialize.ts"
|
|
10
|
+
import { isAccessToken, isHumanToken, verifyAccessToken, verifyHumanToken } from "../shared/token.ts"
|
|
11
11
|
import type { AuthUser } from "../shared/types.ts"
|
|
12
12
|
import { isPlainObject, newId } from "../shared/utils.ts"
|
|
13
13
|
|
|
@@ -39,7 +39,7 @@ interface Target {
|
|
|
39
39
|
project: ProjectRow
|
|
40
40
|
app: AppRow
|
|
41
41
|
env: EnvironmentRow
|
|
42
|
-
|
|
42
|
+
accessToken: boolean
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function authorizeRead(req: Request): Target {
|
|
@@ -50,18 +50,18 @@ function authorizeRead(req: Request): Target {
|
|
|
50
50
|
const header = req.headers.get("authorization") ?? ""
|
|
51
51
|
const token = header.replace(/^Bearer\s+/i, "").trim()
|
|
52
52
|
|
|
53
|
-
if (
|
|
53
|
+
if (isAccessToken(token)) {
|
|
54
54
|
const principal = requireAppPrincipal(req)
|
|
55
55
|
if (principal.appId !== app.id || principal.environmentId !== env.id) {
|
|
56
56
|
throw new HttpError(403, "forbidden", "该令牌无权访问此应用环境")
|
|
57
57
|
}
|
|
58
|
-
return { project, app, env,
|
|
58
|
+
return { project, app, env, accessToken: true }
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const user = requireUser(req)
|
|
62
62
|
requireProjectRole(user, project.id, "viewer")
|
|
63
63
|
assertEnvAccess(user, env)
|
|
64
|
-
return { project, app, env,
|
|
64
|
+
return { project, app, env, accessToken: false }
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function authorizeWatch(req: Request): Target {
|
|
@@ -75,13 +75,13 @@ function authorizeWatch(req: Request): Target {
|
|
|
75
75
|
|
|
76
76
|
if (!raw) throw new HttpError(401, "unauthorized", "缺少访问凭证")
|
|
77
77
|
|
|
78
|
-
if (
|
|
79
|
-
const tokenRow =
|
|
80
|
-
if (!tokenRow) throw new HttpError(401, "unauthorized", "
|
|
78
|
+
if (isAccessToken(raw)) {
|
|
79
|
+
const tokenRow = verifyAccessToken(raw)
|
|
80
|
+
if (!tokenRow) throw new HttpError(401, "unauthorized", "令牌无效或已撤销")
|
|
81
81
|
if (tokenRow.appId !== app.id || tokenRow.environmentId !== env.id) {
|
|
82
82
|
throw new HttpError(403, "forbidden", "该令牌无权访问此应用环境")
|
|
83
83
|
}
|
|
84
|
-
return { project, app, env,
|
|
84
|
+
return { project, app, env, accessToken: true }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
if (isHumanToken(raw)) {
|
|
@@ -95,7 +95,7 @@ function authorizeWatch(req: Request): Target {
|
|
|
95
95
|
}
|
|
96
96
|
requireProjectRole(user, project.id, "viewer")
|
|
97
97
|
assertEnvAccess(user, env)
|
|
98
|
-
return { project, app, env,
|
|
98
|
+
return { project, app, env, accessToken: false }
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
throw new HttpError(401, "unauthorized", "凭证格式无效")
|
|
@@ -121,16 +121,16 @@ function resolveByRange(appId: string, envId: string, range: string): ReleaseRow
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
function listReleases(req: Request): Response {
|
|
124
|
-
const { app, env,
|
|
125
|
-
const sql =
|
|
124
|
+
const { app, env, accessToken } = authorizeRead(req)
|
|
125
|
+
const sql = accessToken
|
|
126
126
|
? `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND status = 'published' ORDER BY createdAt DESC`
|
|
127
127
|
: `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? ORDER BY createdAt DESC`
|
|
128
128
|
const rows = getDb().query(sql).all(app.id, env.id) as ReleaseRow[]
|
|
129
|
-
return ok({ releases: rows.map((r) => publicRelease(r, false)) })
|
|
129
|
+
return ok({ releases: rows.map((r) => (accessToken ? publicRelease(r, false) : publicReleaseDetail(r, false))) })
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
function resolveRelease(req: Request): Response {
|
|
133
|
-
const { app, env,
|
|
133
|
+
const { app, env, accessToken } = authorizeRead(req)
|
|
134
134
|
const spec = param(req, "spec")
|
|
135
135
|
|
|
136
136
|
if (spec === "latest") {
|
|
@@ -147,7 +147,7 @@ function resolveRelease(req: Request): Response {
|
|
|
147
147
|
return ok({ release: publicRelease(row, true) })
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
const sql =
|
|
150
|
+
const sql = accessToken
|
|
151
151
|
? `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ? AND status = 'published'`
|
|
152
152
|
: `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ?`
|
|
153
153
|
const row = getDb().query(sql).get(app.id, env.id, spec) as ReleaseRow | null
|
|
@@ -183,7 +183,7 @@ function setStatus(req: Request, status: "published" | "unpublished"): Response
|
|
|
183
183
|
db.query(`UPDATE "Release" SET status = ? WHERE id = ?`).run(status, row.id)
|
|
184
184
|
const updated = db.query(`SELECT * FROM "Release" WHERE id = ?`).get(row.id) as ReleaseRow
|
|
185
185
|
notifyWatchers(app.id, env.id)
|
|
186
|
-
return ok({ release:
|
|
186
|
+
return ok({ release: publicReleaseDetail(updated, false) })
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
function publishRelease(req: Request): Response {
|
|
@@ -209,7 +209,7 @@ function resolveSpecSnapshot(target: Target, spec: string): ReturnType<typeof pu
|
|
|
209
209
|
const row = resolveByRange(target.app.id, target.env.id, range)
|
|
210
210
|
return row ? publicRelease(row, true) : null
|
|
211
211
|
}
|
|
212
|
-
const sql = target.
|
|
212
|
+
const sql = target.accessToken
|
|
213
213
|
? `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ? AND status = 'published'`
|
|
214
214
|
: `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ?`
|
|
215
215
|
const row = getDb().query(sql).get(target.app.id, target.env.id, spec) as ReleaseRow | null
|
|
@@ -290,7 +290,7 @@ async function createRelease(req: Request): Promise<Response> {
|
|
|
290
290
|
throw new HttpError(
|
|
291
291
|
422,
|
|
292
292
|
"schema_invalid",
|
|
293
|
-
|
|
293
|
+
`配置无效:\n${errors.map((e) => ` ${e.path.replace(/^\$\./, "")} ${e.message}`).join("\n")}`,
|
|
294
294
|
)
|
|
295
295
|
}
|
|
296
296
|
|
|
@@ -316,7 +316,7 @@ async function createRelease(req: Request): Promise<Response> {
|
|
|
316
316
|
`INSERT INTO "Release" (id, appId, environmentId, semver, changeKind, status, notes, snapshot, schemaSnapshot, createdBy) VALUES (?, ?, ?, ?, ?, 'unpublished', ?, ?, ?, ?)`,
|
|
317
317
|
).run(id, app.id, env.id, semver, kind, notes, JSON.stringify(config), app.schema, user.id)
|
|
318
318
|
const created = db.query(`SELECT * FROM "Release" WHERE id = ?`).get(id) as ReleaseRow
|
|
319
|
-
return ok({ release:
|
|
319
|
+
return ok({ release: publicReleaseDetail(created, true) }, 201)
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
export const releaseRoutes = {
|
|
@@ -5,6 +5,12 @@ import { hashPassword } from "../shared/password.ts"
|
|
|
5
5
|
import { publicUser } from "../shared/serialize.ts"
|
|
6
6
|
import { newId } from "../shared/utils.ts"
|
|
7
7
|
|
|
8
|
+
async function checkSetup(): Promise<Response> {
|
|
9
|
+
const db = getDb()
|
|
10
|
+
const hasSuperAdmin = db.query(`SELECT 1 FROM "User" WHERE "isSuperAdmin" = 1 LIMIT 1`).get()
|
|
11
|
+
return ok({ initialized: !!hasSuperAdmin })
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
async function setup(req: Request): Promise<Response> {
|
|
9
15
|
const db = getDb()
|
|
10
16
|
const hasSuperAdmin = db.query(`SELECT 1 FROM "User" WHERE "isSuperAdmin" = 1 LIMIT 1`).get()
|
|
@@ -31,5 +37,5 @@ async function setup(req: Request): Promise<Response> {
|
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export const setupRoutes = {
|
|
34
|
-
"/v1/setup": { POST: route(setup) },
|
|
40
|
+
"/v1/setup": { GET: route(checkSetup), POST: route(setup) },
|
|
35
41
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { getDb } from "../db/client.ts"
|
|
2
|
-
import type {
|
|
2
|
+
import type { AccessTokenRow } from "../db/types.ts"
|
|
3
3
|
import { HttpError, ok, optionalString, param, readBody, route } from "../http/response.ts"
|
|
4
4
|
import { assertEnvAccess, requireProjectRole, requireUser } from "../shared/auth.ts"
|
|
5
5
|
import { findApp, findEnv, findProject } from "../shared/resolve.ts"
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { publicAccessToken } from "../shared/serialize.ts"
|
|
7
|
+
import { issueAccessToken } from "../shared/token.ts"
|
|
8
8
|
|
|
9
9
|
function listTokens(req: Request): Response {
|
|
10
10
|
const user = requireUser(req)
|
|
@@ -14,9 +14,9 @@ function listTokens(req: Request): Response {
|
|
|
14
14
|
const env = findEnv(project.id, param(req, "env"))
|
|
15
15
|
assertEnvAccess(user, env)
|
|
16
16
|
const rows = getDb()
|
|
17
|
-
.query(`SELECT * FROM "
|
|
18
|
-
.all(app.id, env.id) as
|
|
19
|
-
return ok({ tokens: rows.map(
|
|
17
|
+
.query(`SELECT * FROM "AccessToken" WHERE appId = ? AND environmentId = ? ORDER BY createdAt DESC`)
|
|
18
|
+
.all(app.id, env.id) as AccessTokenRow[]
|
|
19
|
+
return ok({ tokens: rows.map(publicAccessToken) })
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
async function createToken(req: Request): Promise<Response> {
|
|
@@ -29,11 +29,11 @@ async function createToken(req: Request): Promise<Response> {
|
|
|
29
29
|
const body = await readBody(req)
|
|
30
30
|
const name = optionalString(body, "name")
|
|
31
31
|
|
|
32
|
-
const issued =
|
|
32
|
+
const issued = issueAccessToken(app.id, env.id, user.id, name)
|
|
33
33
|
const row = getDb()
|
|
34
|
-
.query(`SELECT * FROM "
|
|
35
|
-
.get(issued.prefix) as
|
|
36
|
-
return ok({ token: issued.raw, info:
|
|
34
|
+
.query(`SELECT * FROM "AccessToken" WHERE tokenPrefix = ? ORDER BY createdAt DESC LIMIT 1`)
|
|
35
|
+
.get(issued.prefix) as AccessTokenRow
|
|
36
|
+
return ok({ token: issued.raw, info: publicAccessToken(row) }, 201)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function revokeToken(req: Request): Response {
|
|
@@ -45,10 +45,10 @@ function revokeToken(req: Request): Response {
|
|
|
45
45
|
assertEnvAccess(user, env)
|
|
46
46
|
const tokenId = param(req, "tokenId")
|
|
47
47
|
const existing = getDb()
|
|
48
|
-
.query(`SELECT 1 FROM "
|
|
48
|
+
.query(`SELECT 1 FROM "AccessToken" WHERE id = ? AND appId = ? AND environmentId = ?`)
|
|
49
49
|
.get(tokenId, app.id, env.id)
|
|
50
50
|
if (!existing) throw new HttpError(404, "token_not_found", "令牌不存在")
|
|
51
|
-
getDb().query(`UPDATE "
|
|
51
|
+
getDb().query(`UPDATE "AccessToken" SET revoked = 1 WHERE id = ?`).run(tokenId)
|
|
52
52
|
return ok({ revoked: true })
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -2,7 +2,7 @@ import { getDb } from "../db/client.ts"
|
|
|
2
2
|
import type { EnvironmentRow, ProjectRole, UserRow } from "../db/types.ts"
|
|
3
3
|
import { HttpError } from "../http/response.ts"
|
|
4
4
|
import { ROLE_RANK } from "./consts.ts"
|
|
5
|
-
import {
|
|
5
|
+
import { isAccessToken, isHumanToken, verifyAccessToken, verifyHumanToken } from "./token.ts"
|
|
6
6
|
import type { AppPrincipal, AuthUser } from "./types.ts"
|
|
7
7
|
|
|
8
8
|
function bearer(req: Request): string {
|
|
@@ -26,9 +26,9 @@ export function requireUser(req: Request): AuthUser {
|
|
|
26
26
|
|
|
27
27
|
export function requireAppPrincipal(req: Request): AppPrincipal {
|
|
28
28
|
const raw = bearer(req)
|
|
29
|
-
if (!
|
|
30
|
-
const token =
|
|
31
|
-
if (!token) throw new HttpError(401, "unauthorized", "
|
|
29
|
+
if (!isAccessToken(raw)) throw new HttpError(401, "unauthorized", "需要访问令牌")
|
|
30
|
+
const token = verifyAccessToken(raw)
|
|
31
|
+
if (!token) throw new HttpError(401, "unauthorized", "令牌无效或已撤销")
|
|
32
32
|
return { tokenId: token.id, appId: token.appId, environmentId: token.environmentId }
|
|
33
33
|
}
|
|
34
34
|
|
package/server/src/shared/env.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { type Static, Type } from "typebox"
|
|
|
3
3
|
import { Value } from "typebox/value"
|
|
4
4
|
|
|
5
5
|
export const envSchema = Type.Object({
|
|
6
|
-
CONFIG_PORT: Type.Optional(Type.String({ pattern: "^\\d+$", default: "4921" })),
|
|
7
6
|
CONFIG_DB_PATH: Type.Optional(Type.String({ default: path.join(process.cwd(), "data", "config.db") })),
|
|
8
7
|
CONFIG_ADMIN_NAME: Type.Optional(Type.String({ minLength: 1, default: "admin" })),
|
|
9
8
|
CONFIG_ADMIN_EMAIL: Type.Optional(Type.String()),
|
|
@@ -13,7 +12,6 @@ export const envSchema = Type.Object({
|
|
|
13
12
|
type RawEnv = Static<typeof envSchema>
|
|
14
13
|
|
|
15
14
|
export interface Env {
|
|
16
|
-
port: number
|
|
17
15
|
dbPath: string
|
|
18
16
|
adminName: string
|
|
19
17
|
adminEmail: string | undefined
|
|
@@ -25,15 +23,12 @@ let cached: Env | undefined
|
|
|
25
23
|
export function getEnv(): Env {
|
|
26
24
|
if (cached) return cached
|
|
27
25
|
const raw: RawEnv = {
|
|
28
|
-
CONFIG_PORT: process.env.CONFIG_PORT,
|
|
29
26
|
CONFIG_DB_PATH: process.env.CONFIG_DB_PATH,
|
|
30
27
|
CONFIG_ADMIN_NAME: process.env.CONFIG_ADMIN_NAME,
|
|
31
28
|
CONFIG_ADMIN_EMAIL: process.env.CONFIG_ADMIN_EMAIL,
|
|
32
29
|
CONFIG_ADMIN_PASSWORD: process.env.CONFIG_ADMIN_PASSWORD,
|
|
33
30
|
}
|
|
34
|
-
const filled = Value.Default(envSchema, raw) as Required<
|
|
35
|
-
Pick<RawEnv, "CONFIG_PORT" | "CONFIG_DB_PATH" | "CONFIG_ADMIN_NAME">
|
|
36
|
-
> &
|
|
31
|
+
const filled = Value.Default(envSchema, raw) as Required<Pick<RawEnv, "CONFIG_DB_PATH" | "CONFIG_ADMIN_NAME">> &
|
|
37
32
|
RawEnv
|
|
38
33
|
const errors = [...Value.Errors(envSchema, filled)]
|
|
39
34
|
if (errors.length) {
|
|
@@ -41,7 +36,6 @@ export function getEnv(): Env {
|
|
|
41
36
|
throw new Error(`环境变量配置错误:\n${msgs}`)
|
|
42
37
|
}
|
|
43
38
|
cached = {
|
|
44
|
-
port: Number.parseInt(filled.CONFIG_PORT, 10),
|
|
45
39
|
dbPath: filled.CONFIG_DB_PATH,
|
|
46
40
|
adminName: filled.CONFIG_ADMIN_NAME,
|
|
47
41
|
adminEmail: filled.CONFIG_ADMIN_EMAIL,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AccessTokenRow,
|
|
2
3
|
AppRow,
|
|
3
|
-
AppTokenRow,
|
|
4
4
|
EnvironmentRow,
|
|
5
5
|
ProjectMemberRow,
|
|
6
6
|
ProjectRow,
|
|
@@ -60,19 +60,22 @@ export function publicApp(row: AppRow) {
|
|
|
60
60
|
|
|
61
61
|
export function publicRelease(row: ReleaseRow, withConfig: boolean) {
|
|
62
62
|
return {
|
|
63
|
-
id: row.id,
|
|
64
63
|
semver: row.semver,
|
|
64
|
+
config: withConfig ? JSON.parse(row.snapshot) : undefined,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function publicReleaseDetail(row: ReleaseRow, withConfig: boolean) {
|
|
69
|
+
return {
|
|
70
|
+
...publicRelease(row, withConfig),
|
|
65
71
|
changeKind: row.changeKind,
|
|
66
72
|
status: row.status,
|
|
67
73
|
notes: row.notes,
|
|
68
|
-
createdBy: row.createdBy,
|
|
69
|
-
createdAt: row.createdAt,
|
|
70
|
-
config: withConfig ? JSON.parse(row.snapshot) : undefined,
|
|
71
74
|
schema: withConfig ? JSON.parse(row.schemaSnapshot) : undefined,
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
export function
|
|
78
|
+
export function publicAccessToken(row: AccessTokenRow) {
|
|
76
79
|
return {
|
|
77
80
|
id: row.id,
|
|
78
81
|
name: row.name,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getDb } from "../db/client.ts"
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
2
|
+
import type { AccessTokenRow, TokenRow, UserRow } from "../db/types.ts"
|
|
3
|
+
import { ACCESS_TOKEN_PREFIX, HUMAN_TOKEN_PREFIX, TOKEN_BYTES } from "./consts.ts"
|
|
4
4
|
import type { IssuedToken } from "./types.ts"
|
|
5
5
|
import { newId, nowSec } from "./utils.ts"
|
|
6
6
|
|
|
@@ -17,8 +17,8 @@ export function isHumanToken(raw: string): boolean {
|
|
|
17
17
|
return raw.startsWith(`${HUMAN_TOKEN_PREFIX}_`)
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export function
|
|
21
|
-
return raw.startsWith(`${
|
|
20
|
+
export function isAccessToken(raw: string): boolean {
|
|
21
|
+
return raw.startsWith(`${ACCESS_TOKEN_PREFIX}_`)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function issueHumanToken(userId: string, name?: string): IssuedToken {
|
|
@@ -39,21 +39,21 @@ export function verifyHumanToken(raw: string): UserRow | null {
|
|
|
39
39
|
return db.query(`SELECT * FROM "User" WHERE id = ?`).get(row.userId) as UserRow | null
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export function
|
|
43
|
-
const raw = `${
|
|
42
|
+
export function issueAccessToken(appId: string, environmentId: string, createdBy: string, name?: string): IssuedToken {
|
|
43
|
+
const raw = `${ACCESS_TOKEN_PREFIX}_${randomSecret()}`
|
|
44
44
|
const prefix = raw.slice(0, 14)
|
|
45
45
|
getDb()
|
|
46
46
|
.query(
|
|
47
|
-
`INSERT INTO "
|
|
47
|
+
`INSERT INTO "AccessToken" (id, appId, environmentId, name, tokenHash, tokenPrefix, createdBy) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
48
48
|
)
|
|
49
49
|
.run(newId(), appId, environmentId, name ?? null, hashSecret(raw), prefix, createdBy)
|
|
50
50
|
return { raw, prefix }
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function
|
|
53
|
+
export function verifyAccessToken(raw: string): AccessTokenRow | null {
|
|
54
54
|
const db = getDb()
|
|
55
|
-
const row = db.query(`SELECT * FROM "
|
|
55
|
+
const row = db.query(`SELECT * FROM "AccessToken" WHERE tokenHash = ?`).get(hashSecret(raw)) as AccessTokenRow | null
|
|
56
56
|
if (!row || row.revoked) return null
|
|
57
|
-
db.query(`UPDATE "
|
|
57
|
+
db.query(`UPDATE "AccessToken" SET lastUsedAt = ? WHERE id = ?`).run(nowSec(), row.id)
|
|
58
58
|
return row
|
|
59
59
|
}
|
package/src/config.compose.ts
CHANGED
|
@@ -1,44 +1,26 @@
|
|
|
1
1
|
import { tmpl } from "@atomservice/core"
|
|
2
|
-
import {
|
|
2
|
+
import { CONFIG_SERVER_PORT } from "./config.consts.ts"
|
|
3
|
+
import type { ConfigComposeOptions } from "./config.types.ts"
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
dataDir: string
|
|
7
|
-
environment?: Record<string, string>
|
|
8
|
-
hostPort?: number
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function configCompose(id: string, opts: ConfigComposeOptions): string {
|
|
12
|
-
const port = CONFIG_DEFAULT_PORT
|
|
13
|
-
const portsBlock = opts.hostPort ? `\n ports:\n - "${opts.hostPort}:${port}"` : ""
|
|
14
|
-
|
|
15
|
-
const lines: string[] = []
|
|
16
|
-
const push = (k: string, v: string | number | undefined) => {
|
|
17
|
-
if (v === undefined || v === "") return
|
|
18
|
-
lines.push(` - ${k}=${v}`)
|
|
19
|
-
}
|
|
20
|
-
if (opts.environment) {
|
|
21
|
-
for (const [k, v] of Object.entries(opts.environment)) push(k, v)
|
|
22
|
-
}
|
|
23
|
-
const extraEnv = lines.length ? `\n${lines.join("\n")}` : ""
|
|
5
|
+
export function configCompose(opts: ConfigComposeOptions): string {
|
|
6
|
+
const portsBlock = opts.hostPort ? `\n ports:\n - "${opts.hostPort}:${CONFIG_SERVER_PORT}"` : ""
|
|
24
7
|
|
|
25
8
|
return tmpl`\
|
|
26
9
|
services:
|
|
27
|
-
${id}:
|
|
10
|
+
${opts.id}:
|
|
28
11
|
image: ${opts.image}
|
|
29
|
-
container_name: ${id}
|
|
12
|
+
container_name: ${opts.id}
|
|
30
13
|
environment:
|
|
31
|
-
-
|
|
32
|
-
- CONFIG_DB_PATH=/data/config.db${extraEnv}
|
|
14
|
+
- CONFIG_DB_PATH=/data/config.db
|
|
33
15
|
volumes:
|
|
34
16
|
- ${opts.dataDir}:/data
|
|
35
17
|
expose:
|
|
36
|
-
- "${
|
|
18
|
+
- "${CONFIG_SERVER_PORT}"${portsBlock}
|
|
37
19
|
networks:
|
|
38
20
|
- atomservice
|
|
39
21
|
restart: unless-stopped
|
|
40
22
|
healthcheck:
|
|
41
|
-
test: ["CMD", "curl", "-sf", "http://localhost:${
|
|
23
|
+
test: ["CMD", "curl", "-sf", "http://localhost:${CONFIG_SERVER_PORT}/v1/health"]
|
|
42
24
|
interval: 10s
|
|
43
25
|
timeout: 5s
|
|
44
26
|
retries: 5
|
package/src/config.consts.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pkg from "../package.json" with { type: "json" }
|
|
2
2
|
|
|
3
|
-
export const CONFIG_IMAGE_REPO = "localhost/atomservice/config
|
|
4
|
-
export const
|
|
3
|
+
export const CONFIG_IMAGE_REPO = "localhost/atomservice/config/server"
|
|
4
|
+
export const CONFIG_SERVER_PORT = 52010
|
|
5
5
|
export const CONFIG_VERSION = pkg.version
|
package/src/config.instance.ts
CHANGED
package/src/config.options.ts
CHANGED
|
@@ -11,27 +11,24 @@ export interface ConfigOptions {
|
|
|
11
11
|
/**
|
|
12
12
|
* 容器对外端口
|
|
13
13
|
*
|
|
14
|
-
* -
|
|
14
|
+
* - 不指定则不对外暴露,仅容器网络内可访问
|
|
15
15
|
*/
|
|
16
16
|
hostPort?: number
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
18
|
+
* 网关配置
|
|
19
19
|
*
|
|
20
|
-
* -
|
|
21
|
-
* - 需要和 `gateway` 原子一起使用
|
|
20
|
+
* - 关联原子网关并自动注册反代路由
|
|
22
21
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*/
|
|
36
|
-
gateway?: CallableService<GatewayInstance>
|
|
22
|
+
gateway?: {
|
|
23
|
+
/**
|
|
24
|
+
* 关联的原子网关服务实例
|
|
25
|
+
*
|
|
26
|
+
* - 填写后自动向网关注册反代路由
|
|
27
|
+
*/
|
|
28
|
+
service: CallableService<GatewayInstance>
|
|
29
|
+
/**
|
|
30
|
+
* 对外域名
|
|
31
|
+
*/
|
|
32
|
+
domain?: string
|
|
33
|
+
}
|
|
37
34
|
}
|
package/src/config.service.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url"
|
|
|
3
3
|
import type { CallableService } from "@atomservice/core"
|
|
4
4
|
import { defineService, onDown, onHealth, onUp, useConfig, useLogger, useShell, useState } from "@atomservice/core"
|
|
5
5
|
import { configCompose } from "./config.compose.ts"
|
|
6
|
-
import {
|
|
6
|
+
import { CONFIG_IMAGE_REPO, CONFIG_SERVER_PORT, CONFIG_VERSION } from "./config.consts.ts"
|
|
7
7
|
import type { ConfigInstance } from "./config.instance.ts"
|
|
8
8
|
import type { ConfigOptions } from "./config.options.ts"
|
|
9
9
|
import type { ConfigState } from "./config.types.ts"
|
|
@@ -13,7 +13,7 @@ const SERVER_DIR = path.resolve(HERE, "..", "server")
|
|
|
13
13
|
|
|
14
14
|
export function config(opts: ConfigOptions = {}): CallableService<ConfigInstance> {
|
|
15
15
|
const serviceId = opts.id && opts.id !== "default" ? opts.id : "default"
|
|
16
|
-
const containerName = serviceId !== "default" ? `config-${serviceId}` : "config"
|
|
16
|
+
const containerName = serviceId !== "default" ? `atomservice-config-${serviceId}` : "atomservice-config"
|
|
17
17
|
const version = CONFIG_VERSION
|
|
18
18
|
const imageTag = `${CONFIG_IMAGE_REPO}:${version}`
|
|
19
19
|
|
|
@@ -24,7 +24,7 @@ export function config(opts: ConfigOptions = {}): CallableService<ConfigInstance
|
|
|
24
24
|
const logger = useLogger()
|
|
25
25
|
const $ = useShell()
|
|
26
26
|
const conf = useConfig()
|
|
27
|
-
const gw = opts.gateway?.()
|
|
27
|
+
const gw = opts.gateway?.service()
|
|
28
28
|
|
|
29
29
|
const dir = path.join(conf.root, containerName)
|
|
30
30
|
const composePath = path.join(dir, "compose.yml")
|
|
@@ -62,10 +62,10 @@ export function config(opts: ConfigOptions = {}): CallableService<ConfigInstance
|
|
|
62
62
|
|
|
63
63
|
await Bun.write(
|
|
64
64
|
composePath,
|
|
65
|
-
configCompose(
|
|
65
|
+
configCompose({
|
|
66
|
+
id: containerName,
|
|
66
67
|
image: imageTag,
|
|
67
68
|
dataDir,
|
|
68
|
-
environment: opts.environment,
|
|
69
69
|
hostPort: opts.hostPort,
|
|
70
70
|
}),
|
|
71
71
|
)
|
|
@@ -74,10 +74,10 @@ export function config(opts: ConfigOptions = {}): CallableService<ConfigInstance
|
|
|
74
74
|
await $`podman compose -f ${composePath} up -d`
|
|
75
75
|
logger.success(`${containerName} 已启动`)
|
|
76
76
|
|
|
77
|
-
if (opts.domain && gw) {
|
|
78
|
-
logger.info(`注册路由 ${opts.domain} → ${containerName}:${
|
|
79
|
-
await gw.registerRoute(opts.domain, `${containerName}:${
|
|
80
|
-
logger.success(`路由已注册,访问 https://${opts.domain}`)
|
|
77
|
+
if (opts.gateway?.domain && gw) {
|
|
78
|
+
logger.info(`注册路由 ${opts.gateway.domain} → ${containerName}:${CONFIG_SERVER_PORT}`)
|
|
79
|
+
await gw.registerRoute(opts.gateway.domain, `${containerName}:${CONFIG_SERVER_PORT}`)
|
|
80
|
+
logger.success(`路由已注册,访问 https://${opts.gateway.domain}`)
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
await save(currentState)
|
|
@@ -86,16 +86,15 @@ export function config(opts: ConfigOptions = {}): CallableService<ConfigInstance
|
|
|
86
86
|
onDown(async () => {
|
|
87
87
|
logger.info(`正在停止 ${containerName}`)
|
|
88
88
|
await $`podman compose -f ${composePath} down`
|
|
89
|
-
if (opts.domain && gw) await gw.unregisterRoute(opts.domain)
|
|
89
|
+
if (opts.gateway?.domain && gw) await gw.unregisterRoute(opts.gateway.domain)
|
|
90
90
|
logger.success(`${containerName} 已停止`)
|
|
91
91
|
})
|
|
92
92
|
|
|
93
93
|
onHealth(async () => {
|
|
94
94
|
try {
|
|
95
|
-
const result =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.nothrow()
|
|
95
|
+
const result = await $`podman exec ${containerName} curl -sf http://localhost:${CONFIG_SERVER_PORT}/v1/health`
|
|
96
|
+
.quiet()
|
|
97
|
+
.nothrow()
|
|
99
98
|
if (result.exitCode === 0) return { status: "healthy" }
|
|
100
99
|
return { status: "unhealthy", message: `Config 健康检查返回 ${result.exitCode}` }
|
|
101
100
|
} catch {
|
|
@@ -108,13 +107,13 @@ export function config(opts: ConfigOptions = {}): CallableService<ConfigInstance
|
|
|
108
107
|
return containerName
|
|
109
108
|
},
|
|
110
109
|
get port() {
|
|
111
|
-
return
|
|
110
|
+
return CONFIG_SERVER_PORT
|
|
112
111
|
},
|
|
113
112
|
get url() {
|
|
114
|
-
return `http://${containerName}:${
|
|
113
|
+
return `http://${containerName}:${CONFIG_SERVER_PORT}/`
|
|
115
114
|
},
|
|
116
115
|
get externalUrl() {
|
|
117
|
-
return opts.domain ? `https://${opts.domain}/` : undefined
|
|
116
|
+
return opts.gateway?.domain ? `https://${opts.gateway.domain}/` : undefined
|
|
118
117
|
},
|
|
119
118
|
}
|
|
120
119
|
},
|