@atomservice/config 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -0
- package/server/.env +3 -0
- package/server/Containerfile +50 -0
- package/server/package.json +18 -0
- package/server/src/db/client.ts +49 -0
- package/server/src/db/consts.ts +3 -0
- package/server/src/db/migrations.ts +106 -0
- package/server/src/db/types.ts +101 -0
- package/server/src/http/response.ts +82 -0
- package/server/src/http/router.ts +27 -0
- package/server/src/main.ts +37 -0
- package/server/src/modules/app.ts +100 -0
- package/server/src/modules/env.ts +86 -0
- package/server/src/modules/inspect.ts +67 -0
- package/server/src/modules/member.ts +59 -0
- package/server/src/modules/project.ts +98 -0
- package/server/src/modules/release.ts +328 -0
- package/server/src/modules/token.ts +58 -0
- package/server/src/modules/user.ts +74 -0
- package/server/src/shared/auth.ts +58 -0
- package/server/src/shared/bootstrap.ts +29 -0
- package/server/src/shared/consts.ts +10 -0
- package/server/src/shared/env.ts +55 -0
- package/server/src/shared/jsonschema.ts +74 -0
- package/server/src/shared/password.ts +7 -0
- package/server/src/shared/resolve.ts +33 -0
- package/server/src/shared/semver.ts +33 -0
- package/server/src/shared/serialize.ts +84 -0
- package/server/src/shared/token.ts +59 -0
- package/server/src/shared/types.ts +25 -0
- package/server/src/shared/utils.ts +17 -0
- package/server/tsconfig.json +27 -0
- package/src/config.compose.ts +59 -0
- package/src/config.consts.ts +5 -0
- package/src/config.instance.ts +26 -0
- package/src/config.options.ts +58 -0
- package/src/config.service.ts +121 -0
- package/src/config.types.ts +3 -0
- package/src/index.ts +3 -0
|
@@ -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
|
+
}
|