@atomservice/config 0.1.14 → 0.1.16

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atomservice/config",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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.14"
32
+ "@atomservice/gateway": "0.1.16"
33
33
  },
34
34
  "peerDependencies": {
35
- "@atomservice/core": "0.1.14"
35
+ "@atomservice/core": "0.1.16"
36
36
  }
37
37
  }
@@ -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,41 +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
- # Create non-root user
35
- RUN groupadd --system --gid 1001 app \
36
- && useradd --system --uid 1001 --gid app --no-create-home app
37
-
38
28
  WORKDIR /app
39
29
 
40
- # Copy compiled binary
41
- COPY --from=builder --chown=app:app /build/dist/config-server ./config-server
42
-
43
- # Prepare data directory (mount a volume here for persistence)
44
- RUN mkdir -p /data && chown app:app /data
30
+ COPY --from=builder /build/dist/config-server ./config-server
45
31
 
46
- USER app
32
+ RUN mkdir -p /data
47
33
 
48
- # Default data path — compose will bind-mount host:${containerName}/data here
49
34
  ENV CONFIG_DB_PATH=/data/config.db
50
35
  VOLUME ["/data"]
51
- EXPOSE 4000
36
+ EXPOSE 52010
52
37
 
53
- # Health check against /v1/health; respects CONFIG_PORT if overridden
54
38
  HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
55
- CMD curl -sf http://localhost:${CONFIG_PORT:-4000}/v1/health
39
+ CMD curl -sf http://localhost:52010/v1/health
56
40
 
57
41
  CMD ["./config-server"]
