@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,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,7 @@
1
+ export function hashPassword(plain: string): Promise<string> {
2
+ return Bun.password.hash(plain)
3
+ }
4
+
5
+ export function verifyPassword(plain: string, hash: string): Promise<boolean> {
6
+ return Bun.password.verify(plain, hash)
7
+ }
@@ -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
+ }