init studio auth backend

This commit is contained in:
2026-07-01 17:36:34 +09:00
commit 87f6f46d3c
35 changed files with 2492 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.env
dist
*.log
.env.*
coverage
+14
View File
@@ -0,0 +1,14 @@
FROM oven/bun:1.2
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile || bun install
COPY prisma ./prisma
RUN bunx prisma generate
COPY src ./src
EXPOSE 4100
CMD ["bun", "src/index.ts"]
+171
View File
@@ -0,0 +1,171 @@
# ATNMY STDO Auth
Central JWT auth server for Autonomy Studio and launched apps.
## Stack
- Bun
- Hono
- PostgreSQL
- Redis
- Prisma
- JWT access tokens
- Opaque refresh tokens stored in Redis
- One-time handoff tokens for app SSO stored in Redis
## Local Run
```bash
cp .env.example .env
docker compose up -d postgres redis
bun install
bun run db:generate
bun run db:migrate
bun run db:seed
bun run dev
```
Default server:
```text
http://localhost:4100
```
Default backing services:
```text
PostgreSQL: localhost:5434
Redis: localhost:6380
```
Default seeded admin:
```text
admin@futuredrive.net / admin1234
```
Change this before using the service outside local development.
## API
### Login
```http
POST /auth/login
Content-Type: application/json
{
"email": "admin@futuredrive.net",
"password": "admin1234"
}
```
Returns:
```json
{
"accessToken": "...",
"accessTokenExpiresIn": 900,
"refreshToken": "...",
"refreshTokenExpiresIn": 2592000,
"user": {
"id": "...",
"email": "admin@futuredrive.net",
"name": "ATNMY Studio Admin",
"role": "ADMIN",
"roles": ["ADMIN"]
}
}
```
### Current User
```http
GET /auth/me
Authorization: Bearer <accessToken>
```
### Refresh
```http
POST /auth/refresh
Content-Type: application/json
{
"refreshToken": "<refreshToken>"
}
```
Refresh tokens are rotated on every use.
### Logout
```http
POST /auth/logout
Content-Type: application/json
{
"refreshToken": "<refreshToken>"
}
```
### Create App Handoff Token
Launcher calls this before launching ADS IDE or another app.
```http
POST /auth/handoff
Authorization: Bearer <launcherAccessToken>
Content-Type: application/json
{
"audienceApp": "ADS IDE"
}
```
Returns a short-lived one-time token:
```json
{
"handoffToken": "...",
"expiresIn": 60,
"audienceApp": "ADS IDE"
}
```
### Exchange App Handoff Token
The launched app calls this on startup.
```http
POST /auth/handoff/exchange
Content-Type: application/json
{
"handoffToken": "...",
"audienceApp": "ADS IDE"
}
```
Returns the launched app's own access/refresh token pair.
## Launcher Integration Sketch
```ts
const handoff = await fetch(`${AUTH_URL}/auth/handoff`, {
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
'content-type': 'application/json'
},
body: JSON.stringify({ audienceApp: 'ADS IDE' })
}).then((r) => r.json())
spawn(execBin, [
`--url=${encodeURIComponent(url)}`,
`--handoff-token=${encodeURIComponent(handoff.handoffToken)}`,
`--auth-url=${encodeURIComponent(AUTH_URL)}`
])
```
ADS IDE then calls `/auth/handoff/exchange` and stores the returned session.
+116
View File
@@ -0,0 +1,116 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "atnmy-stdo-auth",
"dependencies": {
"@hono/node-server": "^1.14.4",
"@prisma/client": "^6.9.0",
"@types/nodemailer": "^8.0.0",
"hono": "^4.7.11",
"jose": "^6.0.11",
"nodemailer": "^8.0.10",
"zod": "^3.25.48",
},
"devDependencies": {
"@types/bun": "^1.2.15",
"prisma": "^6.9.0",
"typescript": "^5.8.3",
},
},
},
"packages": {
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@prisma/client": ["@prisma/client@6.19.3", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg=="],
"@prisma/config": ["@prisma/config@6.19.3", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.21.0", "empathic": "2.0.0" } }, "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ=="],
"@prisma/debug": ["@prisma/debug@6.19.3", "", {}, "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw=="],
"@prisma/engines": ["@prisma/engines@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "@prisma/fetch-engine": "6.19.3", "@prisma/get-platform": "6.19.3" } }, "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g=="],
"@prisma/engines-version": ["@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "", {}, "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "@prisma/get-platform": "6.19.3" } }, "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA=="],
"@prisma/get-platform": ["@prisma/get-platform@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3" } }, "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/nodemailer": ["@types/nodemailer@8.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"nodemailer": ["nodemailer@8.0.10", "", {}, "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ=="],
"nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
"prisma": ["prisma@6.19.3", "", { "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
}
}
+38
View File
@@ -0,0 +1,38 @@
services:
postgres:
image: postgres:17-alpine
container_name: atnmy-stdo-auth-postgres
command: postgres -c timezone=Asia/Seoul
environment:
POSTGRES_DB: atnmy_auth
POSTGRES_USER: atnmy_auth
POSTGRES_PASSWORD: future1234!@
ports:
- "5434:5432"
volumes:
- atnmy-auth-postgres-17:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U atnmy_auth -d atnmy_auth"]
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7-alpine
container_name: atnmy-stdo-auth-redis
command: ["redis-server", "--appendonly", "yes"]
ports:
- "6380:6379"
volumes:
- atnmy-auth-redis:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
volumes:
atnmy-auth-postgres-17:
atnmy-auth-redis:
+32
View File
@@ -0,0 +1,32 @@
{
"name": "atnmy-stdo-auth",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "bun src/index.ts",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "bun prisma/seed.ts"
},
"dependencies": {
"@hono/node-server": "^1.14.4",
"@prisma/client": "^6.9.0",
"@types/nodemailer": "^8.0.0",
"hono": "^4.7.11",
"jose": "^6.0.11",
"nodemailer": "^8.0.10",
"zod": "^3.25.48"
},
"devDependencies": {
"@types/bun": "^1.2.15",
"prisma": "^6.9.0",
"typescript": "^5.8.3"
},
"prisma": {
"seed": "bun prisma/seed.ts"
}
}
@@ -0,0 +1,47 @@
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'USER',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"last_login_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "refresh_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "handoff_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"audience_app" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "handoff_tokens_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
CREATE UNIQUE INDEX "handoff_tokens_token_hash_key" ON "handoff_tokens"("token_hash");
CREATE INDEX "handoff_tokens_user_id_idx" ON "handoff_tokens"("user_id");
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "handoff_tokens" ADD CONSTRAINT "handoff_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
CREATE TABLE "login_notices" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"link_url" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"starts_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"priority" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "login_notices_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "login_notices_is_active_priority_idx" ON "login_notices"("is_active", "priority");
@@ -0,0 +1,49 @@
-- CreateEnum
CREATE TYPE "OrganizationType" AS ENUM ('COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER');
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "OrganizationType" NOT NULL DEFAULT 'OTHER',
"domain" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_affiliations" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"organization_id" TEXT NOT NULL,
"department" TEXT,
"position" TEXT,
"is_primary" BOOLEAN NOT NULL DEFAULT false,
"starts_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_affiliations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organizations_name_type_key" ON "organizations"("name", "type");
-- CreateIndex
CREATE INDEX "organizations_is_active_type_idx" ON "organizations"("is_active", "type");
-- CreateIndex
CREATE INDEX "user_affiliations_user_id_is_primary_idx" ON "user_affiliations"("user_id", "is_primary");
-- CreateIndex
CREATE INDEX "user_affiliations_organization_id_idx" ON "user_affiliations"("organization_id");
-- AddForeignKey
ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
COMMENT ON TYPE "OrganizationType" IS '사용자 소속 유형. 회사, 학교, 공공기관, 연구소, 기타를 구분한다.';
COMMENT ON TABLE "organizations" IS '사용자에게 부여할 수 있는 소속 마스터. 회사, 학교, 기관, 연구소 등을 통합 관리한다.';
COMMENT ON COLUMN "organizations"."id" IS '소속 고유 ID';
COMMENT ON COLUMN "organizations"."name" IS '소속명';
COMMENT ON COLUMN "organizations"."type" IS '소속 유형';
COMMENT ON COLUMN "organizations"."domain" IS '소속 이메일/웹 도메인. 선택 입력값';
COMMENT ON COLUMN "organizations"."is_active" IS '소속 사용 여부';
COMMENT ON COLUMN "organizations"."created_at" IS '소속 생성 일시';
COMMENT ON COLUMN "organizations"."updated_at" IS '소속 수정 일시';
COMMENT ON TABLE "user_affiliations" IS '사용자와 소속의 연결 정보. 사용자 1명은 여러 소속을 가질 수 있다.';
COMMENT ON COLUMN "user_affiliations"."id" IS '사용자 소속 연결 고유 ID';
COMMENT ON COLUMN "user_affiliations"."user_id" IS '사용자 ID';
COMMENT ON COLUMN "user_affiliations"."organization_id" IS '소속 ID';
COMMENT ON COLUMN "user_affiliations"."department" IS '부서, 학과, 조직 단위명';
COMMENT ON COLUMN "user_affiliations"."position" IS '직책, 직급, 역할명';
COMMENT ON COLUMN "user_affiliations"."is_primary" IS '대표 소속 여부';
COMMENT ON COLUMN "user_affiliations"."starts_at" IS '소속 시작 일시';
COMMENT ON COLUMN "user_affiliations"."ends_at" IS '소속 종료 일시. NULL이면 현재 유효한 소속';
COMMENT ON COLUMN "user_affiliations"."created_at" IS '사용자 소속 연결 생성 일시';
COMMENT ON COLUMN "user_affiliations"."updated_at" IS '사용자 소속 연결 수정 일시';
@@ -0,0 +1,41 @@
COMMENT ON TYPE "UserRole" IS 'auth 사용자 권한. 관리자와 일반 사용자를 구분한다.';
COMMENT ON TABLE "users" IS '중앙 인증 사용자 계정';
COMMENT ON COLUMN "users"."id" IS '사용자 고유 ID';
COMMENT ON COLUMN "users"."email" IS '로그인 이메일';
COMMENT ON COLUMN "users"."password_hash" IS '해시 처리된 비밀번호';
COMMENT ON COLUMN "users"."name" IS '사용자 이름';
COMMENT ON COLUMN "users"."role" IS '사용자 권한';
COMMENT ON COLUMN "users"."is_active" IS '계정 활성 여부';
COMMENT ON COLUMN "users"."last_login_at" IS '마지막 로그인 일시';
COMMENT ON COLUMN "users"."created_at" IS '계정 생성 일시';
COMMENT ON COLUMN "users"."updated_at" IS '계정 수정 일시';
COMMENT ON TABLE "refresh_tokens" IS '로그인 세션 유지를 위한 refresh token 저장소';
COMMENT ON COLUMN "refresh_tokens"."id" IS 'refresh token 레코드 고유 ID';
COMMENT ON COLUMN "refresh_tokens"."user_id" IS '사용자 ID';
COMMENT ON COLUMN "refresh_tokens"."token_hash" IS 'refresh token 해시값';
COMMENT ON COLUMN "refresh_tokens"."expires_at" IS 'refresh token 만료 일시';
COMMENT ON COLUMN "refresh_tokens"."revoked_at" IS 'refresh token 폐기 일시';
COMMENT ON COLUMN "refresh_tokens"."created_at" IS 'refresh token 생성 일시';
COMMENT ON TABLE "handoff_tokens" IS '런처에서 개별 앱으로 인증 상태를 전달하기 위한 일회성 토큰';
COMMENT ON COLUMN "handoff_tokens"."id" IS 'handoff token 레코드 고유 ID';
COMMENT ON COLUMN "handoff_tokens"."user_id" IS '사용자 ID';
COMMENT ON COLUMN "handoff_tokens"."token_hash" IS 'handoff token 해시값';
COMMENT ON COLUMN "handoff_tokens"."audience_app" IS '토큰을 사용할 대상 앱 식별자';
COMMENT ON COLUMN "handoff_tokens"."expires_at" IS 'handoff token 만료 일시';
COMMENT ON COLUMN "handoff_tokens"."used_at" IS 'handoff token 사용 일시';
COMMENT ON COLUMN "handoff_tokens"."created_at" IS 'handoff token 생성 일시';
COMMENT ON TABLE "login_notices" IS '런처 로그인 화면에 노출할 공지사항';
COMMENT ON COLUMN "login_notices"."id" IS '로그인 공지 고유 ID';
COMMENT ON COLUMN "login_notices"."title" IS '공지 제목';
COMMENT ON COLUMN "login_notices"."body" IS '공지 본문';
COMMENT ON COLUMN "login_notices"."link_url" IS '자세히보기 링크 URL';
COMMENT ON COLUMN "login_notices"."is_active" IS '공지 활성 여부';
COMMENT ON COLUMN "login_notices"."starts_at" IS '공지 노출 시작 일시';
COMMENT ON COLUMN "login_notices"."ends_at" IS '공지 노출 종료 일시';
COMMENT ON COLUMN "login_notices"."priority" IS '공지 우선순위. 값이 높을수록 우선 노출';
COMMENT ON COLUMN "login_notices"."created_at" IS '공지 생성 일시';
COMMENT ON COLUMN "login_notices"."updated_at" IS '공지 수정 일시';
@@ -0,0 +1,56 @@
INSERT INTO "organizations" ("id", "name", "type", "domain", "is_active", "created_at", "updated_at")
VALUES
('11111111-1111-4111-8111-111111111111', '퓨처드라이브', 'COMPANY', 'futuredrive.net', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('22222222-2222-4222-8222-222222222222', '영남대학교', 'SCHOOL', 'yu.ac.kr', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("name", "type") DO UPDATE
SET
"domain" = EXCLUDED."domain",
"is_active" = true,
"updated_at" = CURRENT_TIMESTAMP;
UPDATE "user_affiliations"
SET "is_primary" = false,
"updated_at" = CURRENT_TIMESTAMP
WHERE "ends_at" IS NULL;
UPDATE "user_affiliations"
SET "is_primary" = true,
"updated_at" = CURRENT_TIMESTAMP
WHERE "organization_id" = '11111111-1111-4111-8111-111111111111'
AND "ends_at" IS NULL;
INSERT INTO "user_affiliations" (
"id",
"user_id",
"organization_id",
"department",
"position",
"is_primary",
"starts_at",
"created_at",
"updated_at"
)
SELECT
lower(
substr(md5("users"."id" || ':futuredrive'), 1, 8) || '-' ||
substr(md5("users"."id" || ':futuredrive'), 9, 4) || '-4' ||
substr(md5("users"."id" || ':futuredrive'), 14, 3) || '-8' ||
substr(md5("users"."id" || ':futuredrive'), 18, 3) || '-' ||
substr(md5("users"."id" || ':futuredrive'), 21, 12)
),
"users"."id",
'11111111-1111-4111-8111-111111111111',
NULL,
NULL,
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "users"
WHERE NOT EXISTS (
SELECT 1
FROM "user_affiliations"
WHERE "user_affiliations"."user_id" = "users"."id"
AND "user_affiliations"."organization_id" = '11111111-1111-4111-8111-111111111111'
AND "user_affiliations"."ends_at" IS NULL
);
@@ -0,0 +1,9 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
UPDATE "organizations"
SET "id" = gen_random_uuid()::text,
"updated_at" = CURRENT_TIMESTAMP
WHERE "id" IN (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222'
);
@@ -0,0 +1,57 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
UPDATE "users"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "organizations"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "refresh_tokens"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "handoff_tokens"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "user_affiliations"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "login_notices"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
ALTER TABLE "refresh_tokens" DROP CONSTRAINT "refresh_tokens_user_id_fkey";
ALTER TABLE "handoff_tokens" DROP CONSTRAINT "handoff_tokens_user_id_fkey";
ALTER TABLE "user_affiliations" DROP CONSTRAINT "user_affiliations_user_id_fkey";
ALTER TABLE "user_affiliations" DROP CONSTRAINT "user_affiliations_organization_id_fkey";
ALTER TABLE "users"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
ALTER TABLE "organizations"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
ALTER TABLE "refresh_tokens"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid,
ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid;
ALTER TABLE "handoff_tokens"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid,
ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid;
ALTER TABLE "user_affiliations"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid,
ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid,
ALTER COLUMN "organization_id" TYPE uuid USING "organization_id"::uuid;
ALTER TABLE "login_notices"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "handoff_tokens" ADD CONSTRAINT "handoff_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,68 @@
CREATE TYPE "LicenseCode" AS ENUM ('FREE', 'EDUCATION', 'PROFESSIONAL');
CREATE TABLE "licenses" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"code" "LicenseCode" NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "licenses_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "user_licenses" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"license_id" UUID NOT NULL,
"starts_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3),
"assigned_by" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_licenses_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "licenses_code_key" ON "licenses"("code");
CREATE INDEX "licenses_is_active_idx" ON "licenses"("is_active");
CREATE INDEX "user_licenses_user_id_starts_at_idx" ON "user_licenses"("user_id", "starts_at");
CREATE INDEX "user_licenses_license_id_idx" ON "user_licenses"("license_id");
ALTER TABLE "user_licenses" ADD CONSTRAINT "user_licenses_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_licenses" ADD CONSTRAINT "user_licenses_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO "licenses" ("code", "name", "description", "updated_at")
VALUES
('FREE', 'Free', '기본 무료 라이선스', CURRENT_TIMESTAMP),
('EDUCATION', 'Education', '교육기관용 라이선스', CURRENT_TIMESTAMP),
('PROFESSIONAL', 'Professional', '전문가용 라이선스', CURRENT_TIMESTAMP);
INSERT INTO "user_licenses" ("user_id", "license_id", "starts_at")
SELECT u."id", l."id", CURRENT_TIMESTAMP
FROM "users" u
CROSS JOIN "licenses" l
WHERE l."code" = 'PROFESSIONAL'
AND NOT EXISTS (
SELECT 1
FROM "user_licenses" ul
WHERE ul."user_id" = u."id"
);
COMMENT ON TYPE "LicenseCode" IS '라이선스 코드. 무료, 교육용, 전문가용 플랜을 구분한다.';
COMMENT ON TABLE "licenses" IS 'Autonomy Studio 라이선스 마스터. 플랜 코드와 표시명을 관리한다.';
COMMENT ON COLUMN "licenses"."id" IS '라이선스 UUID.';
COMMENT ON COLUMN "licenses"."code" IS '라이선스 코드.';
COMMENT ON COLUMN "licenses"."name" IS '화면에 표시할 라이선스명.';
COMMENT ON COLUMN "licenses"."description" IS '라이선스 설명.';
COMMENT ON COLUMN "licenses"."is_active" IS '라이선스 사용 가능 여부.';
COMMENT ON COLUMN "licenses"."created_at" IS '라이선스 생성 시각.';
COMMENT ON COLUMN "licenses"."updated_at" IS '라이선스 수정 시각.';
COMMENT ON TABLE "user_licenses" IS '사용자별 라이선스 부여 이력. 현재 유효 라이선스와 기간을 관리한다.';
COMMENT ON COLUMN "user_licenses"."id" IS '사용자 라이선스 UUID.';
COMMENT ON COLUMN "user_licenses"."user_id" IS '라이선스를 부여받은 사용자 UUID.';
COMMENT ON COLUMN "user_licenses"."license_id" IS '부여된 라이선스 UUID.';
COMMENT ON COLUMN "user_licenses"."starts_at" IS '라이선스 시작 시각.';
COMMENT ON COLUMN "user_licenses"."expires_at" IS '라이선스 만료 시각. NULL이면 무기한.';
COMMENT ON COLUMN "user_licenses"."assigned_by" IS '라이선스를 부여한 관리자 UUID.';
COMMENT ON COLUMN "user_licenses"."created_at" IS '라이선스 부여 이력 생성 시각.';
@@ -0,0 +1,8 @@
ALTER TABLE "users"
ADD COLUMN "profile_image_data" BYTEA,
ADD COLUMN "profile_image_mime" TEXT,
ADD COLUMN "profile_image_updated_at" TIMESTAMP(3);
COMMENT ON COLUMN "users"."profile_image_data" IS '사용자 프로필 썸네일 이미지 바이너리. 128px WebP 압축본만 저장한다.';
COMMENT ON COLUMN "users"."profile_image_mime" IS '프로필 썸네일 MIME 타입.';
COMMENT ON COLUMN "users"."profile_image_updated_at" IS '프로필 썸네일 마지막 수정 시각.';
@@ -0,0 +1,19 @@
DO $$
DECLARE
column_record record;
BEGIN
FOR column_record IN
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type = 'timestamp without time zone'
LOOP
EXECUTE format(
'ALTER TABLE %I.%I ALTER COLUMN %I TYPE timestamptz USING %I AT TIME ZONE ''UTC''',
column_record.table_schema,
column_record.table_name,
column_record.column_name,
column_record.column_name
);
END LOOP;
END $$;
@@ -0,0 +1,12 @@
ALTER TABLE "licenses"
ADD COLUMN "default_duration_days" INTEGER DEFAULT 365;
UPDATE "licenses"
SET "default_duration_days" = 365
WHERE "default_duration_days" IS NULL;
UPDATE "user_licenses"
SET "expires_at" = "starts_at" + INTERVAL '365 days'
WHERE "expires_at" IS NULL;
COMMENT ON COLUMN "licenses"."default_duration_days" IS '사용자에게 라이선스를 부여할 때 기본 적용할 기간(일). NULL이면 무기한.';
@@ -0,0 +1,40 @@
CREATE TABLE "user_app_roles" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"app_key" text NOT NULL,
"role_code" text NOT NULL,
"assigned_by" uuid,
"assigned_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"starts_at" timestamptz(3),
"ends_at" timestamptz(3),
"created_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_app_roles_pkey" PRIMARY KEY ("id"),
CONSTRAINT "user_app_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "user_app_roles_user_id_app_key_role_code_key" ON "user_app_roles"("user_id", "app_key", "role_code");
CREATE INDEX "user_app_roles_app_key_role_code_idx" ON "user_app_roles"("app_key", "role_code");
CREATE INDEX "user_app_roles_user_id_app_key_idx" ON "user_app_roles"("user_id", "app_key");
COMMENT ON TABLE "user_app_roles" IS '사용자별 앱 역할. 전역 계정 역할과 별도로 LMS 강사, 학생, ADS IDE 개발자 같은 앱 단위 역할을 관리한다.';
COMMENT ON COLUMN "user_app_roles"."id" IS '앱 역할 매핑 UUID';
COMMENT ON COLUMN "user_app_roles"."user_id" IS '역할을 부여받은 auth 사용자 ID';
COMMENT ON COLUMN "user_app_roles"."app_key" IS '역할이 적용되는 앱 식별자. 예: LMS, ADS IDE';
COMMENT ON COLUMN "user_app_roles"."role_code" IS '앱 내부 역할 코드. 예: INSTRUCTOR, STUDENT, DEVELOPER';
COMMENT ON COLUMN "user_app_roles"."assigned_by" IS '역할을 부여한 관리자 사용자 ID';
COMMENT ON COLUMN "user_app_roles"."assigned_at" IS '역할 부여 일시';
COMMENT ON COLUMN "user_app_roles"."starts_at" IS '역할 유효 시작 일시. NULL이면 즉시 유효';
COMMENT ON COLUMN "user_app_roles"."ends_at" IS '역할 유효 종료 일시. NULL이면 무기한';
COMMENT ON COLUMN "user_app_roles"."created_at" IS '생성 일시';
COMMENT ON COLUMN "user_app_roles"."updated_at" IS '수정 일시';
INSERT INTO "user_app_roles" ("user_id", "app_key", "role_code")
SELECT "id", 'LMS', 'INSTRUCTOR'
FROM "users"
WHERE "email" IN ('teacher@futuredrive.net', 'teacher@yu.ac.kr')
ON CONFLICT ("user_id", "app_key", "role_code") DO NOTHING;
UPDATE "users"
SET "role" = 'USER'
WHERE "email" IN ('teacher@futuredrive.net', 'teacher@yu.ac.kr');
@@ -0,0 +1,2 @@
ALTER TYPE "LicenseCode" RENAME VALUE 'EDUCATION' TO 'EDU';
ALTER TYPE "LicenseCode" RENAME VALUE 'PROFESSIONAL' TO 'PRO';
+1
View File
@@ -0,0 +1 @@
provider = "postgresql"
+176
View File
@@ -0,0 +1,176 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
ADMIN
USER
}
enum OrganizationType {
COMPANY
SCHOOL
GOVERNMENT
LAB
OTHER
}
enum LicenseCode {
FREE
EDU
PRO
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
passwordHash String @map("password_hash")
name String?
role UserRole @default(USER)
isActive Boolean @default(true) @map("is_active")
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
profileImageData Bytes? @map("profile_image_data")
profileImageMime String? @map("profile_image_mime")
profileImageUpdatedAt DateTime? @map("profile_image_updated_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
refreshTokens RefreshToken[]
handoffTokens HandoffToken[]
affiliations UserAffiliation[]
userLicenses UserLicense[]
appRoles UserAppRole[]
@@map("users")
}
model UserAppRole {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
appKey String @map("app_key")
roleCode String @map("role_code")
assignedById String? @map("assigned_by") @db.Uuid
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, appKey, roleCode])
@@index([appKey, roleCode])
@@index([userId, appKey])
@@map("user_app_roles")
}
model License {
id String @id @default(uuid()) @db.Uuid
code LicenseCode @unique
name String
description String?
defaultDurationDays Int? @default(365) @map("default_duration_days")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
userLicenses UserLicense[]
@@index([isActive])
@@map("licenses")
}
model UserLicense {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
licenseId String @map("license_id") @db.Uuid
startsAt DateTime @default(now()) @map("starts_at") @db.Timestamptz(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
assignedById String? @map("assigned_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
license License @relation(fields: [licenseId], references: [id], onDelete: Restrict)
@@index([userId, startsAt])
@@index([licenseId])
@@map("user_licenses")
}
model Organization {
id String @id @default(uuid()) @db.Uuid
name String
type OrganizationType @default(OTHER)
domain String?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
affiliations UserAffiliation[]
@@unique([name, type])
@@index([isActive, type])
@@map("organizations")
}
model UserAffiliation {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
department String?
position String?
isPrimary Boolean @default(false) @map("is_primary")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
@@index([userId, isPrimary])
@@index([organizationId])
@@map("user_affiliations")
}
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("refresh_tokens")
}
model HandoffToken {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tokenHash String @unique @map("token_hash")
audienceApp String @map("audience_app")
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
usedAt DateTime? @map("used_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("handoff_tokens")
}
model LoginNotice {
id String @id @default(uuid()) @db.Uuid
title String
body String
linkUrl String? @map("link_url")
isActive Boolean @default(true) @map("is_active")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
priority Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([isActive, priority])
@@map("login_notices")
}
+137
View File
@@ -0,0 +1,137 @@
import { LicenseCode, PrismaClient, UserRole } from '@prisma/client'
import { config } from '../src/config'
import { hashPassword } from '../src/security/password'
const prisma = new PrismaClient()
async function main(): Promise<void> {
const professionalLicense = await prisma.license.upsert({
where: { code: LicenseCode.PRO },
create: {
code: LicenseCode.PRO,
name: 'Professional',
description: '전문가용 라이선스',
defaultDurationDays: 365
},
update: {
name: 'Professional',
description: '전문가용 라이선스',
defaultDurationDays: 365,
isActive: true
}
})
await prisma.license.upsert({
where: { code: LicenseCode.FREE },
create: {
code: LicenseCode.FREE,
name: 'Free',
description: '기본 무료 라이선스',
defaultDurationDays: 365
},
update: {
name: 'Free',
description: '기본 무료 라이선스',
defaultDurationDays: 365,
isActive: true
}
})
await prisma.license.upsert({
where: { code: LicenseCode.EDU },
create: {
code: LicenseCode.EDU,
name: 'Education',
description: '교육기관용 라이선스',
defaultDurationDays: 365
},
update: {
name: 'Education',
description: '교육기관용 라이선스',
defaultDurationDays: 365,
isActive: true
}
})
const admin = await prisma.user.upsert({
where: { email: config.seedAdminEmail.toLowerCase() },
create: {
email: config.seedAdminEmail.toLowerCase(),
name: 'ATNMY Studio Admin',
role: UserRole.ADMIN,
passwordHash: await hashPassword(config.seedAdminPassword)
},
update: {
name: 'ATNMY Studio Admin',
role: UserRole.ADMIN,
isActive: true
}
})
const existingAdminLicense = await prisma.userLicense.findFirst({
where: {
userId: admin.id,
expiresAt: null
}
})
if (!existingAdminLicense) {
await prisma.userLicense.create({
data: {
userId: admin.id,
licenseId: professionalLicense.id
}
})
}
await prisma.user.updateMany({
where: {
email: {
in: ['teacher@futuredrive.net', 'teacher@yu.ac.kr']
}
},
data: {
role: UserRole.USER
}
})
const instructors = await prisma.user.findMany({
where: {
email: {
in: ['teacher@futuredrive.net', 'teacher@yu.ac.kr']
}
},
select: { id: true }
})
for (const instructor of instructors) {
await prisma.userAppRole.upsert({
where: {
userId_appKey_roleCode: {
userId: instructor.id,
appKey: 'LMS',
roleCode: 'INSTRUCTOR'
}
},
create: {
userId: instructor.id,
appKey: 'LMS',
roleCode: 'INSTRUCTOR'
},
update: {
endsAt: null
}
})
}
console.log(`Seeded admin user: ${config.seedAdminEmail}`)
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})
+27
View File
@@ -0,0 +1,27 @@
import type { MiddlewareHandler } from 'hono'
import { findUserById } from './repository'
import { verifyAccessToken } from '../security/tokens'
import type { AuthUser } from '../types'
export type AuthVariables = {
user: AuthUser
}
export const authRequired: MiddlewareHandler<{ Variables: AuthVariables }> = async (c, next) => {
const header = c.req.header('authorization')
const token = header?.match(/^Bearer\s+(.+)$/i)?.[1]
if (!token) {
return c.json({ error: 'missing_authorization' }, 401)
}
try {
const claims = await verifyAccessToken(token)
const user = await findUserById(claims.sub)
if (!user) return c.json({ error: 'invalid_authorization' }, 401)
c.set('user', user)
return next()
} catch {
return c.json({ error: 'invalid_authorization' }, 401)
}
}
+534
View File
@@ -0,0 +1,534 @@
import { config } from '../config'
import { prisma } from '../prisma'
import { hashPassword, verifyPassword } from '../security/password'
import { redis } from '../redis'
import { createOpaqueToken, hashToken } from '../security/tokens'
import type { AuthUser } from '../types'
type UserWithAffiliation = {
id: string
email: string
name: string | null
role: string
createdAt: Date
lastLoginAt: Date | null
appRoles?: Array<{
appKey: string
roleCode: string
}>
affiliations?: Array<{
id: string
organizationId: string
department: string | null
position: string | null
organization: {
name: string
type: string
}
}>
userLicenses?: Array<{
id: string
startsAt: Date
expiresAt: Date | null
license: {
code: string
name: string
}
}>
}
const activeLicenseWhere = () => {
const now = new Date()
return {
startsAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
license: { isActive: true }
}
}
const currentLicenseRelation = () => ({
where: activeLicenseWhere(),
orderBy: { startsAt: 'desc' },
take: 1,
select: {
id: true,
startsAt: true,
expiresAt: true,
license: {
select: {
code: true,
name: true
}
}
}
}) as const
const userSelect = () => {
const now = new Date()
return {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
lastLoginAt: true,
affiliations: {
where: { isPrimary: true, endsAt: null },
take: 1,
select: {
id: true,
organizationId: true,
department: true,
position: true,
organization: {
select: {
name: true,
type: true
}
}
}
},
userLicenses: currentLicenseRelation(),
appRoles: {
where: {
AND: [
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
{ OR: [{ endsAt: null }, { endsAt: { gt: now } }] }
]
},
select: {
appKey: true,
roleCode: true
}
}
}
}
const toAuthUser = (user: UserWithAffiliation): AuthUser => {
const primaryAffiliation = user.affiliations?.[0]
const currentLicense = user.userLicenses?.[0]
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
appRoles: user.appRoles ?? [],
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
primaryAffiliation: primaryAffiliation
? {
id: primaryAffiliation.id,
organizationId: primaryAffiliation.organizationId,
organizationName: primaryAffiliation.organization.name,
organizationType: primaryAffiliation.organization.type,
department: primaryAffiliation.department,
position: primaryAffiliation.position
}
: null,
currentLicense: currentLicense
? {
id: currentLicense.id,
code: currentLicense.license.code,
name: currentLicense.license.name,
startsAt: currentLicense.startsAt,
expiresAt: currentLicense.expiresAt
}
: null
}
}
export async function findUserByEmail(email: string) {
return prisma.user.findUnique({
where: { email: email.toLowerCase() },
include: {
affiliations: {
where: { isPrimary: true, endsAt: null },
take: 1,
include: { organization: true }
},
userLicenses: {
where: activeLicenseWhere(),
orderBy: { startsAt: 'desc' },
take: 1,
include: { license: true }
},
appRoles: {
where: {
AND: [
{ OR: [{ startsAt: null }, { startsAt: { lte: new Date() } }] },
{ OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }] }
]
},
select: {
appKey: true,
roleCode: true
}
}
}
})
}
export async function createUser(input: { email: string; password: string; name?: string | null }): Promise<AuthUser> {
const freeLicense = await prisma.license.findUnique({
where: { code: 'FREE' },
select: { defaultDurationDays: true }
})
const licenseStartsAt = new Date()
const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense?.defaultDurationDays)
const user = await prisma.user.create({
data: {
email: input.email.toLowerCase(),
passwordHash: await hashPassword(input.password),
name: input.name?.trim() || null,
userLicenses: {
create: {
startsAt: licenseStartsAt,
expiresAt: licenseExpiresAt,
license: {
connect: { code: 'FREE' }
}
}
}
},
select: userSelect()
})
return toAuthUser(user)
}
function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null {
if (!durationDays) return null
const expiresAt = new Date(startsAt)
expiresAt.setDate(expiresAt.getDate() + durationDays)
return expiresAt
}
export async function createManagedUser(input: {
email: string
password: string
name?: string | null
organizationId: string
department?: string | null
position?: string | null
licenseCode?: 'FREE' | 'EDU' | 'PRO'
}): Promise<AuthUser> {
const user = await prisma.user.create({
data: {
email: input.email.toLowerCase(),
passwordHash: await hashPassword(input.password),
name: input.name?.trim() || null,
role: 'USER',
affiliations: {
create: {
organizationId: input.organizationId,
department: input.department?.trim() || null,
position: input.position?.trim() || null,
isPrimary: true
}
},
userLicenses: {
create: {
license: {
connect: { code: input.licenseCode ?? 'EDU' }
}
}
}
},
select: userSelect()
})
return toAuthUser(user)
}
export async function updateUserPassword(userId: string, password: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { passwordHash: await hashPassword(password) }
})
}
export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
const user = await prisma.user.findFirst({
where: { id: userId, isActive: true },
select: { passwordHash: true }
})
if (!user) return false
const passwordOk = await verifyPassword(currentPassword, user.passwordHash)
if (!passwordOk) return false
await updateUserPassword(userId, newPassword)
return true
}
export async function updateUserProfileImage(userId: string, imageData: Uint8Array, mimeType: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
profileImageData: Buffer.from(imageData),
profileImageMime: mimeType,
profileImageUpdatedAt: new Date()
}
})
}
export async function deleteUserProfileImage(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
profileImageData: null,
profileImageMime: null,
profileImageUpdatedAt: null
}
})
}
export async function findUserProfileImage(userId: string): Promise<{ data: Buffer; mimeType: string; updatedAt: Date | null } | null> {
const user = await prisma.user.findFirst({
where: { id: userId, isActive: true },
select: {
profileImageData: true,
profileImageMime: true,
profileImageUpdatedAt: true
}
})
if (!user?.profileImageData || !user.profileImageMime) return null
return {
data: Buffer.from(user.profileImageData),
mimeType: user.profileImageMime,
updatedAt: user.profileImageUpdatedAt
}
}
export async function findUserById(id: string): Promise<AuthUser | null> {
const user = await prisma.user.findFirst({
where: { id, isActive: true },
select: userSelect()
})
return user ? toAuthUser(user) : null
}
export async function listOrganizations() {
return prisma.organization.findMany({
where: { isActive: true },
orderBy: [{ type: 'asc' }, { name: 'asc' }],
select: {
id: true,
name: true,
type: true,
domain: true,
createdAt: true
}
})
}
export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) {
return prisma.organization.create({
data: {
name: input.name.trim(),
type: input.type,
domain: input.domain?.trim() || null
},
select: {
id: true,
name: true,
type: true,
domain: true,
createdAt: true
}
})
}
export async function assignUserAffiliation(input: {
userId: string
organizationId: string
department?: string | null
position?: string | null
isPrimary?: boolean
}) {
return prisma.$transaction(async (tx) => {
if (input.isPrimary) {
await tx.userAffiliation.updateMany({
where: { userId: input.userId, endsAt: null },
data: { isPrimary: false }
})
}
return tx.userAffiliation.create({
data: {
userId: input.userId,
organizationId: input.organizationId,
department: input.department?.trim() || null,
position: input.position?.trim() || null,
isPrimary: input.isPrimary ?? true
},
select: {
id: true,
userId: true,
organizationId: true,
department: true,
position: true,
isPrimary: true,
organization: {
select: {
name: true,
type: true
}
}
}
})
})
}
export async function markLastLogin(userId: string, loggedInAt = new Date()): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { lastLoginAt: loggedInAt }
})
}
export async function isLoginBlocked(email: string): Promise<boolean> {
const attempts = Number(await redis.get(loginAttemptKey(email)))
return Number.isFinite(attempts) && attempts >= config.maxFailedLoginAttempts
}
export async function recordFailedLogin(email: string): Promise<void> {
const attempts = await redis.incr(loginAttemptKey(email))
if (attempts === 1) {
await redis.expire(loginAttemptKey(email), config.loginAttemptWindowSeconds)
}
}
export async function clearLoginAttempts(email: string): Promise<void> {
await redis.del(loginAttemptKey(email))
}
export async function storeEmailVerificationCode(email: string, code: string): Promise<void> {
await redis.set(
emailVerificationKey(email),
JSON.stringify({ code, email: email.toLowerCase() }),
'EX',
config.emailVerificationTtlSeconds
)
}
export async function verifyEmailVerificationCode(email: string, code: string): Promise<boolean> {
const key = emailVerificationKey(email)
const tokenJson = await redis.get(key)
if (typeof tokenJson !== 'string') return false
const token = parseStoredEmailVerification(tokenJson)
if (!token || token.email !== email.toLowerCase() || token.code !== code) return false
await redis.del(key)
return true
}
export async function storePasswordResetCode(email: string, code: string): Promise<void> {
await redis.set(
passwordResetKey(email),
JSON.stringify({ code, email: email.toLowerCase() }),
'EX',
config.emailVerificationTtlSeconds
)
}
export async function verifyPasswordResetCode(email: string, code: string): Promise<boolean> {
const key = passwordResetKey(email)
const tokenJson = await redis.get(key)
if (typeof tokenJson !== 'string') return false
const token = parseStoredEmailVerification(tokenJson)
if (!token || token.email !== email.toLowerCase() || token.code !== code) return false
await redis.del(key)
return true
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = createOpaqueToken()
await redis.set(refreshTokenKey(token), JSON.stringify({ userId }), 'EX', config.refreshTokenTtlSeconds)
return token
}
export async function rotateRefreshToken(refreshToken: string): Promise<AuthUser | null> {
const tokenJson = await redis.send('GETDEL', [refreshTokenKey(refreshToken)])
if (typeof tokenJson !== 'string') return null
const token = parseStoredRefreshToken(tokenJson)
if (!token) return null
return findUserById(token.userId)
}
export async function revokeRefreshToken(refreshToken: string): Promise<void> {
await redis.del(refreshTokenKey(refreshToken))
}
export async function createHandoffToken(userId: string, audienceApp: string): Promise<string> {
const token = createOpaqueToken()
await redis.set(
handoffTokenKey(token),
JSON.stringify({ userId, audienceApp }),
'EX',
config.handoffTokenTtlSeconds
)
return token
}
export async function exchangeHandoffToken(handoffToken: string, audienceApp?: string): Promise<AuthUser | null> {
const tokenJson = await redis.send('GETDEL', [handoffTokenKey(handoffToken)])
if (typeof tokenJson !== 'string') return null
const token = parseStoredHandoffToken(tokenJson)
if (!token || (audienceApp && token.audienceApp !== audienceApp)) return null
return findUserById(token.userId)
}
const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}`
const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}`
const loginAttemptKey = (email: string): string => `auth:login_attempt:${hashToken(email.toLowerCase())}`
const emailVerificationKey = (email: string): string => `auth:email_verify:${hashToken(email.toLowerCase())}`
const passwordResetKey = (email: string): string => `auth:password_reset:${hashToken(email.toLowerCase())}`
const parseStoredRefreshToken = (value: string): { userId: string } | null => {
try {
const parsed = JSON.parse(value)
return typeof parsed.userId === 'string' ? { userId: parsed.userId } : null
} catch {
return null
}
}
const parseStoredHandoffToken = (value: string): { userId: string; audienceApp: string } | null => {
try {
const parsed = JSON.parse(value)
return typeof parsed.userId === 'string' && typeof parsed.audienceApp === 'string'
? { userId: parsed.userId, audienceApp: parsed.audienceApp }
: null
} catch {
return null
}
}
const parseStoredEmailVerification = (value: string): { email: string; code: string } | null => {
try {
const parsed = JSON.parse(value)
return typeof parsed.email === 'string' && typeof parsed.code === 'string'
? { email: parsed.email, code: parsed.code }
: null
} catch {
return null
}
}
+555
View File
@@ -0,0 +1,555 @@
import { Hono } from 'hono'
import { randomInt } from 'crypto'
import { z } from 'zod'
import { config } from '../config'
import { sendEmailVerificationCode } from '../mail'
import { verifyPassword } from '../security/password'
import { signAccessToken } from '../security/tokens'
import type { AuthUser } from '../types'
import { authRequired, type AuthVariables } from './middleware'
import {
clearLoginAttempts,
assignUserAffiliation,
changeUserPassword,
createManagedUser,
createOrganization,
createHandoffToken,
createRefreshToken,
createUser,
deleteUserProfileImage,
exchangeHandoffToken,
findUserProfileImage,
findUserByEmail,
listOrganizations,
isLoginBlocked,
markLastLogin,
recordFailedLogin,
revokeRefreshToken,
rotateRefreshToken,
storeEmailVerificationCode,
storePasswordResetCode,
updateUserPassword,
updateUserProfileImage,
verifyEmailVerificationCode,
verifyPasswordResetCode
} from './repository'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
})
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().trim().min(1).max(80),
verificationCode: z.string().regex(/^\d{6}$/)
})
const emailVerificationSendSchema = z.object({
email: z.string().email()
})
const passwordResetSendSchema = z.object({
email: z.string().email()
})
const passwordResetConfirmSchema = z.object({
email: z.string().email(),
verificationCode: z.string().regex(/^\d{6}$/),
password: z.string().min(8)
})
const passwordChangeSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8)
}).refine((value) => value.currentPassword !== value.newPassword, {
path: ['newPassword'],
message: 'new_password_must_be_different'
})
const organizationTypeSchema = z.enum(['COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER'])
const createOrganizationSchema = z.object({
name: z.string().trim().min(1).max(120),
type: organizationTypeSchema.default('OTHER'),
domain: z.string().trim().max(120).optional().nullable()
})
const assignAffiliationSchema = z.object({
organizationId: z.string().uuid(),
department: z.string().trim().max(120).optional().nullable(),
position: z.string().trim().max(120).optional().nullable(),
isPrimary: z.boolean().optional()
})
const adminCreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().trim().min(1).max(80),
organizationId: z.string().uuid(),
department: z.string().trim().max(120).optional().nullable(),
position: z.string().trim().max(120).optional().nullable(),
licenseCode: z.enum(['FREE', 'EDU', 'PRO']).optional()
})
const refreshSchema = z.object({
refreshToken: z.string().min(1).optional()
})
const logoutSchema = z.object({
refreshToken: z.string().min(1).optional()
})
const handoffSchema = z.object({
audienceApp: z.string().min(1).max(120)
})
const handoffExchangeSchema = z.object({
handoffToken: z.string().min(1),
audienceApp: z.string().min(1).max(120).optional()
})
const AVATAR_MAX_BYTES = 80 * 1024
const AVATAR_ALLOWED_MIME_TYPES = new Set(['image/webp', 'image/png', 'image/jpeg'])
const userResponse = (user: AuthUser) => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
roles: [...new Set([user.role, ...(user.appRoles ?? []).map((appRole) => appRole.roleCode)])],
appRoles: user.appRoles ?? [],
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : user.createdAt,
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt,
primaryAffiliation: user.primaryAffiliation ?? null,
currentLicense: user.currentLicense
? {
...user.currentLicense,
startsAt: user.currentLicense.startsAt instanceof Date
? user.currentLicense.startsAt.toISOString()
: user.currentLicense.startsAt,
expiresAt: user.currentLicense.expiresAt instanceof Date
? user.currentLicense.expiresAt.toISOString()
: user.currentLicense.expiresAt
}
: null
})
const requireAdmin = (user: AuthUser): boolean => user.role === 'ADMIN'
const accessTokenResponse = async (user: AuthUser) => ({
accessToken: await signAccessToken(user),
accessTokenExpiresIn: config.accessTokenTtlSeconds,
user: userResponse(user)
})
const tokenPairResponse = async (user: AuthUser) => ({
...(await accessTokenResponse(user)),
refreshToken: await createRefreshToken(user.id),
refreshTokenExpiresIn: config.refreshTokenTtlSeconds
})
const cookieAttributes = (maxAgeSeconds: number): string[] => [
'HttpOnly',
'Path=/',
'SameSite=Lax',
`Max-Age=${maxAgeSeconds}`,
...(config.refreshCookieSecure ? ['Secure'] : [])
]
const setRefreshCookie = (c: { header: (name: string, value: string) => void }, refreshToken: string): void => {
c.header(
'Set-Cookie',
`${config.refreshCookieName}=${encodeURIComponent(refreshToken)}; ${cookieAttributes(config.refreshTokenTtlSeconds).join('; ')}`
)
}
const clearRefreshCookie = (c: { header: (name: string, value: string) => void }): void => {
c.header('Set-Cookie', `${config.refreshCookieName}=; ${cookieAttributes(0).join('; ')}`)
}
const readCookie = (cookieHeader: string | undefined, name: string): string | undefined => {
if (!cookieHeader) return undefined
const prefix = `${name}=`
const cookie = cookieHeader
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(prefix))
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : undefined
}
const readRefreshToken = async (c: { req: { header: (name: string) => string | undefined; json: () => Promise<unknown> } }) => {
const cookieToken = readCookie(c.req.header('cookie'), config.refreshCookieName)
if (cookieToken) return cookieToken
const body = refreshSchema.safeParse(await c.req.json().catch(() => ({})))
return body.success ? body.data.refreshToken : undefined
}
export const authRoutes = new Hono<{ Variables: AuthVariables }>()
authRoutes.post('/email-verification/send', async (c) => {
const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const email = body.data.email.toLowerCase()
const existingUser = await findUserByEmail(email)
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
const code = String(randomInt(100000, 1000000))
await storeEmailVerificationCode(email, code)
try {
await sendEmailVerificationCode(email, code)
} catch (error) {
console.error('Failed to send email verification code', error)
return c.json({ error: 'email_send_failed' }, 502)
}
return c.json({
ok: true,
expiresIn: config.emailVerificationTtlSeconds
})
})
authRoutes.post('/password-reset/send', async (c) => {
const body = passwordResetSendSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const email = body.data.email.toLowerCase()
const existingUser = await findUserByEmail(email)
if (!existingUser?.isActive) {
return c.json({ error: 'email_not_registered' }, 404)
}
const code = String(randomInt(100000, 1000000))
await storePasswordResetCode(email, code)
try {
await sendEmailVerificationCode(email, code)
} catch (error) {
console.error('Failed to send password reset code', error)
return c.json({ error: 'email_send_failed' }, 502)
}
return c.json({
ok: true,
expiresIn: config.emailVerificationTtlSeconds
})
})
authRoutes.post('/password-reset/confirm', async (c) => {
const body = passwordResetConfirmSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const email = body.data.email.toLowerCase()
const existingUser = await findUserByEmail(email)
if (!existingUser?.isActive) return c.json({ error: 'invalid_password_reset_code' }, 400)
const codeVerified = await verifyPasswordResetCode(email, body.data.verificationCode)
if (!codeVerified) return c.json({ error: 'invalid_password_reset_code' }, 400)
await updateUserPassword(existingUser.id, body.data.password)
await clearLoginAttempts(email)
return c.json({ ok: true })
})
authRoutes.post('/password/change', authRequired, async (c) => {
const body = passwordChangeSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const changed = await changeUserPassword(
c.get('user').id,
body.data.currentPassword,
body.data.newPassword
)
if (!changed) return c.json({ error: 'invalid_current_password' }, 400)
return c.json({ ok: true })
})
authRoutes.post('/login', async (c) => {
const body = loginSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const email = body.data.email.toLowerCase()
if (await isLoginBlocked(email)) return c.json({ error: 'too_many_login_attempts' }, 429)
const dbUser = await findUserByEmail(email)
if (!dbUser || !dbUser.isActive) {
await recordFailedLogin(email)
return c.json({ error: 'invalid_credentials' }, 401)
}
const passwordOk = await verifyPassword(body.data.password, dbUser.passwordHash)
if (!passwordOk) {
await recordFailedLogin(email)
return c.json({ error: 'invalid_credentials' }, 401)
}
const loggedInAt = new Date()
const user: AuthUser = {
id: dbUser.id,
email: dbUser.email,
name: dbUser.name,
role: dbUser.role,
appRoles: dbUser.appRoles ?? [],
createdAt: dbUser.createdAt,
lastLoginAt: loggedInAt,
primaryAffiliation: dbUser.affiliations?.[0]
? {
id: dbUser.affiliations[0].id,
organizationId: dbUser.affiliations[0].organizationId,
organizationName: dbUser.affiliations[0].organization.name,
organizationType: dbUser.affiliations[0].organization.type,
department: dbUser.affiliations[0].department,
position: dbUser.affiliations[0].position
}
: null,
currentLicense: dbUser.userLicenses?.[0]
? {
id: dbUser.userLicenses[0].id,
code: dbUser.userLicenses[0].license.code,
name: dbUser.userLicenses[0].license.name,
startsAt: dbUser.userLicenses[0].startsAt,
expiresAt: dbUser.userLicenses[0].expiresAt
}
: null
}
await markLastLogin(user.id, loggedInAt)
await clearLoginAttempts(email)
const refreshToken = await createRefreshToken(user.id)
setRefreshCookie(c, refreshToken)
return c.json(await accessTokenResponse(user))
})
authRoutes.post('/signup', async (c) => {
const body = signupSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const email = body.data.email.toLowerCase()
const existingUser = await findUserByEmail(email)
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
const emailVerified = await verifyEmailVerificationCode(email, body.data.verificationCode)
if (!emailVerified) return c.json({ error: 'invalid_email_verification_code' }, 400)
const user = await createUser({
email,
password: body.data.password,
name: body.data.name
})
const refreshToken = await createRefreshToken(user.id)
setRefreshCookie(c, refreshToken)
return c.json(await accessTokenResponse(user), 201)
})
authRoutes.post('/refresh', async (c) => {
const refreshToken = await readRefreshToken(c)
if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401)
const user = await rotateRefreshToken(refreshToken)
if (!user) {
clearRefreshCookie(c)
return c.json({ error: 'invalid_refresh_token' }, 401)
}
const nextRefreshToken = await createRefreshToken(user.id)
setRefreshCookie(c, nextRefreshToken)
return c.json(await accessTokenResponse(user))
})
authRoutes.post('/logout', async (c) => {
const body = logoutSchema.safeParse(await c.req.json().catch(() => ({})))
const refreshToken = body.success && body.data.refreshToken
? body.data.refreshToken
: readCookie(c.req.header('cookie'), config.refreshCookieName)
if (refreshToken) {
await revokeRefreshToken(refreshToken)
}
clearRefreshCookie(c)
return c.json({ ok: true })
})
authRoutes.get('/me', authRequired, (c) => {
return c.json({ user: userResponse(c.get('user')) })
})
authRoutes.get('/profile/avatar', authRequired, async (c) => {
const image = await findUserProfileImage(c.get('user').id)
if (!image) return c.body(null, 404)
c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data))
})
authRoutes.get('/users/:userId/avatar', authRequired, async (c) => {
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
const userId = c.req.param('userId')
const image = await findUserProfileImage(userId)
if (!image) return c.body(null, 404)
c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data))
})
authRoutes.get('/users/avatar', authRequired, async (c) => {
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
const email = c.req.query('email')?.toLowerCase()
if (!email) return c.json({ error: 'email_required' }, 400)
const user = await findUserByEmail(email)
if (!user) return c.body(null, 404)
const image = await findUserProfileImage(user.id)
if (!image) return c.body(null, 404)
c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data))
})
authRoutes.put('/profile/avatar', authRequired, async (c) => {
const contentType = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase() ?? ''
if (!AVATAR_ALLOWED_MIME_TYPES.has(contentType)) {
return c.json({ error: 'unsupported_image_type' }, 415)
}
const body = new Uint8Array(await c.req.arrayBuffer())
if (body.length === 0) return c.json({ error: 'empty_image' }, 400)
if (body.length > AVATAR_MAX_BYTES) return c.json({ error: 'image_too_large', maxBytes: AVATAR_MAX_BYTES }, 413)
await updateUserProfileImage(c.get('user').id, body, contentType)
return c.json({ ok: true })
})
authRoutes.delete('/profile/avatar', authRequired, async (c) => {
await deleteUserProfileImage(c.get('user').id)
return c.json({ ok: true })
})
authRoutes.get('/admin/organizations', authRequired, async (c) => {
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
const organizations = await listOrganizations()
return c.json({
organizations: organizations.map((organization) => ({
...organization,
createdAt: organization.createdAt.toISOString()
}))
})
})
authRoutes.post('/admin/organizations', authRequired, async (c) => {
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
const body = createOrganizationSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
try {
const organization = await createOrganization(body.data)
return c.json({
organization: {
...organization,
createdAt: organization.createdAt.toISOString()
}
}, 201)
} catch {
return c.json({ error: 'organization_already_exists' }, 409)
}
})
authRoutes.post('/admin/users', authRequired, async (c) => {
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
const body = adminCreateUserSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const email = body.data.email.toLowerCase()
const existingUser = await findUserByEmail(email)
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
try {
const user = await createManagedUser({
email,
password: body.data.password,
name: body.data.name,
organizationId: body.data.organizationId,
department: body.data.department,
position: body.data.position,
licenseCode: body.data.licenseCode
})
return c.json({ user: userResponse(user) }, 201)
} catch {
return c.json({ error: 'user_create_failed' }, 400)
}
})
authRoutes.post('/admin/users/:userId/affiliations', authRequired, async (c) => {
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
const userId = c.req.param('userId')
const body = assignAffiliationSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
try {
const affiliation = await assignUserAffiliation({
userId,
organizationId: body.data.organizationId,
department: body.data.department,
position: body.data.position,
isPrimary: body.data.isPrimary
})
return c.json({ affiliation }, 201)
} catch {
return c.json({ error: 'affiliation_assign_failed' }, 400)
}
})
authRoutes.post('/handoff', authRequired, async (c) => {
const body = handoffSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const token = await createHandoffToken(c.get('user').id, body.data.audienceApp)
return c.json({
handoffToken: token,
expiresIn: config.handoffTokenTtlSeconds,
audienceApp: body.data.audienceApp
})
})
authRoutes.post('/handoff/exchange', async (c) => {
const body = handoffExchangeSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const user = await exchangeHandoffToken(body.data.handoffToken, body.data.audienceApp)
if (!user) return c.json({ error: 'invalid_handoff_token' }, 401)
return c.json(await tokenPairResponse(user))
})
+38
View File
@@ -0,0 +1,38 @@
const toInt = (value: string | undefined, fallback: number): number => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
const required = (name: string, fallback?: string): string => {
const value = process.env[name] ?? fallback
if (!value) throw new Error(`Missing required env: ${name}`)
return value
}
export const config = {
port: toInt(process.env.PORT, 4100),
databaseUrl: required('DATABASE_URL', 'postgres://atnmy_auth:future1234!%40@localhost:5434/atnmy_auth'),
redisUrl: required('REDIS_URL', 'redis://localhost:6380'),
jwtSecret: required('JWT_SECRET', 'dev-only-change-this-secret-at-least-32-bytes'),
jwtIssuer: process.env.JWT_ISSUER ?? 'atnmy-stdo-auth',
accessTokenTtlSeconds: toInt(process.env.ACCESS_TOKEN_TTL_SECONDS, 60 * 60),
refreshTokenTtlSeconds: toInt(process.env.REFRESH_TOKEN_TTL_SECONDS, 30 * 24 * 60 * 60),
handoffTokenTtlSeconds: toInt(process.env.HANDOFF_TOKEN_TTL_SECONDS, 60),
maxFailedLoginAttempts: toInt(process.env.MAX_FAILED_LOGIN_ATTEMPTS, 5),
loginAttemptWindowSeconds: toInt(process.env.LOGIN_ATTEMPT_WINDOW_SECONDS, 15 * 60),
emailVerificationTtlSeconds: toInt(process.env.EMAIL_VERIFICATION_TTL_SECONDS, 5 * 60),
refreshCookieName: process.env.REFRESH_COOKIE_NAME ?? 'atnmy_refresh',
refreshCookieSecure: process.env.REFRESH_COOKIE_SECURE === 'true',
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:4200,http://localhost:4300,http://localhost:4400')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean),
seedAdminEmail: process.env.SEED_ADMIN_EMAIL ?? 'admin@futuredrive.net',
seedAdminPassword: process.env.SEED_ADMIN_PASSWORD ?? 'admin1234',
smtpHost: process.env.SMTP_HOST,
smtpPort: toInt(process.env.SMTP_PORT, 587),
smtpSecure: process.env.SMTP_SECURE === 'true',
smtpUser: process.env.SMTP_USER,
smtpPass: process.env.SMTP_PASS,
smtpFrom: process.env.SMTP_FROM
}
+40
View File
@@ -0,0 +1,40 @@
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { config } from './config'
import { prisma } from './prisma'
import { closeRedis, redis } from './redis'
import { authRoutes } from './auth/routes'
const app = new Hono()
app.use('*', logger())
app.use(
'*',
cors({
origin: (origin) => {
if (!origin) return null
return config.corsOrigins.includes(origin) ? origin : null
},
allowHeaders: ['authorization', 'content-type'],
allowMethods: ['GET', 'POST', 'PUT', 'OPTIONS'],
credentials: true
})
)
app.get('/health', async (c) => {
const redisOk = (await redis.send('PING', [])) === 'PONG'
return c.json({ ok: true, service: 'atnmy-stdo-auth', redis: redisOk ? 'ok' : 'error' }, redisOk ? 200 : 503)
})
app.route('/auth', authRoutes)
process.on('SIGINT', async () => {
await closeRedis()
await prisma.$disconnect()
process.exit(0)
})
serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`Auth server listening on http://localhost:${info.port}`)
})
+39
View File
@@ -0,0 +1,39 @@
import nodemailer from 'nodemailer'
import { config } from './config'
const createTransporter = () => {
if (!config.smtpHost || !config.smtpUser || !config.smtpPass || !config.smtpFrom) {
throw new Error('SMTP configuration is incomplete')
}
return nodemailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: {
user: config.smtpUser,
pass: config.smtpPass
}
})
}
let transporter: ReturnType<typeof createTransporter> | null = null
export async function sendEmailVerificationCode(to: string, code: string): Promise<void> {
transporter ??= createTransporter()
await transporter.sendMail({
from: config.smtpFrom,
to,
subject: '[Autonomy Studio] 이메일 인증코드',
text: `Autonomy Studio 이메일 인증코드는 ${code} 입니다. ${Math.floor(config.emailVerificationTtlSeconds / 60)}분 안에 입력해주세요.`,
html: `
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#111827">
<h2 style="margin:0 0 12px">Autonomy Studio 이메일 인증</h2>
<p style="margin:0 0 16px">아래 인증코드를 회원가입 화면에 입력해주세요.</p>
<div style="font-size:28px;font-weight:700;letter-spacing:6px;padding:16px 18px;background:#f3f4f6;border-radius:8px;display:inline-block">${code}</div>
<p style="margin:16px 0 0;color:#6b7280;font-size:13px">${Math.floor(config.emailVerificationTtlSeconds / 60)}분 후 만료됩니다.</p>
</div>
`
})
}
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()
+11
View File
@@ -0,0 +1,11 @@
import { RedisClient } from 'bun'
import { config } from './config'
export const redis = new RedisClient(config.redisUrl, {
autoReconnect: true,
enableOfflineQueue: true
})
export async function closeRedis(): Promise<void> {
redis.close()
}
+25
View File
@@ -0,0 +1,25 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'crypto'
import { promisify } from 'util'
const scrypt = promisify(scryptCallback)
const KEY_LENGTH = 64
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex')
const derivedKey = (await scrypt(password, salt, KEY_LENGTH)) as Buffer
return `scrypt:${salt}:${derivedKey.toString('hex')}`
}
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
const [algorithm, salt, key] = passwordHash.split(':')
if (algorithm !== 'scrypt' || !salt || !key) {
return false
}
const storedKey = Buffer.from(key, 'hex')
const derivedKey = (await scrypt(password, salt, storedKey.length)) as Buffer
return storedKey.length === derivedKey.length && timingSafeEqual(storedKey, derivedKey)
}
+42
View File
@@ -0,0 +1,42 @@
import { createHash, randomBytes } from 'crypto'
import { jwtVerify, SignJWT } from 'jose'
import { config } from '../config'
import type { AuthUser } from '../types'
const secret = new TextEncoder().encode(config.jwtSecret)
export interface AccessTokenClaims {
sub: string
email: string
role: string
}
export const hashToken = (token: string): string => createHash('sha256').update(token).digest('hex')
export const createOpaqueToken = (): string => randomBytes(32).toString('base64url')
export async function signAccessToken(user: AuthUser): Promise<string> {
return new SignJWT({
email: user.email,
role: user.role
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuer(config.jwtIssuer)
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime(`${config.accessTokenTtlSeconds}s`)
.sign(secret)
}
export async function verifyAccessToken(token: string): Promise<AccessTokenClaims> {
const { payload } = await jwtVerify(token, secret, { issuer: config.jwtIssuer })
if (!payload.sub || typeof payload.email !== 'string' || typeof payload.role !== 'string') {
throw new Error('Invalid access token claims')
}
return {
sub: payload.sub,
email: payload.email,
role: payload.role
}
}
+27
View File
@@ -0,0 +1,27 @@
export interface AuthUser {
id: string
email: string
name: string | null
role: string
appRoles?: Array<{
appKey: string
roleCode: string
}>
createdAt?: Date | string
lastLoginAt?: Date | string | null
primaryAffiliation?: {
id: string
organizationId: string
organizationName: string
organizationType: string
department: string | null
position: string | null
} | null
currentLicense?: {
id: string
code: string
name: string
startsAt: Date | string
expiresAt: Date | string | null
} | null
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["bun"],
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*.ts"]
}