@@ -114,9 +114,9 @@ export const migrations: Migration[] = [
114
114
  `,
115
115
  },
116
116
  {
117
- id: "008_app_tokens",
117
+ id: "008_access_tokens",
118
118
  up: `
119
- CREATE TABLE "AppToken" (
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 app_token_lookup_idx ON "AppToken"("appId", "environmentId");
131
+ CREATE INDEX access_token_lookup_idx ON "AccessToken"("appId", "environmentId");
132
132
  `,
133
133
  },
134
134
  ]
@@ -87,7 +87,7 @@ export interface ReleaseRow {
87
87
  createdAt: number
88
88
  }
89
89
 
90
- export interface AppTokenRow {
90
+ export interface AccessTokenRow {
91
91
  id: string
92
92
  appId: string
93
93
  environmentId: string
@@ -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 { getEnv, validateEnv } from "./shared/env.ts"
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(`config-server 已启动 http://localhost:${server.port}`)
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, publicRelease } from "../shared/serialize.ts"
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 publicRelease>[] }> = []
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) => publicRelease(r, true)) })
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 { isAppToken, isHumanToken, verifyAppToken, verifyHumanToken } from "../shared/token.ts"
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
- appToken: boolean
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 (isAppToken(token)) {
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, appToken: true }
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, appToken: false }
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 (isAppToken(raw)) {
79
- const tokenRow = verifyAppToken(raw)
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, appToken: true }
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, appToken: false }
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, appToken } = authorizeRead(req)
125
- const sql = appToken
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, appToken } = authorizeRead(req)
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 = appToken
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: publicRelease(updated, false) })
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.appToken
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
- `配置不符合应用 schema:${errors.map((e) => `${e.path} ${e.message}`).join("")}`,
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: publicRelease(created, true) }, 201)
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 { AppTokenRow } from "../db/types.ts"
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 { publicAppToken } from "../shared/serialize.ts"
7
- import { issueAppToken } from "../shared/token.ts"
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 "AppToken" WHERE appId = ? AND environmentId = ? ORDER BY createdAt DESC`)
18
- .all(app.id, env.id) as AppTokenRow[]
19
- return ok({ tokens: rows.map(publicAppToken) })
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 = issueAppToken(app.id, env.id, user.id, name)
32
+ const issued = issueAccessToken(app.id, env.id, user.id, name)
33
33
  const row = getDb()
34
- .query(`SELECT * FROM "AppToken" WHERE tokenPrefix = ? ORDER BY createdAt DESC LIMIT 1`)
35
- .get(issued.prefix) as AppTokenRow
36
- return ok({ token: issued.raw, info: publicAppToken(row) }, 201)
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 "AppToken" WHERE id = ? AND appId = ? AND environmentId = ?`)
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 "AppToken" SET revoked = 1 WHERE id = ?`).run(tokenId)
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 { isAppToken, isHumanToken, verifyAppToken, verifyHumanToken } from "./token.ts"
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 (!isAppToken(raw)) throw new HttpError(401, "unauthorized", "需要应用访问令牌")
30
- const token = verifyAppToken(raw)
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
 
@@ -1,7 +1,7 @@
1
1
  export const API_PREFIX = "/v1"
2
2
 
3
3
  export const HUMAN_TOKEN_PREFIX = "atmu"
4
- export const APP_TOKEN_PREFIX = "atma"
4
+ export const ACCESS_TOKEN_PREFIX = "atma"
5
5
  export const TOKEN_BYTES = 32
6
6
 
7
7
  export const SLUG_RE = /^[a-z][a-z0-9-]*$/
@@ -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 publicAppToken(row: AppTokenRow) {
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 { AppTokenRow, TokenRow, UserRow } from "../db/types.ts"
3
- import { APP_TOKEN_PREFIX, HUMAN_TOKEN_PREFIX, TOKEN_BYTES } from "./consts.ts"
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 isAppToken(raw: string): boolean {
21
- return raw.startsWith(`${APP_TOKEN_PREFIX}_`)
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 issueAppToken(appId: string, environmentId: string, createdBy: string, name?: string): IssuedToken {
43
- const raw = `${APP_TOKEN_PREFIX}_${randomSecret()}`
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 "AppToken" (id, appId, environmentId, name, tokenHash, tokenPrefix, createdBy) VALUES (?, ?, ?, ?, ?, ?, ?)`,
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 verifyAppToken(raw: string): AppTokenRow | null {
53
+ export function verifyAccessToken(raw: string): AccessTokenRow | null {
54
54
  const db = getDb()
55
- const row = db.query(`SELECT * FROM "AppToken" WHERE tokenHash = ?`).get(hashSecret(raw)) as AppTokenRow | null
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 "AppToken" SET lastUsedAt = ? WHERE id = ?`).run(nowSec(), row.id)
57
+ db.query(`UPDATE "AccessToken" SET lastUsedAt = ? WHERE id = ?`).run(nowSec(), row.id)
58
58
  return row
59
59
  }
@@ -1,44 +1,26 @@
1
1
  import { tmpl } from "@atomservice/core"
2
- import { CONFIG_DEFAULT_PORT } from "./config.consts.ts"
2
+ import { CONFIG_SERVER_PORT } from "./config.consts.ts"
3
+ import type { ConfigComposeOptions } from "./config.types.ts"
3
4
 
4
- export interface ConfigComposeOptions {
5
- image: string
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
- - CONFIG_PORT=${port}
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
- - "${port}"${portsBlock}
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:${port}/v1/health"]
23
+ test: ["CMD", "curl", "-sf", "http://localhost:${CONFIG_SERVER_PORT}/v1/health"]
42
24
  interval: 10s
43
25
  timeout: 5s
44
26
  retries: 5
@@ -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-server"
4
- export const CONFIG_DEFAULT_PORT = 4921
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
@@ -13,7 +13,7 @@ export interface ConfigInstance {
13
13
  /**
14
14
  * 容器网络内访问地址
15
15
  *
16
- * - 如 `http://config:4921/`
16
+ * - 如 `http://config:52000/`
17
17
  */
18
18
  readonly url: string
19
19
  /**
@@ -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
- domain?: string
24
- /**
25
- * 自定义环境变量
26
- *
27
- * - 透传到容器
28
- */
29
- environment?: Record<string, string>
30
- /**
31
- * 关联的网关原子
32
- *
33
- * - 填写后自动向网关注册反代路由
34
- * - 通常和 `domain` 一起使用
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
  }
@@ -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 { CONFIG_DEFAULT_PORT, CONFIG_IMAGE_REPO, CONFIG_VERSION } from "./config.consts.ts"
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(containerName, {
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}:${CONFIG_DEFAULT_PORT}`)
79
- await gw.registerRoute(opts.domain, `${containerName}:${CONFIG_DEFAULT_PORT}`)
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
- await $`podman exec ${containerName} curl -sf http://localhost:${CONFIG_DEFAULT_PORT}/v1/health`
97
- .quiet()
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 CONFIG_DEFAULT_PORT
110
+ return CONFIG_SERVER_PORT
112
111
  },
113
112
  get url() {
114
- return `http://${containerName}:${CONFIG_DEFAULT_PORT}/`
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
  },
@@ -1,3 +1,10 @@
1
1
  export type ConfigState = {
2
2
  version: string
3
3
  }
4
+
5
+ export interface ConfigComposeOptions {
6
+ id: string
7
+ image: string
8
+ dataDir: string
9
+ hostPort?: number
10
+ }