@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,58 @@
|
|
|
1
|
+
import { getDb } from "../db/client.ts"
|
|
2
|
+
import type { AppTokenRow } from "../db/types.ts"
|
|
3
|
+
import { HttpError, ok, optionalString, param, readBody, route } from "../http/response.ts"
|
|
4
|
+
import { assertEnvAccess, requireProjectRole, requireUser } from "../shared/auth.ts"
|
|
5
|
+
import { findApp, findEnv, findProject } from "../shared/resolve.ts"
|
|
6
|
+
import { publicAppToken } from "../shared/serialize.ts"
|
|
7
|
+
import { issueAppToken } from "../shared/token.ts"
|
|
8
|
+
|
|
9
|
+
function listTokens(req: Request): Response {
|
|
10
|
+
const user = requireUser(req)
|
|
11
|
+
const project = findProject(param(req, "project"))
|
|
12
|
+
requireProjectRole(user, project.id, "admin")
|
|
13
|
+
const app = findApp(project.id, param(req, "app"))
|
|
14
|
+
const env = findEnv(project.id, param(req, "env"))
|
|
15
|
+
assertEnvAccess(user, env)
|
|
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) })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function createToken(req: Request): Promise<Response> {
|
|
23
|
+
const user = requireUser(req)
|
|
24
|
+
const project = findProject(param(req, "project"))
|
|
25
|
+
requireProjectRole(user, project.id, "admin")
|
|
26
|
+
const app = findApp(project.id, param(req, "app"))
|
|
27
|
+
const env = findEnv(project.id, param(req, "env"))
|
|
28
|
+
assertEnvAccess(user, env)
|
|
29
|
+
const body = await readBody(req)
|
|
30
|
+
const name = optionalString(body, "name")
|
|
31
|
+
|
|
32
|
+
const issued = issueAppToken(app.id, env.id, user.id, name)
|
|
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)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function revokeToken(req: Request): Response {
|
|
40
|
+
const user = requireUser(req)
|
|
41
|
+
const project = findProject(param(req, "project"))
|
|
42
|
+
requireProjectRole(user, project.id, "admin")
|
|
43
|
+
const app = findApp(project.id, param(req, "app"))
|
|
44
|
+
const env = findEnv(project.id, param(req, "env"))
|
|
45
|
+
assertEnvAccess(user, env)
|
|
46
|
+
const tokenId = param(req, "tokenId")
|
|
47
|
+
const existing = getDb()
|
|
48
|
+
.query(`SELECT 1 FROM "AppToken" WHERE id = ? AND appId = ? AND environmentId = ?`)
|
|
49
|
+
.get(tokenId, app.id, env.id)
|
|
50
|
+
if (!existing) throw new HttpError(404, "token_not_found", "令牌不存在")
|
|
51
|
+
getDb().query(`UPDATE "AppToken" SET revoked = 1 WHERE id = ?`).run(tokenId)
|
|
52
|
+
return ok({ revoked: true })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const tokenRoutes = {
|
|
56
|
+
"/v1/projects/:project/apps/:app/envs/:env/tokens": { GET: route(listTokens), POST: route(createToken) },
|
|
57
|
+
"/v1/projects/:project/apps/:app/envs/:env/tokens/:tokenId": { DELETE: route(revokeToken) },
|
|
58
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getDb } from "../db/client.ts"
|
|
2
|
+
import type { UserRow } from "../db/types.ts"
|
|
3
|
+
import { HttpError, ok, readBody, requireString, route } from "../http/response.ts"
|
|
4
|
+
import { requireSuperAdmin, requireUser } from "../shared/auth.ts"
|
|
5
|
+
import { EMAIL_RE } from "../shared/consts.ts"
|
|
6
|
+
import { hashPassword, verifyPassword } from "../shared/password.ts"
|
|
7
|
+
import { publicUser } from "../shared/serialize.ts"
|
|
8
|
+
import { issueHumanToken } from "../shared/token.ts"
|
|
9
|
+
import { newId } from "../shared/utils.ts"
|
|
10
|
+
|
|
11
|
+
async function login(req: Request): Promise<Response> {
|
|
12
|
+
const body = await readBody(req)
|
|
13
|
+
const account = requireString(body, "account", "账号")
|
|
14
|
+
const password = requireString(body, "password", "密码")
|
|
15
|
+
|
|
16
|
+
const db = getDb()
|
|
17
|
+
const user = (db.query(`SELECT * FROM "User" WHERE email = ?`).get(account) ??
|
|
18
|
+
db.query(`SELECT * FROM "User" WHERE name = ?`).get(account)) as UserRow | null
|
|
19
|
+
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
|
20
|
+
throw new HttpError(401, "invalid_credentials", "账号或密码错误")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const token = issueHumanToken(user.id, "cli")
|
|
24
|
+
return ok({ token: token.raw, user: publicUser(user) })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function listUsers(req: Request): Response {
|
|
28
|
+
requireUser(req)
|
|
29
|
+
const rows = getDb().query(`SELECT * FROM "User" ORDER BY createdAt`).all() as UserRow[]
|
|
30
|
+
return ok({ users: rows.map(publicUser) })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function createUser(req: Request): Promise<Response> {
|
|
34
|
+
const actor = requireUser(req)
|
|
35
|
+
requireSuperAdmin(actor)
|
|
36
|
+
const body = await readBody(req)
|
|
37
|
+
const email = requireString(body, "email", "邮箱")
|
|
38
|
+
const name = requireString(body, "name", "用户名")
|
|
39
|
+
const password = requireString(body, "password", "密码")
|
|
40
|
+
const isSuperAdmin = body.isSuperAdmin === true ? 1 : 0
|
|
41
|
+
|
|
42
|
+
if (!EMAIL_RE.test(email)) throw new HttpError(400, "invalid_field", "邮箱格式不正确")
|
|
43
|
+
if (password.length < 8) throw new HttpError(400, "invalid_field", "密码至少 8 位")
|
|
44
|
+
|
|
45
|
+
const db = getDb()
|
|
46
|
+
if (db.query(`SELECT 1 FROM "User" WHERE email = ?`).get(email)) {
|
|
47
|
+
throw new HttpError(409, "conflict", "邮箱已被占用")
|
|
48
|
+
}
|
|
49
|
+
if (db.query(`SELECT 1 FROM "User" WHERE name = ?`).get(name)) {
|
|
50
|
+
throw new HttpError(409, "conflict", "用户名已被占用")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const id = newId()
|
|
54
|
+
db.query(`INSERT INTO "User" (id, email, name, passwordHash, isSuperAdmin) VALUES (?, ?, ?, ?, ?)`).run(
|
|
55
|
+
id,
|
|
56
|
+
email,
|
|
57
|
+
name,
|
|
58
|
+
await hashPassword(password),
|
|
59
|
+
isSuperAdmin,
|
|
60
|
+
)
|
|
61
|
+
const created = db.query(`SELECT * FROM "User" WHERE id = ?`).get(id) as UserRow
|
|
62
|
+
return ok({ user: publicUser(created) }, 201)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function whoami(req: Request): Response {
|
|
66
|
+
const user = requireUser(req)
|
|
67
|
+
return ok({ user })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const userRoutes = {
|
|
71
|
+
"/v1/auth/login": { POST: route(login) },
|
|
72
|
+
"/v1/auth/whoami": { GET: route(whoami) },
|
|
73
|
+
"/v1/users": { GET: route(listUsers), POST: route(createUser) },
|
|
74
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getDb } from "../db/client.ts"
|
|
2
|
+
import type { EnvironmentRow, ProjectRole, UserRow } from "../db/types.ts"
|
|
3
|
+
import { HttpError } from "../http/response.ts"
|
|
4
|
+
import { ROLE_RANK } from "./consts.ts"
|
|
5
|
+
import { isAppToken, isHumanToken, verifyAppToken, verifyHumanToken } from "./token.ts"
|
|
6
|
+
import type { AppPrincipal, AuthUser } from "./types.ts"
|
|
7
|
+
|
|
8
|
+
function bearer(req: Request): string {
|
|
9
|
+
const header = req.headers.get("authorization") ?? ""
|
|
10
|
+
const match = header.match(/^Bearer\s+(.+)$/i)
|
|
11
|
+
if (!match?.[1]) throw new HttpError(401, "unauthorized", "缺少访问凭证")
|
|
12
|
+
return match[1].trim()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toAuthUser(row: UserRow): AuthUser {
|
|
16
|
+
return { id: row.id, email: row.email, name: row.name, isSuperAdmin: row.isSuperAdmin === 1 }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function requireUser(req: Request): AuthUser {
|
|
20
|
+
const raw = bearer(req)
|
|
21
|
+
if (!isHumanToken(raw)) throw new HttpError(401, "unauthorized", "请使用用户凭证登录后再操作")
|
|
22
|
+
const user = verifyHumanToken(raw)
|
|
23
|
+
if (!user) throw new HttpError(401, "unauthorized", "凭证无效或已过期,请重新登录")
|
|
24
|
+
return toAuthUser(user)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function requireAppPrincipal(req: Request): AppPrincipal {
|
|
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", "应用令牌无效或已撤销")
|
|
32
|
+
return { tokenId: token.id, appId: token.appId, environmentId: token.environmentId }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function membershipRole(userId: string, projectId: string): ProjectRole | null {
|
|
36
|
+
const row = getDb()
|
|
37
|
+
.query(`SELECT role FROM "ProjectMember" WHERE projectId = ? AND userId = ?`)
|
|
38
|
+
.get(projectId, userId) as { role: ProjectRole } | null
|
|
39
|
+
return row?.role ?? null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function requireSuperAdmin(user: AuthUser): void {
|
|
43
|
+
if (!user.isSuperAdmin) throw new HttpError(403, "forbidden", "该操作仅超级管理员可执行")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function requireProjectRole(user: AuthUser, projectId: string, minimum: ProjectRole): void {
|
|
47
|
+
if (user.isSuperAdmin) return
|
|
48
|
+
const role = membershipRole(user.id, projectId)
|
|
49
|
+
if (!role || (ROLE_RANK[role] ?? 0) < (ROLE_RANK[minimum] ?? 0)) {
|
|
50
|
+
throw new HttpError(403, "forbidden", "权限不足,无法执行该操作")
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function assertEnvAccess(user: AuthUser, env: EnvironmentRow): void {
|
|
55
|
+
if (env.sensitive === 1 && !user.isSuperAdmin) {
|
|
56
|
+
throw new HttpError(403, "forbidden", "敏感环境仅超级管理员可访问")
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getDb } from "../db/client.ts"
|
|
2
|
+
import type { UserRow } from "../db/types.ts"
|
|
3
|
+
import { getEnv } from "./env.ts"
|
|
4
|
+
import { hashPassword } from "./password.ts"
|
|
5
|
+
import { newId } from "./utils.ts"
|
|
6
|
+
|
|
7
|
+
export async function bootstrapAdmin(): Promise<void> {
|
|
8
|
+
const db = getDb()
|
|
9
|
+
const hasSuperAdmin = db.query(`SELECT 1 FROM "User" WHERE isSuperAdmin = 1 LIMIT 1`).get()
|
|
10
|
+
if (hasSuperAdmin) return
|
|
11
|
+
|
|
12
|
+
const { adminEmail: email, adminPassword: password, adminName: name } = getEnv()
|
|
13
|
+
if (!email || !password) {
|
|
14
|
+
throw new Error("数据库中尚无超管账号,请通过环境变量 CONFIG_ADMIN_EMAIL 和 CONFIG_ADMIN_PASSWORD 配置初始超管")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const existing = db.query(`SELECT * FROM "User" WHERE email = ?`).get(email) as UserRow | null
|
|
18
|
+
if (existing) {
|
|
19
|
+
db.query(`UPDATE "User" SET isSuperAdmin = 1 WHERE id = ?`).run(existing.id)
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
db.query(`INSERT INTO "User" (id, email, name, passwordHash, isSuperAdmin) VALUES (?, ?, ?, ?, 1)`).run(
|
|
24
|
+
newId(),
|
|
25
|
+
email,
|
|
26
|
+
name,
|
|
27
|
+
await hashPassword(password),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const API_PREFIX = "/v1"
|
|
2
|
+
|
|
3
|
+
export const HUMAN_TOKEN_PREFIX = "atmu"
|
|
4
|
+
export const APP_TOKEN_PREFIX = "atma"
|
|
5
|
+
export const TOKEN_BYTES = 32
|
|
6
|
+
|
|
7
|
+
export const SLUG_RE = /^[a-z][a-z0-9-]*$/
|
|
8
|
+
export const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
|
|
9
|
+
|
|
10
|
+
export const ROLE_RANK: Record<string, number> = { admin: 3, collaborator: 2, viewer: 1 }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { type Static, Type } from "typebox"
|
|
3
|
+
import { Value } from "typebox/value"
|
|
4
|
+
|
|
5
|
+
export const envSchema = Type.Object({
|
|
6
|
+
CONFIG_PORT: Type.Optional(Type.String({ pattern: "^\\d+$", default: "4921" })),
|
|
7
|
+
CONFIG_DB_PATH: Type.Optional(Type.String({ default: path.join(process.cwd(), "data", "config.db") })),
|
|
8
|
+
CONFIG_ADMIN_NAME: Type.Optional(Type.String({ minLength: 1, default: "admin" })),
|
|
9
|
+
CONFIG_ADMIN_EMAIL: Type.Optional(Type.String()),
|
|
10
|
+
CONFIG_ADMIN_PASSWORD: Type.Optional(Type.String({ minLength: 1 })),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
type RawEnv = Static<typeof envSchema>
|
|
14
|
+
|
|
15
|
+
export interface Env {
|
|
16
|
+
port: number
|
|
17
|
+
dbPath: string
|
|
18
|
+
adminName: string
|
|
19
|
+
adminEmail: string | undefined
|
|
20
|
+
adminPassword: string | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let cached: Env | undefined
|
|
24
|
+
|
|
25
|
+
export function getEnv(): Env {
|
|
26
|
+
if (cached) return cached
|
|
27
|
+
const raw: RawEnv = {
|
|
28
|
+
CONFIG_PORT: process.env.CONFIG_PORT,
|
|
29
|
+
CONFIG_DB_PATH: process.env.CONFIG_DB_PATH,
|
|
30
|
+
CONFIG_ADMIN_NAME: process.env.CONFIG_ADMIN_NAME,
|
|
31
|
+
CONFIG_ADMIN_EMAIL: process.env.CONFIG_ADMIN_EMAIL,
|
|
32
|
+
CONFIG_ADMIN_PASSWORD: process.env.CONFIG_ADMIN_PASSWORD,
|
|
33
|
+
}
|
|
34
|
+
const filled = Value.Default(envSchema, raw) as Required<
|
|
35
|
+
Pick<RawEnv, "CONFIG_PORT" | "CONFIG_DB_PATH" | "CONFIG_ADMIN_NAME">
|
|
36
|
+
> &
|
|
37
|
+
RawEnv
|
|
38
|
+
const errors = [...Value.Errors(envSchema, filled)]
|
|
39
|
+
if (errors.length) {
|
|
40
|
+
const msgs = errors.map((e) => ` ${e.instancePath || "/"}: ${e.message}`).join("\n")
|
|
41
|
+
throw new Error(`环境变量配置错误:\n${msgs}`)
|
|
42
|
+
}
|
|
43
|
+
cached = {
|
|
44
|
+
port: Number.parseInt(filled.CONFIG_PORT, 10),
|
|
45
|
+
dbPath: filled.CONFIG_DB_PATH,
|
|
46
|
+
adminName: filled.CONFIG_ADMIN_NAME,
|
|
47
|
+
adminEmail: filled.CONFIG_ADMIN_EMAIL,
|
|
48
|
+
adminPassword: filled.CONFIG_ADMIN_PASSWORD,
|
|
49
|
+
}
|
|
50
|
+
return cached
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateEnv(): void {
|
|
54
|
+
getEnv()
|
|
55
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { isPlainObject, jsonType } from "./utils.ts"
|
|
2
|
+
|
|
3
|
+
export interface SchemaError {
|
|
4
|
+
path: string
|
|
5
|
+
message: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function validateAgainstSchema(schema: unknown, data: unknown): SchemaError[] {
|
|
9
|
+
if (!isPlainObject(schema)) return []
|
|
10
|
+
const errors: SchemaError[] = []
|
|
11
|
+
walk(schema, data, "$", errors)
|
|
12
|
+
return errors
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function walk(schema: Record<string, unknown>, data: unknown, path: string, errors: SchemaError[]): void {
|
|
16
|
+
const expected = schema.type
|
|
17
|
+
if (typeof expected === "string" && !matchesType(expected, data)) {
|
|
18
|
+
errors.push({ path, message: `期望类型 ${expected},实际为 ${jsonType(data)}` })
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((opt) => JSON.stringify(opt) === JSON.stringify(data))) {
|
|
23
|
+
errors.push({ path, message: `取值不在允许范围内` })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (expected === "object" || isPlainObject(data)) {
|
|
27
|
+
const props = isPlainObject(schema.properties) ? schema.properties : {}
|
|
28
|
+
const required = Array.isArray(schema.required) ? schema.required : []
|
|
29
|
+
const obj = isPlainObject(data) ? data : {}
|
|
30
|
+
|
|
31
|
+
for (const key of required) {
|
|
32
|
+
if (typeof key === "string" && !(key in obj)) {
|
|
33
|
+
errors.push({ path: `${path}.${key}`, message: "缺少必填字段" })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (schema.additionalProperties === false) {
|
|
38
|
+
for (const key of Object.keys(obj)) {
|
|
39
|
+
if (!(key in props)) errors.push({ path: `${path}.${key}`, message: "不允许的额外字段" })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const [key, sub] of Object.entries(props)) {
|
|
44
|
+
if (key in obj && isPlainObject(sub)) walk(sub, obj[key], `${path}.${key}`, errors)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (expected === "array" && Array.isArray(data) && isPlainObject(schema.items)) {
|
|
49
|
+
for (const [index, item] of data.entries()) {
|
|
50
|
+
walk(schema.items as Record<string, unknown>, item, `${path}[${index}]`, errors)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function matchesType(expected: string, data: unknown): boolean {
|
|
56
|
+
switch (expected) {
|
|
57
|
+
case "object":
|
|
58
|
+
return isPlainObject(data)
|
|
59
|
+
case "array":
|
|
60
|
+
return Array.isArray(data)
|
|
61
|
+
case "string":
|
|
62
|
+
return typeof data === "string"
|
|
63
|
+
case "number":
|
|
64
|
+
return typeof data === "number"
|
|
65
|
+
case "integer":
|
|
66
|
+
return typeof data === "number" && Number.isInteger(data)
|
|
67
|
+
case "boolean":
|
|
68
|
+
return typeof data === "boolean"
|
|
69
|
+
case "null":
|
|
70
|
+
return data === null
|
|
71
|
+
default:
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getDb } from "../db/client.ts"
|
|
2
|
+
import type { AppRow, EnvironmentRow, ProjectRow, UserRow } from "../db/types.ts"
|
|
3
|
+
import { HttpError } from "../http/response.ts"
|
|
4
|
+
|
|
5
|
+
export function findProject(slug: string): ProjectRow {
|
|
6
|
+
const row = getDb().query(`SELECT * FROM "Project" WHERE slug = ?`).get(slug) as ProjectRow | null
|
|
7
|
+
if (!row) throw new HttpError(404, "project_not_found", `项目不存在:${slug}`)
|
|
8
|
+
return row
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function findEnv(projectId: string, slug: string): EnvironmentRow {
|
|
12
|
+
const row = getDb()
|
|
13
|
+
.query(`SELECT * FROM "Environment" WHERE projectId = ? AND slug = ?`)
|
|
14
|
+
.get(projectId, slug) as EnvironmentRow | null
|
|
15
|
+
if (!row) throw new HttpError(404, "env_not_found", `环境不存在:${slug}`)
|
|
16
|
+
return row
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function findApp(projectId: string, slug: string): AppRow {
|
|
20
|
+
const row = getDb()
|
|
21
|
+
.query(`SELECT * FROM "App" WHERE projectId = ? AND slug = ?`)
|
|
22
|
+
.get(projectId, slug) as AppRow | null
|
|
23
|
+
if (!row) throw new HttpError(404, "app_not_found", `应用不存在:${slug}`)
|
|
24
|
+
return row
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function findUserByAccount(account: string): UserRow {
|
|
28
|
+
const db = getDb()
|
|
29
|
+
const row = (db.query(`SELECT * FROM "User" WHERE email = ?`).get(account) ??
|
|
30
|
+
db.query(`SELECT * FROM "User" WHERE name = ?`).get(account)) as UserRow | null
|
|
31
|
+
if (!row) throw new HttpError(404, "user_not_found", `用户不存在:${account}`)
|
|
32
|
+
return row
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Value } from "typebox/value"
|
|
2
|
+
import type { ChangeKind } from "../db/types.ts"
|
|
3
|
+
|
|
4
|
+
export function diffChangeKind(prev: Record<string, unknown> | null, next: Record<string, unknown>): ChangeKind | null {
|
|
5
|
+
if (!prev) return "major"
|
|
6
|
+
|
|
7
|
+
const edits = Value.Diff(prev, next)
|
|
8
|
+
if (edits.length === 0) return null
|
|
9
|
+
|
|
10
|
+
let hasMajor = false
|
|
11
|
+
let hasMinor = false
|
|
12
|
+
for (const edit of edits) {
|
|
13
|
+
if (edit.type === "delete") {
|
|
14
|
+
hasMajor = true
|
|
15
|
+
break
|
|
16
|
+
}
|
|
17
|
+
if (edit.type === "insert") hasMinor = true
|
|
18
|
+
}
|
|
19
|
+
if (hasMajor) return "major"
|
|
20
|
+
if (hasMinor) return "minor"
|
|
21
|
+
return "patch"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function bumpSemver(prev: string | null, kind: ChangeKind): string {
|
|
25
|
+
if (!prev) return "1.0.0"
|
|
26
|
+
const parts = prev.split(".")
|
|
27
|
+
const major = Number.parseInt(parts[0] ?? "0", 10)
|
|
28
|
+
const minor = Number.parseInt(parts[1] ?? "0", 10)
|
|
29
|
+
const patch = Number.parseInt(parts[2] ?? "0", 10)
|
|
30
|
+
if (kind === "major") return `${major + 1}.0.0`
|
|
31
|
+
if (kind === "minor") return `${major}.${minor + 1}.0`
|
|
32
|
+
return `${major}.${minor}.${patch + 1}`
|
|
33
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppRow,
|
|
3
|
+
AppTokenRow,
|
|
4
|
+
EnvironmentRow,
|
|
5
|
+
ProjectMemberRow,
|
|
6
|
+
ProjectRow,
|
|
7
|
+
ReleaseRow,
|
|
8
|
+
UserRow,
|
|
9
|
+
} from "../db/types.ts"
|
|
10
|
+
|
|
11
|
+
export function publicUser(row: UserRow) {
|
|
12
|
+
return {
|
|
13
|
+
id: row.id,
|
|
14
|
+
email: row.email,
|
|
15
|
+
name: row.name,
|
|
16
|
+
isSuperAdmin: row.isSuperAdmin === 1,
|
|
17
|
+
createdAt: row.createdAt,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function publicProject(row: ProjectRow) {
|
|
22
|
+
return {
|
|
23
|
+
id: row.id,
|
|
24
|
+
slug: row.slug,
|
|
25
|
+
name: row.name,
|
|
26
|
+
description: row.description,
|
|
27
|
+
createdAt: row.createdAt,
|
|
28
|
+
updatedAt: row.updatedAt,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function publicMember(row: ProjectMemberRow & Pick<UserRow, "email" | "name">) {
|
|
33
|
+
return { userId: row.userId, email: row.email, name: row.name, role: row.role, joinedAt: row.joinedAt }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function publicEnv(row: EnvironmentRow) {
|
|
37
|
+
return {
|
|
38
|
+
id: row.id,
|
|
39
|
+
slug: row.slug,
|
|
40
|
+
name: row.name,
|
|
41
|
+
description: row.description,
|
|
42
|
+
sortOrder: row.sortOrder,
|
|
43
|
+
sensitive: row.sensitive === 1,
|
|
44
|
+
createdAt: row.createdAt,
|
|
45
|
+
updatedAt: row.updatedAt,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function publicApp(row: AppRow) {
|
|
50
|
+
return {
|
|
51
|
+
id: row.id,
|
|
52
|
+
slug: row.slug,
|
|
53
|
+
name: row.name,
|
|
54
|
+
description: row.description,
|
|
55
|
+
schema: row.schema ? JSON.parse(row.schema) : {},
|
|
56
|
+
createdAt: row.createdAt,
|
|
57
|
+
updatedAt: row.updatedAt,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function publicRelease(row: ReleaseRow, withConfig: boolean) {
|
|
62
|
+
return {
|
|
63
|
+
id: row.id,
|
|
64
|
+
semver: row.semver,
|
|
65
|
+
changeKind: row.changeKind,
|
|
66
|
+
status: row.status,
|
|
67
|
+
notes: row.notes,
|
|
68
|
+
createdBy: row.createdBy,
|
|
69
|
+
createdAt: row.createdAt,
|
|
70
|
+
config: withConfig ? JSON.parse(row.snapshot) : undefined,
|
|
71
|
+
schema: withConfig ? JSON.parse(row.schemaSnapshot) : undefined,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function publicAppToken(row: AppTokenRow) {
|
|
76
|
+
return {
|
|
77
|
+
id: row.id,
|
|
78
|
+
name: row.name,
|
|
79
|
+
tokenPrefix: row.tokenPrefix,
|
|
80
|
+
revoked: row.revoked === 1,
|
|
81
|
+
lastUsedAt: row.lastUsedAt,
|
|
82
|
+
createdAt: row.createdAt,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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"
|
|
4
|
+
import type { IssuedToken } from "./types.ts"
|
|
5
|
+
import { newId, nowSec } from "./utils.ts"
|
|
6
|
+
|
|
7
|
+
function randomSecret(): string {
|
|
8
|
+
const buf = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))
|
|
9
|
+
return Buffer.from(buf).toString("base64url")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hashSecret(raw: string): string {
|
|
13
|
+
return new Bun.CryptoHasher("sha256").update(raw).digest("hex")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isHumanToken(raw: string): boolean {
|
|
17
|
+
return raw.startsWith(`${HUMAN_TOKEN_PREFIX}_`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isAppToken(raw: string): boolean {
|
|
21
|
+
return raw.startsWith(`${APP_TOKEN_PREFIX}_`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function issueHumanToken(userId: string, name?: string): IssuedToken {
|
|
25
|
+
const raw = `${HUMAN_TOKEN_PREFIX}_${randomSecret()}`
|
|
26
|
+
const prefix = raw.slice(0, 14)
|
|
27
|
+
getDb()
|
|
28
|
+
.query(`INSERT INTO "Token" (id, userId, name, tokenHash, tokenPrefix) VALUES (?, ?, ?, ?, ?)`)
|
|
29
|
+
.run(newId(), userId, name ?? null, hashSecret(raw), prefix)
|
|
30
|
+
return { raw, prefix }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function verifyHumanToken(raw: string): UserRow | null {
|
|
34
|
+
const db = getDb()
|
|
35
|
+
const row = db.query(`SELECT * FROM "Token" WHERE tokenHash = ?`).get(hashSecret(raw)) as TokenRow | null
|
|
36
|
+
if (!row || row.revoked) return null
|
|
37
|
+
if (row.expiresAt && row.expiresAt < nowSec()) return null
|
|
38
|
+
db.query(`UPDATE "Token" SET lastUsedAt = ? WHERE id = ?`).run(nowSec(), row.id)
|
|
39
|
+
return db.query(`SELECT * FROM "User" WHERE id = ?`).get(row.userId) as UserRow | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function issueAppToken(appId: string, environmentId: string, createdBy: string, name?: string): IssuedToken {
|
|
43
|
+
const raw = `${APP_TOKEN_PREFIX}_${randomSecret()}`
|
|
44
|
+
const prefix = raw.slice(0, 14)
|
|
45
|
+
getDb()
|
|
46
|
+
.query(
|
|
47
|
+
`INSERT INTO "AppToken" (id, appId, environmentId, name, tokenHash, tokenPrefix, createdBy) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
48
|
+
)
|
|
49
|
+
.run(newId(), appId, environmentId, name ?? null, hashSecret(raw), prefix, createdBy)
|
|
50
|
+
return { raw, prefix }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function verifyAppToken(raw: string): AppTokenRow | null {
|
|
54
|
+
const db = getDb()
|
|
55
|
+
const row = db.query(`SELECT * FROM "AppToken" WHERE tokenHash = ?`).get(hashSecret(raw)) as AppTokenRow | null
|
|
56
|
+
if (!row || row.revoked) return null
|
|
57
|
+
db.query(`UPDATE "AppToken" SET lastUsedAt = ? WHERE id = ?`).run(nowSec(), row.id)
|
|
58
|
+
return row
|
|
59
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ProjectRole } from "../db/types.ts"
|
|
2
|
+
|
|
3
|
+
export interface AuthUser {
|
|
4
|
+
id: string
|
|
5
|
+
email: string
|
|
6
|
+
name: string
|
|
7
|
+
isSuperAdmin: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AppPrincipal {
|
|
11
|
+
tokenId: string
|
|
12
|
+
appId: string
|
|
13
|
+
environmentId: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IssuedToken {
|
|
17
|
+
raw: string
|
|
18
|
+
prefix: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface JsonObject {
|
|
22
|
+
[key: string]: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type { ProjectRole }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function newId(): string {
|
|
2
|
+
return Bun.randomUUIDv7().replaceAll("-", "")
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function nowSec(): number {
|
|
6
|
+
return Math.floor(Date.now() / 1000)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function jsonType(value: unknown): string {
|
|
10
|
+
if (value === null) return "null"
|
|
11
|
+
if (Array.isArray(value)) return "array"
|
|
12
|
+
return typeof value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "Preserve",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"types": ["bun"],
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"strict": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"noFallthroughCasesInSwitch": true,
|
|
17
|
+
"noUncheckedIndexedAccess": true,
|
|
18
|
+
"noImplicitOverride": true,
|
|
19
|
+
"noUnusedLocals": false,
|
|
20
|
+
"noUnusedParameters": false,
|
|
21
|
+
"noPropertyAccessFromIndexSignature": false,
|
|
22
|
+
"paths": {
|
|
23
|
+
"~/*": ["./src/*"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"include": ["src"]
|
|
27
|
+
}
|