@atomservice/config 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +37 -0
  2. package/server/.env +3 -0
  3. package/server/Containerfile +50 -0
  4. package/server/package.json +18 -0
  5. package/server/src/db/client.ts +49 -0
  6. package/server/src/db/consts.ts +3 -0
  7. package/server/src/db/migrations.ts +106 -0
  8. package/server/src/db/types.ts +101 -0
  9. package/server/src/http/response.ts +82 -0
  10. package/server/src/http/router.ts +27 -0
  11. package/server/src/main.ts +37 -0
  12. package/server/src/modules/app.ts +100 -0
  13. package/server/src/modules/env.ts +86 -0
  14. package/server/src/modules/inspect.ts +67 -0
  15. package/server/src/modules/member.ts +59 -0
  16. package/server/src/modules/project.ts +98 -0
  17. package/server/src/modules/release.ts +328 -0
  18. package/server/src/modules/token.ts +58 -0
  19. package/server/src/modules/user.ts +74 -0
  20. package/server/src/shared/auth.ts +58 -0
  21. package/server/src/shared/bootstrap.ts +29 -0
  22. package/server/src/shared/consts.ts +10 -0
  23. package/server/src/shared/env.ts +55 -0
  24. package/server/src/shared/jsonschema.ts +74 -0
  25. package/server/src/shared/password.ts +7 -0
  26. package/server/src/shared/resolve.ts +33 -0
  27. package/server/src/shared/semver.ts +33 -0
  28. package/server/src/shared/serialize.ts +84 -0
  29. package/server/src/shared/token.ts +59 -0
  30. package/server/src/shared/types.ts +25 -0
  31. package/server/src/shared/utils.ts +17 -0
  32. package/server/tsconfig.json +27 -0
  33. package/src/config.compose.ts +59 -0
  34. package/src/config.consts.ts +5 -0
  35. package/src/config.instance.ts +26 -0
  36. package/src/config.options.ts +58 -0
  37. package/src/config.service.ts +121 -0
  38. package/src/config.types.ts +3 -0
  39. package/src/index.ts +3 -0
@@ -0,0 +1,86 @@
1
+ import { getDb } from "../db/client.ts"
2
+ import type { EnvironmentRow } from "../db/types.ts"
3
+ import { HttpError, ok, optionalString, param, readBody, requireString, route } from "../http/response.ts"
4
+ import { assertEnvAccess, requireProjectRole, requireSuperAdmin, requireUser } from "../shared/auth.ts"
5
+ import { SLUG_RE } from "../shared/consts.ts"
6
+ import { findEnv, findProject } from "../shared/resolve.ts"
7
+ import { publicEnv } from "../shared/serialize.ts"
8
+ import { newId, nowSec } from "../shared/utils.ts"
9
+
10
+ function listEnvs(req: Request): Response {
11
+ const user = requireUser(req)
12
+ const project = findProject(param(req, "project"))
13
+ requireProjectRole(user, project.id, "viewer")
14
+ let rows = getDb()
15
+ .query(`SELECT * FROM "Environment" WHERE projectId = ? ORDER BY sortOrder, createdAt`)
16
+ .all(project.id) as EnvironmentRow[]
17
+ if (!user.isSuperAdmin) rows = rows.filter((r) => r.sensitive === 0)
18
+ return ok({ environments: rows.map(publicEnv) })
19
+ }
20
+
21
+ async function createEnv(req: Request): Promise<Response> {
22
+ const user = requireUser(req)
23
+ const project = findProject(param(req, "project"))
24
+ requireProjectRole(user, project.id, "admin")
25
+ const body = await readBody(req)
26
+ const slug = requireString(body, "slug", "环境标识")
27
+ const name = requireString(body, "name", "环境名称")
28
+ const description = optionalString(body, "description") ?? null
29
+ const sortOrder = typeof body.sortOrder === "number" ? body.sortOrder : 0
30
+ const sensitive = body.sensitive === true ? 1 : 0
31
+
32
+ if (!SLUG_RE.test(slug))
33
+ throw new HttpError(400, "invalid_field", "环境标识需以小写字母开头,仅含小写字母、数字、连字符")
34
+ if (sensitive === 1) requireSuperAdmin(user)
35
+
36
+ const db = getDb()
37
+ if (db.query(`SELECT 1 FROM "Environment" WHERE projectId = ? AND slug = ?`).get(project.id, slug)) {
38
+ throw new HttpError(409, "conflict", "环境标识已存在")
39
+ }
40
+
41
+ const id = newId()
42
+ db.query(
43
+ `INSERT INTO "Environment" (id, projectId, slug, name, description, sortOrder, sensitive, createdBy) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
44
+ ).run(id, project.id, slug, name, description, sortOrder, sensitive, user.id)
45
+ const created = db.query(`SELECT * FROM "Environment" WHERE id = ?`).get(id) as EnvironmentRow
46
+ return ok({ environment: publicEnv(created) }, 201)
47
+ }
48
+
49
+ async function updateEnv(req: Request): Promise<Response> {
50
+ const user = requireUser(req)
51
+ const project = findProject(param(req, "project"))
52
+ requireProjectRole(user, project.id, "admin")
53
+ const env = findEnv(project.id, param(req, "env"))
54
+ assertEnvAccess(user, env)
55
+ const body = await readBody(req)
56
+ const name = optionalString(body, "name") ?? env.name
57
+ const description = body.description === undefined ? env.description : (optionalString(body, "description") ?? null)
58
+ const sortOrder = typeof body.sortOrder === "number" ? body.sortOrder : env.sortOrder
59
+ let sensitive = env.sensitive
60
+ if (body.sensitive !== undefined) {
61
+ requireSuperAdmin(user)
62
+ sensitive = body.sensitive === true ? 1 : 0
63
+ }
64
+
65
+ const db = getDb()
66
+ db.query(
67
+ `UPDATE "Environment" SET name = ?, description = ?, sortOrder = ?, sensitive = ?, updatedAt = ? WHERE id = ?`,
68
+ ).run(name, description, sortOrder, sensitive, nowSec(), env.id)
69
+ const updated = db.query(`SELECT * FROM "Environment" WHERE id = ?`).get(env.id) as EnvironmentRow
70
+ return ok({ environment: publicEnv(updated) })
71
+ }
72
+
73
+ function deleteEnv(req: Request): Response {
74
+ const user = requireUser(req)
75
+ const project = findProject(param(req, "project"))
76
+ requireProjectRole(user, project.id, "admin")
77
+ const env = findEnv(project.id, param(req, "env"))
78
+ assertEnvAccess(user, env)
79
+ getDb().query(`DELETE FROM "Environment" WHERE id = ?`).run(env.id)
80
+ return ok({ deleted: true })
81
+ }
82
+
83
+ export const envRoutes = {
84
+ "/v1/projects/:project/envs": { GET: route(listEnvs), POST: route(createEnv) },
85
+ "/v1/projects/:project/envs/:env": { PATCH: route(updateEnv), DELETE: route(deleteEnv) },
86
+ }
@@ -0,0 +1,67 @@
1
+ import { getDb } from "../db/client.ts"
2
+ import type { AppRow, EnvironmentRow, ReleaseRow } from "../db/types.ts"
3
+ import { ok, param, route } from "../http/response.ts"
4
+ import { requireProjectRole, requireUser } from "../shared/auth.ts"
5
+ import { findProject } from "../shared/resolve.ts"
6
+ import { publicApp, publicEnv, publicProject, publicRelease } from "../shared/serialize.ts"
7
+
8
+ function inspectProject(req: Request): Response {
9
+ const user = requireUser(req)
10
+ const project = findProject(param(req, "project"))
11
+ requireProjectRole(user, project.id, "viewer")
12
+
13
+ const url = new URL(req.url)
14
+ const appsParam = url.searchParams.get("apps")
15
+ const envsParam = url.searchParams.get("envs")
16
+ const limit = Math.min(Math.max(Number.parseInt(url.searchParams.get("limit") ?? "3", 10) || 3, 1), 20)
17
+
18
+ const db = getDb()
19
+
20
+ let appRows = db.query(`SELECT * FROM "App" WHERE projectId = ? ORDER BY createdAt`).all(project.id) as AppRow[]
21
+ if (appsParam) {
22
+ const slugs = new Set(
23
+ appsParam
24
+ .split(",")
25
+ .map((s) => s.trim())
26
+ .filter(Boolean),
27
+ )
28
+ appRows = appRows.filter((a) => slugs.has(a.slug))
29
+ }
30
+
31
+ let envRows = db
32
+ .query(`SELECT * FROM "Environment" WHERE projectId = ? ORDER BY sortOrder, createdAt`)
33
+ .all(project.id) as EnvironmentRow[]
34
+ if (!user.isSuperAdmin) envRows = envRows.filter((e) => e.sensitive === 0)
35
+ if (envsParam) {
36
+ const slugs = new Set(
37
+ envsParam
38
+ .split(",")
39
+ .map((s) => s.trim())
40
+ .filter(Boolean),
41
+ )
42
+ envRows = envRows.filter((e) => slugs.has(e.slug))
43
+ }
44
+
45
+ const releases: Array<{ app: string; env: string; releases: ReturnType<typeof publicRelease>[] }> = []
46
+ for (const app of appRows) {
47
+ for (const env of envRows) {
48
+ const rows = db
49
+ .query(`SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? ORDER BY createdAt DESC LIMIT ?`)
50
+ .all(app.id, env.id, limit) as ReleaseRow[]
51
+ if (rows.length > 0) {
52
+ releases.push({ app: app.slug, env: env.slug, releases: rows.map((r) => publicRelease(r, true)) })
53
+ }
54
+ }
55
+ }
56
+
57
+ return ok({
58
+ project: publicProject(project),
59
+ apps: appRows.map(publicApp),
60
+ environments: envRows.map(publicEnv),
61
+ releases,
62
+ })
63
+ }
64
+
65
+ export const inspectRoutes = {
66
+ "/v1/projects/:project/inspect": { GET: route(inspectProject) },
67
+ }
@@ -0,0 +1,59 @@
1
+ import { getDb } from "../db/client.ts"
2
+ import type { ProjectMemberRow, ProjectRole, UserRow } from "../db/types.ts"
3
+ import { HttpError, ok, param, readBody, requireString, route } from "../http/response.ts"
4
+ import { requireProjectRole, requireUser } from "../shared/auth.ts"
5
+ import { findProject, findUserByAccount } from "../shared/resolve.ts"
6
+ import { publicMember } from "../shared/serialize.ts"
7
+
8
+ const ROLES: ProjectRole[] = ["admin", "collaborator", "viewer"]
9
+
10
+ function listMembers(req: Request): Response {
11
+ const user = requireUser(req)
12
+ const project = findProject(param(req, "project"))
13
+ requireProjectRole(user, project.id, "viewer")
14
+ const rows = getDb()
15
+ .query(
16
+ `SELECT m.*, u.email AS email, u.name AS name FROM "ProjectMember" m JOIN "User" u ON u.id = m.userId WHERE m.projectId = ? ORDER BY m.joinedAt`,
17
+ )
18
+ .all(project.id) as (ProjectMemberRow & Pick<UserRow, "email" | "name">)[]
19
+ return ok({ members: rows.map(publicMember) })
20
+ }
21
+
22
+ async function addMember(req: Request): Promise<Response> {
23
+ const user = requireUser(req)
24
+ const project = findProject(param(req, "project"))
25
+ requireProjectRole(user, project.id, "admin")
26
+ const body = await readBody(req)
27
+ const account = requireString(body, "account", "用户账号")
28
+ const role = requireString(body, "role", "成员角色") as ProjectRole
29
+ if (!ROLES.includes(role)) throw new HttpError(400, "invalid_field", "角色须为 admin、collaborator 或 viewer")
30
+
31
+ const target = findUserByAccount(account)
32
+ const db = getDb()
33
+ const exists = db.query(`SELECT 1 FROM "ProjectMember" WHERE projectId = ? AND userId = ?`).get(project.id, target.id)
34
+ if (exists) {
35
+ db.query(`UPDATE "ProjectMember" SET role = ? WHERE projectId = ? AND userId = ?`).run(role, project.id, target.id)
36
+ } else {
37
+ db.query(`INSERT INTO "ProjectMember" (projectId, userId, role) VALUES (?, ?, ?)`).run(project.id, target.id, role)
38
+ }
39
+ const row = db
40
+ .query(
41
+ `SELECT m.*, u.email AS email, u.name AS name FROM "ProjectMember" m JOIN "User" u ON u.id = m.userId WHERE m.projectId = ? AND m.userId = ?`,
42
+ )
43
+ .get(project.id, target.id) as ProjectMemberRow & Pick<UserRow, "email" | "name">
44
+ return ok({ member: publicMember(row) }, exists ? 200 : 201)
45
+ }
46
+
47
+ function removeMember(req: Request): Response {
48
+ const user = requireUser(req)
49
+ const project = findProject(param(req, "project"))
50
+ requireProjectRole(user, project.id, "admin")
51
+ const target = findUserByAccount(param(req, "account"))
52
+ getDb().query(`DELETE FROM "ProjectMember" WHERE projectId = ? AND userId = ?`).run(project.id, target.id)
53
+ return ok({ removed: true })
54
+ }
55
+
56
+ export const memberRoutes = {
57
+ "/v1/projects/:project/members": { GET: route(listMembers), POST: route(addMember) },
58
+ "/v1/projects/:project/members/:account": { DELETE: route(removeMember) },
59
+ }
@@ -0,0 +1,98 @@
1
+ import { getDb } from "../db/client.ts"
2
+ import type { ProjectRow } from "../db/types.ts"
3
+ import { HttpError, ok, optionalString, param, readBody, requireString, route } from "../http/response.ts"
4
+ import { requireProjectRole, requireSuperAdmin, requireUser } from "../shared/auth.ts"
5
+ import { SLUG_RE } from "../shared/consts.ts"
6
+ import { findProject } from "../shared/resolve.ts"
7
+ import { publicProject } from "../shared/serialize.ts"
8
+ import { newId, nowSec } from "../shared/utils.ts"
9
+
10
+ function listProjects(req: Request): Response {
11
+ const user = requireUser(req)
12
+ const db = getDb()
13
+ const rows = user.isSuperAdmin
14
+ ? (db.query(`SELECT * FROM "Project" ORDER BY createdAt`).all() as ProjectRow[])
15
+ : (db
16
+ .query(
17
+ `SELECT p.* FROM "Project" p JOIN "ProjectMember" m ON m.projectId = p.id WHERE m.userId = ? ORDER BY p.createdAt`,
18
+ )
19
+ .all(user.id) as ProjectRow[])
20
+ return ok({ projects: rows.map(publicProject) })
21
+ }
22
+
23
+ async function createProject(req: Request): Promise<Response> {
24
+ const user = requireUser(req)
25
+ requireSuperAdmin(user)
26
+ const body = await readBody(req)
27
+ const slug = requireString(body, "slug", "项目标识")
28
+ const name = requireString(body, "name", "项目名称")
29
+ const description = optionalString(body, "description") ?? null
30
+
31
+ if (!SLUG_RE.test(slug))
32
+ throw new HttpError(400, "invalid_field", "项目标识需以小写字母开头,仅含小写字母、数字、连字符")
33
+
34
+ const db = getDb()
35
+ if (db.query(`SELECT 1 FROM "Project" WHERE slug = ?`).get(slug)) {
36
+ throw new HttpError(409, "conflict", "项目标识已存在")
37
+ }
38
+
39
+ const id = newId()
40
+ const create = db.transaction(() => {
41
+ db.query(`INSERT INTO "Project" (id, slug, name, description, createdBy) VALUES (?, ?, ?, ?, ?)`).run(
42
+ id,
43
+ slug,
44
+ name,
45
+ description,
46
+ user.id,
47
+ )
48
+ db.query(`INSERT INTO "ProjectMember" (projectId, userId, role) VALUES (?, ?, 'admin')`).run(id, user.id)
49
+ })
50
+ create()
51
+
52
+ const created = db.query(`SELECT * FROM "Project" WHERE id = ?`).get(id) as ProjectRow
53
+ return ok({ project: publicProject(created) }, 201)
54
+ }
55
+
56
+ function getProject(req: Request): Response {
57
+ const user = requireUser(req)
58
+ const project = findProject(param(req, "project"))
59
+ requireProjectRole(user, project.id, "viewer")
60
+ return ok({ project: publicProject(project) })
61
+ }
62
+
63
+ async function updateProject(req: Request): Promise<Response> {
64
+ const user = requireUser(req)
65
+ const project = findProject(param(req, "project"))
66
+ requireProjectRole(user, project.id, "admin")
67
+ const body = await readBody(req)
68
+ const name = optionalString(body, "name") ?? project.name
69
+ const description =
70
+ body.description === undefined ? project.description : (optionalString(body, "description") ?? null)
71
+
72
+ const db = getDb()
73
+ db.query(`UPDATE "Project" SET name = ?, description = ?, updatedAt = ? WHERE id = ?`).run(
74
+ name,
75
+ description,
76
+ nowSec(),
77
+ project.id,
78
+ )
79
+ const updated = db.query(`SELECT * FROM "Project" WHERE id = ?`).get(project.id) as ProjectRow
80
+ return ok({ project: publicProject(updated) })
81
+ }
82
+
83
+ function deleteProject(req: Request): Response {
84
+ const user = requireUser(req)
85
+ requireSuperAdmin(user)
86
+ const project = findProject(param(req, "project"))
87
+ getDb().query(`DELETE FROM "Project" WHERE id = ?`).run(project.id)
88
+ return ok({ deleted: true })
89
+ }
90
+
91
+ export const projectRoutes = {
92
+ "/v1/projects": { GET: route(listProjects), POST: route(createProject) },
93
+ "/v1/projects/:project": {
94
+ GET: route(getProject),
95
+ PATCH: route(updateProject),
96
+ DELETE: route(deleteProject),
97
+ },
98
+ }
@@ -0,0 +1,328 @@
1
+ import type { Server } from "bun"
2
+ import { getDb } from "../db/client.ts"
3
+ import type { AppRow, EnvironmentRow, ProjectRow, ReleaseRow } from "../db/types.ts"
4
+ import { HttpError, ok, optionalString, param, readBody, route, sseRoute } from "../http/response.ts"
5
+ import { assertEnvAccess, requireAppPrincipal, requireProjectRole, requireUser } from "../shared/auth.ts"
6
+ import { validateAgainstSchema } from "../shared/jsonschema.ts"
7
+ import { findApp, findEnv, findProject } from "../shared/resolve.ts"
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"
11
+ import type { AuthUser } from "../shared/types.ts"
12
+ import { isPlainObject, newId } from "../shared/utils.ts"
13
+
14
+ type NotifyCallback = () => void
15
+ const watchers = new Map<string, Set<NotifyCallback>>()
16
+
17
+ function watcherKey(appId: string, envId: string): string {
18
+ return `${appId}:${envId}`
19
+ }
20
+
21
+ function subscribe(appId: string, envId: string, cb: NotifyCallback): () => void {
22
+ const key = watcherKey(appId, envId)
23
+ let set = watchers.get(key)
24
+ if (!set) {
25
+ set = new Set()
26
+ watchers.set(key, set)
27
+ }
28
+ set.add(cb)
29
+ return () => {
30
+ watchers.get(key)?.delete(cb)
31
+ }
32
+ }
33
+
34
+ function notifyWatchers(appId: string, envId: string): void {
35
+ for (const cb of watchers.get(watcherKey(appId, envId)) ?? []) cb()
36
+ }
37
+
38
+ interface Target {
39
+ project: ProjectRow
40
+ app: AppRow
41
+ env: EnvironmentRow
42
+ appToken: boolean
43
+ }
44
+
45
+ function authorizeRead(req: Request): Target {
46
+ const project = findProject(param(req, "project"))
47
+ const app = findApp(project.id, param(req, "app"))
48
+ const env = findEnv(project.id, param(req, "env"))
49
+
50
+ const header = req.headers.get("authorization") ?? ""
51
+ const token = header.replace(/^Bearer\s+/i, "").trim()
52
+
53
+ if (isAppToken(token)) {
54
+ const principal = requireAppPrincipal(req)
55
+ if (principal.appId !== app.id || principal.environmentId !== env.id) {
56
+ throw new HttpError(403, "forbidden", "该令牌无权访问此应用环境")
57
+ }
58
+ return { project, app, env, appToken: true }
59
+ }
60
+
61
+ const user = requireUser(req)
62
+ requireProjectRole(user, project.id, "viewer")
63
+ assertEnvAccess(user, env)
64
+ return { project, app, env, appToken: false }
65
+ }
66
+
67
+ function authorizeWatch(req: Request): Target {
68
+ const project = findProject(param(req, "project"))
69
+ const app = findApp(project.id, param(req, "app"))
70
+ const env = findEnv(project.id, param(req, "env"))
71
+
72
+ const authHeader = req.headers.get("authorization")
73
+ const queryToken = new URL(req.url).searchParams.get("token")
74
+ const raw = authHeader ? (authHeader.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() ?? "") : (queryToken?.trim() ?? "")
75
+
76
+ if (!raw) throw new HttpError(401, "unauthorized", "缺少访问凭证")
77
+
78
+ if (isAppToken(raw)) {
79
+ const tokenRow = verifyAppToken(raw)
80
+ if (!tokenRow) throw new HttpError(401, "unauthorized", "应用令牌无效或已撤销")
81
+ if (tokenRow.appId !== app.id || tokenRow.environmentId !== env.id) {
82
+ throw new HttpError(403, "forbidden", "该令牌无权访问此应用环境")
83
+ }
84
+ return { project, app, env, appToken: true }
85
+ }
86
+
87
+ if (isHumanToken(raw)) {
88
+ const userRow = verifyHumanToken(raw)
89
+ if (!userRow) throw new HttpError(401, "unauthorized", "凭证无效或已过期,请重新登录")
90
+ const user: AuthUser = {
91
+ id: userRow.id,
92
+ email: userRow.email,
93
+ name: userRow.name,
94
+ isSuperAdmin: userRow.isSuperAdmin === 1,
95
+ }
96
+ requireProjectRole(user, project.id, "viewer")
97
+ assertEnvAccess(user, env)
98
+ return { project, app, env, appToken: false }
99
+ }
100
+
101
+ throw new HttpError(401, "unauthorized", "凭证格式无效")
102
+ }
103
+
104
+ function aliasToRange(spec: string): string | null {
105
+ if (spec.startsWith("compatible-")) return `^${spec.slice("compatible-".length)}`
106
+ if (spec.startsWith("approx-")) return `~${spec.slice("approx-".length)}`
107
+ return null
108
+ }
109
+
110
+ function publishedReleases(appId: string, envId: string): ReleaseRow[] {
111
+ return getDb()
112
+ .query(`SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND status = 'published'`)
113
+ .all(appId, envId) as ReleaseRow[]
114
+ }
115
+
116
+ function resolveByRange(appId: string, envId: string, range: string): ReleaseRow | null {
117
+ const matched = publishedReleases(appId, envId).filter((r) => Bun.semver.satisfies(r.semver, range))
118
+ if (matched.length === 0) return null
119
+ matched.sort((a, b) => Bun.semver.order(b.semver, a.semver))
120
+ return matched[0] ?? null
121
+ }
122
+
123
+ function listReleases(req: Request): Response {
124
+ const { app, env, appToken } = authorizeRead(req)
125
+ const sql = appToken
126
+ ? `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND status = 'published' ORDER BY createdAt DESC`
127
+ : `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? ORDER BY createdAt DESC`
128
+ const rows = getDb().query(sql).all(app.id, env.id) as ReleaseRow[]
129
+ return ok({ releases: rows.map((r) => publicRelease(r, false)) })
130
+ }
131
+
132
+ function resolveRelease(req: Request): Response {
133
+ const { app, env, appToken } = authorizeRead(req)
134
+ const spec = param(req, "spec")
135
+
136
+ if (spec === "latest") {
137
+ const matched = publishedReleases(app.id, env.id)
138
+ if (matched.length === 0) throw new HttpError(404, "release_not_found", "该应用环境尚无已发布版本")
139
+ matched.sort((a, b) => Bun.semver.order(b.semver, a.semver))
140
+ return ok({ release: publicRelease(matched[0] as ReleaseRow, true) })
141
+ }
142
+
143
+ const range = aliasToRange(spec)
144
+ if (range !== null) {
145
+ const row = resolveByRange(app.id, env.id, range)
146
+ if (!row) throw new HttpError(404, "release_not_found", `没有匹配 ${range} 的已发布版本`)
147
+ return ok({ release: publicRelease(row, true) })
148
+ }
149
+
150
+ const sql = appToken
151
+ ? `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ? AND status = 'published'`
152
+ : `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ?`
153
+ const row = getDb().query(sql).get(app.id, env.id, spec) as ReleaseRow | null
154
+ if (!row) throw new HttpError(404, "release_not_found", `版本不存在:${spec}`)
155
+ return ok({ release: publicRelease(row, true) })
156
+ }
157
+
158
+ function authorizePublish(req: Request): { app: AppRow; env: EnvironmentRow } {
159
+ const project = findProject(param(req, "project"))
160
+ const app = findApp(project.id, param(req, "app"))
161
+ const env = findEnv(project.id, param(req, "env"))
162
+ const user = requireUser(req)
163
+ requireProjectRole(user, project.id, "admin")
164
+ assertEnvAccess(user, env)
165
+ return { app, env }
166
+ }
167
+
168
+ function setStatus(req: Request, status: "published" | "unpublished"): Response {
169
+ const { app, env } = authorizePublish(req)
170
+ const semver = param(req, "semver")
171
+ const db = getDb()
172
+ const row = db
173
+ .query(`SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ?`)
174
+ .get(app.id, env.id, semver) as ReleaseRow | null
175
+ if (!row) throw new HttpError(404, "release_not_found", `版本不存在:${semver}`)
176
+ if (row.status === status) {
177
+ throw new HttpError(
178
+ 409,
179
+ "status_unchanged",
180
+ status === "published" ? "该版本已是已发布状态" : "该版本已是未发布状态",
181
+ )
182
+ }
183
+ db.query(`UPDATE "Release" SET status = ? WHERE id = ?`).run(status, row.id)
184
+ const updated = db.query(`SELECT * FROM "Release" WHERE id = ?`).get(row.id) as ReleaseRow
185
+ notifyWatchers(app.id, env.id)
186
+ return ok({ release: publicRelease(updated, false) })
187
+ }
188
+
189
+ function publishRelease(req: Request): Response {
190
+ return setStatus(req, "published")
191
+ }
192
+
193
+ function unpublishRelease(req: Request): Response {
194
+ return setStatus(req, "unpublished")
195
+ }
196
+
197
+ const WATCH_DEADLINE_MS = 30 * 60 * 1000
198
+
199
+ function resolveSpecSnapshot(target: Target, spec: string): ReturnType<typeof publicRelease> | null {
200
+ try {
201
+ if (spec === "latest") {
202
+ const matched = publishedReleases(target.app.id, target.env.id)
203
+ if (matched.length === 0) return null
204
+ matched.sort((a, b) => Bun.semver.order(b.semver, a.semver))
205
+ return publicRelease(matched[0] as ReleaseRow, true)
206
+ }
207
+ const range = aliasToRange(spec)
208
+ if (range !== null) {
209
+ const row = resolveByRange(target.app.id, target.env.id, range)
210
+ return row ? publicRelease(row, true) : null
211
+ }
212
+ const sql = target.appToken
213
+ ? `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ? AND status = 'published'`
214
+ : `SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? AND semver = ?`
215
+ const row = getDb().query(sql).get(target.app.id, target.env.id, spec) as ReleaseRow | null
216
+ return row ? publicRelease(row, true) : null
217
+ } catch {
218
+ return null
219
+ }
220
+ }
221
+
222
+ function watchRelease(req: Request, server: Server<undefined>): Response {
223
+ const target = authorizeWatch(req)
224
+ const spec = param(req, "spec")
225
+ server.timeout(req, 0)
226
+
227
+ return new Response(
228
+ async function* () {
229
+ const start = Date.now()
230
+ const cleanup = { fn: null as (() => void) | null }
231
+
232
+ try {
233
+ yield `data: ${JSON.stringify({ release: resolveSpecSnapshot(target, spec) })}\n\n`
234
+
235
+ while (Date.now() - start < WATCH_DEADLINE_MS) {
236
+ await new Promise<void>((done) => {
237
+ let fired = false
238
+ const fire = () => {
239
+ if (!fired) {
240
+ fired = true
241
+ done()
242
+ }
243
+ }
244
+ const remaining = WATCH_DEADLINE_MS - (Date.now() - start)
245
+ const timer = setTimeout(fire, remaining)
246
+ cleanup.fn = subscribe(target.app.id, target.env.id, () => {
247
+ clearTimeout(timer)
248
+ fire()
249
+ })
250
+ })
251
+ cleanup.fn?.()
252
+ cleanup.fn = null
253
+
254
+ if (Date.now() - start < WATCH_DEADLINE_MS) {
255
+ yield `data: ${JSON.stringify({ release: resolveSpecSnapshot(target, spec) })}\n\n`
256
+ }
257
+ }
258
+
259
+ yield `event: timeout\ndata: {}\n\n`
260
+ } finally {
261
+ cleanup.fn?.()
262
+ }
263
+ },
264
+ {
265
+ headers: {
266
+ "Content-Type": "text/event-stream",
267
+ "Cache-Control": "no-cache",
268
+ "X-Accel-Buffering": "no",
269
+ },
270
+ },
271
+ )
272
+ }
273
+
274
+ async function createRelease(req: Request): Promise<Response> {
275
+ const user = requireUser(req)
276
+ const project = findProject(param(req, "project"))
277
+ requireProjectRole(user, project.id, "collaborator")
278
+ const app: AppRow = findApp(project.id, param(req, "app"))
279
+ const env = findEnv(project.id, param(req, "env"))
280
+ assertEnvAccess(user, env)
281
+
282
+ const body = await readBody(req)
283
+ if (!isPlainObject(body.config)) throw new HttpError(400, "invalid_field", "config 必须是 JSON 对象")
284
+ const config = body.config as Record<string, unknown>
285
+ const notes = optionalString(body, "notes") ?? null
286
+ const base = body.base === undefined || body.base === null ? null : String(body.base)
287
+
288
+ const errors = validateAgainstSchema(JSON.parse(app.schema), config)
289
+ if (errors.length > 0) {
290
+ throw new HttpError(
291
+ 422,
292
+ "schema_invalid",
293
+ `配置不符合应用 schema:${errors.map((e) => `${e.path} ${e.message}`).join(";")}`,
294
+ )
295
+ }
296
+
297
+ const db = getDb()
298
+ const latest = db
299
+ .query(`SELECT * FROM "Release" WHERE appId = ? AND environmentId = ? ORDER BY createdAt DESC LIMIT 1`)
300
+ .get(app.id, env.id) as ReleaseRow | null
301
+
302
+ if (latest && base !== latest.semver) {
303
+ throw new HttpError(409, "stale_base", `推送基线已过期,当前最新版本为 ${latest.semver},请先拉取最新配置`)
304
+ }
305
+ if (!latest && base !== null) {
306
+ throw new HttpError(409, "stale_base", "该应用环境暂无版本,请基于空配置创建首个版本")
307
+ }
308
+
309
+ const prevConfig = latest ? (JSON.parse(latest.snapshot) as Record<string, unknown>) : null
310
+ const kind = diffChangeKind(prevConfig, config)
311
+ if (!kind) throw new HttpError(400, "no_change", "配置无变化,无需创建新版本")
312
+
313
+ const semver = bumpSemver(latest?.semver ?? null, kind)
314
+ const id = newId()
315
+ db.query(
316
+ `INSERT INTO "Release" (id, appId, environmentId, semver, changeKind, status, notes, snapshot, schemaSnapshot, createdBy) VALUES (?, ?, ?, ?, ?, 'unpublished', ?, ?, ?, ?)`,
317
+ ).run(id, app.id, env.id, semver, kind, notes, JSON.stringify(config), app.schema, user.id)
318
+ const created = db.query(`SELECT * FROM "Release" WHERE id = ?`).get(id) as ReleaseRow
319
+ return ok({ release: publicRelease(created, true) }, 201)
320
+ }
321
+
322
+ export const releaseRoutes = {
323
+ "/v1/projects/:project/apps/:app/envs/:env/releases": { GET: route(listReleases), POST: route(createRelease) },
324
+ "/v1/projects/:project/apps/:app/envs/:env/releases/:spec": { GET: route(resolveRelease) },
325
+ "/v1/projects/:project/apps/:app/envs/:env/releases/:spec/watch": { GET: sseRoute(watchRelease) },
326
+ "/v1/projects/:project/apps/:app/envs/:env/releases/:semver/publish": { POST: route(publishRelease) },
327
+ "/v1/projects/:project/apps/:app/envs/:env/releases/:semver/unpublish": { POST: route(unpublishRelease) },
328
+ }