create-saas-starter-workspace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +17 -0
- package/README.md +19 -0
- package/bin/index.mjs +201 -0
- package/package.json +25 -0
- package/src/scaffold.mjs +109 -0
- package/src/validate.mjs +22 -0
- package/templates/workspace/.claude/settings.json +44 -0
- package/templates/workspace/.claude/skills/bridge-guide/SKILL.md +71 -0
- package/templates/workspace/.claude/skills/eas-deploy-guide/SKILL.md +107 -0
- package/templates/workspace/.claude/skills/go-live/SKILL.md +66 -0
- package/templates/workspace/.claude/skills/kickoff/SKILL.md +72 -0
- package/templates/workspace/.claude/skills/launch/SKILL.md +69 -0
- package/templates/workspace/.claude/skills/native-app-guide/SKILL.md +102 -0
- package/templates/workspace/.claude/skills/preview/SKILL.md +43 -0
- package/templates/workspace/.claude/skills/probe/SKILL.md +17 -0
- package/templates/workspace/.claude/skills/release/SKILL.md +51 -0
- package/templates/workspace/.claude/skills/sketch/SKILL.md +19 -0
- package/templates/workspace/.claude/skills/store-release-guide/SKILL.md +239 -0
- package/templates/workspace/.claude/skills/vercel-cron/SKILL.md +17 -0
- package/templates/workspace/.claude/skills/warmup/SKILL.md +33 -0
- package/templates/workspace/AGENTS.md +39 -0
- package/templates/workspace/CLAUDE.md +2 -0
- package/templates/workspace/LICENSE +17 -0
- package/templates/workspace/README.md +20 -0
- package/templates/workspace/START-HERE.md +52 -0
- package/templates/workspace/gitignore +18 -0
- package/templates/workspace/mobile/.env.example +25 -0
- package/templates/workspace/mobile/AGENTS.md +69 -0
- package/templates/workspace/mobile/App.tsx +47 -0
- package/templates/workspace/mobile/CLAUDE.md +2 -0
- package/templates/workspace/mobile/LICENSE +17 -0
- package/templates/workspace/mobile/README.md +73 -0
- package/templates/workspace/mobile/app.config.ts +153 -0
- package/templates/workspace/mobile/assets/android-icon-background.png +0 -0
- package/templates/workspace/mobile/assets/android-icon-foreground.png +0 -0
- package/templates/workspace/mobile/assets/android-icon-monochrome.png +0 -0
- package/templates/workspace/mobile/assets/favicon.png +0 -0
- package/templates/workspace/mobile/assets/icon.png +0 -0
- package/templates/workspace/mobile/assets/splash-icon.png +0 -0
- package/templates/workspace/mobile/docs/web-adapter/README.md +130 -0
- package/templates/workspace/mobile/docs/web-adapter/route-app-bridge.ts +235 -0
- package/templates/workspace/mobile/eas.json +31 -0
- package/templates/workspace/mobile/gitignore +45 -0
- package/templates/workspace/mobile/index.ts +12 -0
- package/templates/workspace/mobile/package.json +38 -0
- package/templates/workspace/mobile/pnpm-lock.yaml +5201 -0
- package/templates/workspace/mobile/src/auth/LoginScreen.tsx +192 -0
- package/templates/workspace/mobile/src/bridge/capabilities.test.ts +44 -0
- package/templates/workspace/mobile/src/bridge/capabilities.ts +42 -0
- package/templates/workspace/mobile/src/bridge/contract.test.ts +49 -0
- package/templates/workspace/mobile/src/bridge/contract.ts +146 -0
- package/templates/workspace/mobile/src/bridge/messaging.test.ts +49 -0
- package/templates/workspace/mobile/src/bridge/messaging.ts +33 -0
- package/templates/workspace/mobile/src/bridge/reader.test.ts +52 -0
- package/templates/workspace/mobile/src/bridge/reader.ts +31 -0
- package/templates/workspace/mobile/src/bridge/router.test.ts +124 -0
- package/templates/workspace/mobile/src/bridge/router.ts +89 -0
- package/templates/workspace/mobile/src/config/env.ts +51 -0
- package/templates/workspace/mobile/src/i18n.ts +71 -0
- package/templates/workspace/mobile/src/session/secureSession.ts +63 -0
- package/templates/workspace/mobile/src/session/sessionHandoff.ts +151 -0
- package/templates/workspace/mobile/src/ui/ErrorView.tsx +75 -0
- package/templates/workspace/mobile/src/ui/LoadingView.tsx +38 -0
- package/templates/workspace/mobile/src/ui/OfflineView.tsx +73 -0
- package/templates/workspace/mobile/src/webview/Host.tsx +353 -0
- package/templates/workspace/mobile/src/webview/linkBoundary.test.ts +57 -0
- package/templates/workspace/mobile/src/webview/linkBoundary.ts +58 -0
- package/templates/workspace/mobile/tsconfig.json +8 -0
- package/templates/workspace/mobile/vitest.config.ts +14 -0
- package/templates/workspace/package.json +9 -0
- package/templates/workspace/scripts/doctor.mjs +291 -0
- package/templates/workspace/ssb/README.md +10 -0
- package/templates/workspace/ssb/contract.ts +146 -0
- package/templates/workspace/ssb/reader.ts +31 -0
- package/templates/workspace/web/.env.example +39 -0
- package/templates/workspace/web/.gitattributes +1 -0
- package/templates/workspace/web/.github/workflows/ci.yml +61 -0
- package/templates/workspace/web/.vscode/settings.json +8 -0
- package/templates/workspace/web/AGENTS.md +103 -0
- package/templates/workspace/web/CLAUDE.md +2 -0
- package/templates/workspace/web/DESIGN.md +18 -0
- package/templates/workspace/web/LICENSE +17 -0
- package/templates/workspace/web/README.md +48 -0
- package/templates/workspace/web/app/error.tsx +28 -0
- package/templates/workspace/web/app/favicon.ico +0 -0
- package/templates/workspace/web/app/global-error.tsx +19 -0
- package/templates/workspace/web/app/globals.css +130 -0
- package/templates/workspace/web/app/layout.tsx +33 -0
- package/templates/workspace/web/app/not-found.tsx +12 -0
- package/templates/workspace/web/app/page.tsx +11 -0
- package/templates/workspace/web/components/ui/button.tsx +58 -0
- package/templates/workspace/web/components.json +25 -0
- package/templates/workspace/web/docs/ENVIRONMENTS.md +102 -0
- package/templates/workspace/web/docs/LIMITS.md +54 -0
- package/templates/workspace/web/eslint.config.mjs +46 -0
- package/templates/workspace/web/features/.gitkeep +0 -0
- package/templates/workspace/web/gitignore +51 -0
- package/templates/workspace/web/instrumentation-client.ts +16 -0
- package/templates/workspace/web/instrumentation.ts +12 -0
- package/templates/workspace/web/lib/app-env.ts +12 -0
- package/templates/workspace/web/lib/bridge/contract.ts +146 -0
- package/templates/workspace/web/lib/bridge/reader.ts +31 -0
- package/templates/workspace/web/lib/env.server.ts +33 -0
- package/templates/workspace/web/lib/env.ts +21 -0
- package/templates/workspace/web/lib/logger.ts +32 -0
- package/templates/workspace/web/lib/supabase/admin.ts +14 -0
- package/templates/workspace/web/lib/supabase/client.ts +9 -0
- package/templates/workspace/web/lib/supabase/server.ts +24 -0
- package/templates/workspace/web/lib/utils.ts +6 -0
- package/templates/workspace/web/next.config.ts +16 -0
- package/templates/workspace/web/npmrc +14 -0
- package/templates/workspace/web/package.json +60 -0
- package/templates/workspace/web/pnpm-lock.yaml +9155 -0
- package/templates/workspace/web/postcss.config.mjs +7 -0
- package/templates/workspace/web/sentry.edge.config.ts +9 -0
- package/templates/workspace/web/sentry.server.config.ts +9 -0
- package/templates/workspace/web/supabase/migrations/.gitkeep +0 -0
- package/templates/workspace/web/tests/setup.ts +1 -0
- package/templates/workspace/web/tests/utils.test.ts +12 -0
- package/templates/workspace/web/tsconfig.json +35 -0
- package/templates/workspace/web/vercel.json +6 -0
- package/templates/workspace/web/vitest.config.ts +15 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 무료 티어 한도 (SSOT)
|
|
2
|
+
|
|
3
|
+
변동되는 숫자만 이 파일에 모은다. 스킬·문서는 이 파일을 가리킨다. 공식 docs가 항상 우선이며, 본 파일과 다르면 docs를 따른다.
|
|
4
|
+
|
|
5
|
+
갱신: 분기 1회 · 최근 2026-05-23.
|
|
6
|
+
|
|
7
|
+
## Supabase Free
|
|
8
|
+
|
|
9
|
+
- 활성 프로젝트: 2 / organization
|
|
10
|
+
- DB 크기: 500MB / project
|
|
11
|
+
- inactivity auto-pause: 7일
|
|
12
|
+
- 출처: <https://supabase.com/pricing>
|
|
13
|
+
|
|
14
|
+
## Vercel Hobby
|
|
15
|
+
|
|
16
|
+
- 빌드 분: ~6,000 / 월
|
|
17
|
+
- 함수 호출: ~1,000,000 / 월
|
|
18
|
+
- 함수 최대 실행 시간: 300초 (Pro/Ent는 800초)
|
|
19
|
+
- 요청·응답 본문: 4.5MB
|
|
20
|
+
- 로그 보존: 1시간
|
|
21
|
+
- Cron: Hobby는 하루 1회 간격까지 (개수·간격은 공식 docs 확인)
|
|
22
|
+
- Deployment Checks (Wait for Checks): Hobby에서 사용 가능
|
|
23
|
+
- Log Drains: Pro 전용
|
|
24
|
+
- 출처: <https://vercel.com/docs/plans/hobby>
|
|
25
|
+
|
|
26
|
+
> 상시 워커·웹소켓이 없고 함수 본문이 4.5MB로 제한되므로, 긴 작업은 Vercel Workflows로 처리한다. (한도가 아니라 아키텍처 지침)
|
|
27
|
+
|
|
28
|
+
## Sentry Developer (Free)
|
|
29
|
+
|
|
30
|
+
- 에러 이벤트: ~5,000 / 월
|
|
31
|
+
- 보존: 30일
|
|
32
|
+
- organization·user: 1
|
|
33
|
+
- 출처: <https://sentry.io/pricing/>
|
|
34
|
+
|
|
35
|
+
## GitHub Free
|
|
36
|
+
|
|
37
|
+
- private repo: 무제한
|
|
38
|
+
- GitHub Actions 분: ~2,000 / 월 (private repo)
|
|
39
|
+
- secret scanning push protection: public repo만 무료 (private는 GitHub Advanced Security, 유료)
|
|
40
|
+
- 출처: <https://github.com/pricing>
|
|
41
|
+
|
|
42
|
+
## Resend (참고용, 본 템플릿엔 미포함)
|
|
43
|
+
|
|
44
|
+
- 이메일 발송: ~3,000 / 월
|
|
45
|
+
- 출처: <https://resend.com/pricing>
|
|
46
|
+
|
|
47
|
+
## 한도 초과 시
|
|
48
|
+
|
|
49
|
+
| 자원 | 초과 시 |
|
|
50
|
+
|---|---|
|
|
51
|
+
| Supabase | 프로젝트 비활성화 → Pro $25/mo 또는 데이터 정리 |
|
|
52
|
+
| Vercel | 함수·빌드 정지 → 다음 사이클 대기 또는 Pro $20/mo |
|
|
53
|
+
| Sentry | 새 이벤트 drop → 사이클 대기 또는 Team $26/mo |
|
|
54
|
+
| GitHub Actions | 차단 → 다음 사이클 대기 또는 분 구입 |
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
2
|
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
3
|
+
import nextTs from "eslint-config-next/typescript";
|
|
4
|
+
|
|
5
|
+
const eslintConfig = defineConfig([
|
|
6
|
+
...nextVitals,
|
|
7
|
+
...nextTs,
|
|
8
|
+
// type-aware: deprecated API 호출을 lint로 검출.
|
|
9
|
+
{
|
|
10
|
+
files: ["**/*.{ts,tsx,mts,cts}"],
|
|
11
|
+
languageOptions: {
|
|
12
|
+
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
|
13
|
+
},
|
|
14
|
+
rules: {
|
|
15
|
+
"@typescript-eslint/no-deprecated": "warn",
|
|
16
|
+
// feature 캡슐화: 다른 feature는 public index(@/features/<name>)로만 import — 내부 deep import 금지.
|
|
17
|
+
"no-restricted-imports": ["error", {
|
|
18
|
+
patterns: [{
|
|
19
|
+
group: ["@/features/*/*"],
|
|
20
|
+
message: "feature 내부 deep import 금지 — public API(@/features/<name>)로만 import.",
|
|
21
|
+
}],
|
|
22
|
+
}],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
// 소유 표면(lib)은 features를 import하지 않는다 — 단방향 seam 경계(app→features→lib).
|
|
27
|
+
// 이 경계가 있어 템플릿 갱신(소유 파일 마이그레이션)이 features와 충돌 없이 기계적으로 적용된다.
|
|
28
|
+
files: ["lib/**/*.{ts,tsx}"],
|
|
29
|
+
rules: {
|
|
30
|
+
"no-restricted-imports": ["error", {
|
|
31
|
+
patterns: [{
|
|
32
|
+
group: ["@/features", "@/features/*", "@/features/*/*"],
|
|
33
|
+
message: "lib(소유 표면)은 features를 import할 수 없다 — 단방향 의존(app→features→lib) 유지.",
|
|
34
|
+
}],
|
|
35
|
+
}],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
globalIgnores([
|
|
39
|
+
".next/**",
|
|
40
|
+
"out/**",
|
|
41
|
+
"build/**",
|
|
42
|
+
"next-env.d.ts",
|
|
43
|
+
]),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export default eslintConfig;
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnp/
|
|
4
|
+
.pnp.*
|
|
5
|
+
|
|
6
|
+
# next
|
|
7
|
+
.next/
|
|
8
|
+
out/
|
|
9
|
+
build/
|
|
10
|
+
|
|
11
|
+
# vercel
|
|
12
|
+
.vercel
|
|
13
|
+
.vercel.local
|
|
14
|
+
|
|
15
|
+
# env (절대 커밋 금지)
|
|
16
|
+
.env
|
|
17
|
+
.env.local
|
|
18
|
+
.env.*.local
|
|
19
|
+
.env.development.local
|
|
20
|
+
.env.production.local
|
|
21
|
+
.env.sentry-build-plugin
|
|
22
|
+
|
|
23
|
+
# testing
|
|
24
|
+
coverage/
|
|
25
|
+
.vitest-cache/
|
|
26
|
+
|
|
27
|
+
# misc
|
|
28
|
+
.DS_Store
|
|
29
|
+
*.pem
|
|
30
|
+
|
|
31
|
+
# logs
|
|
32
|
+
*.log
|
|
33
|
+
npm-debug.log*
|
|
34
|
+
pnpm-debug.log*
|
|
35
|
+
|
|
36
|
+
# typescript
|
|
37
|
+
*.tsbuildinfo
|
|
38
|
+
next-env.d.ts
|
|
39
|
+
|
|
40
|
+
# sentry
|
|
41
|
+
.sentryclirc
|
|
42
|
+
|
|
43
|
+
# IDE
|
|
44
|
+
.idea/
|
|
45
|
+
.vscode/*
|
|
46
|
+
!.vscode/settings.json
|
|
47
|
+
!.vscode/extensions.json
|
|
48
|
+
|
|
49
|
+
# supabase (Docker 미사용이지만 잔여 파일 대비)
|
|
50
|
+
supabase/.branches
|
|
51
|
+
supabase/.temp
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Browser SDK 초기화. Next가 client bundle에 자동 포함.
|
|
2
|
+
import * as Sentry from "@sentry/nextjs";
|
|
3
|
+
|
|
4
|
+
// 브라우저엔 배포 환경 신호(APP_ENV)가 client bundle에 없으므로 DSN 존재로 게이트한다.
|
|
5
|
+
// DSN은 prod에만 주입되므로(go-live 배선) 사실상 production-only 송출.
|
|
6
|
+
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
|
7
|
+
|
|
8
|
+
Sentry.init({
|
|
9
|
+
dsn,
|
|
10
|
+
enabled: !!dsn,
|
|
11
|
+
environment: dsn ? "production" : "development",
|
|
12
|
+
tracesSampleRate: 0,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Next 16: 내비게이션 계측을 위해 Sentry가 요구하는 hook.
|
|
16
|
+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as Sentry from "@sentry/nextjs";
|
|
2
|
+
|
|
3
|
+
export async function register() {
|
|
4
|
+
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
5
|
+
await import("./sentry.server.config");
|
|
6
|
+
}
|
|
7
|
+
if (process.env.NEXT_RUNTIME === "edge") {
|
|
8
|
+
await import("./sentry.edge.config");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const onRequestError = Sentry.captureRequestError;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// 벤더 중립 배포 환경 신호. Vercel은 VERCEL_ENV를 자동 주입하고, 그 외 호스트는 APP_ENV를 직접 설정한다.
|
|
2
|
+
// 순수 함수(process.env만 읽음, server-only 아님)라 런타임 코드(lib/env.server)와
|
|
3
|
+
// 빌드 시점 init 파일(sentry.*.config·instrumentation)에서 모두 안전하게 쓴다.
|
|
4
|
+
export type AppEnv = "development" | "preview" | "production";
|
|
5
|
+
|
|
6
|
+
export function resolveAppEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|
7
|
+
const raw = source.APP_ENV ?? source.VERCEL_ENV;
|
|
8
|
+
// production·preview만 명시 환경, 그 외(미설정·"" 포함)는 development로 안전 기본.
|
|
9
|
+
return raw === "production" || raw === "preview" ? raw : "development";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const APP_ENV: AppEnv = resolveAppEnv();
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App ↔ Web bridge contract — SINGLE SOURCE OF TRUTH.
|
|
5
|
+
*
|
|
6
|
+
* This file is copied byte-for-byte into the web project (docs/web-adapter/contract.ts).
|
|
7
|
+
* The two MUST stay identical; the web adapter ships a checksum check to enforce it.
|
|
8
|
+
*
|
|
9
|
+
* Invariants (never break these):
|
|
10
|
+
* 1. Every message is namespaced (`ns: "ssb"`) so unrelated postMessage traffic on
|
|
11
|
+
* the page is ignored.
|
|
12
|
+
* 2. The contract is ADDITIVE-ONLY. Add new message `type`s or new OPTIONAL payload
|
|
13
|
+
* fields; never remove a type, rename one, or make an existing field required.
|
|
14
|
+
* Old apps must keep working against new webs and vice-versa.
|
|
15
|
+
* 3. Features are gated by HELLO `capabilities` (a string array), NOT by version
|
|
16
|
+
* number. The app advertises what it can do; the web feature-detects.
|
|
17
|
+
* 4. The reader (reader.ts) is tolerant: anything unrecognized becomes UNKNOWN and
|
|
18
|
+
* never throws.
|
|
19
|
+
* 5. Auth tokens NEVER travel over this channel. Session handoff is a server
|
|
20
|
+
* Set-Cookie via a one-time nonce (see src/session + docs/web-adapter).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const SSB_NS = "ssb" as const;
|
|
24
|
+
export const SSB_PROTOCOL_VERSION = 1 as const;
|
|
25
|
+
|
|
26
|
+
/** All message type names. Add here (append-only) when extending the contract. */
|
|
27
|
+
export const MSG = {
|
|
28
|
+
// ── app → web ──
|
|
29
|
+
/** App announces itself + capabilities as soon as the page is reachable. */
|
|
30
|
+
HELLO: "HELLO",
|
|
31
|
+
/** App confirms a web session cookie was installed (after native login handoff). */
|
|
32
|
+
SESSION_INSTALLED: "SESSION_INSTALLED",
|
|
33
|
+
/** App confirms it cleared its local session copy after a logout. */
|
|
34
|
+
LOGOUT_DONE: "LOGOUT_DONE",
|
|
35
|
+
/** Forward-compat only — push is a fast-follow; defined so the web can feature-detect later. */
|
|
36
|
+
PUSH_TOKEN_UPDATED: "PUSH_TOKEN_UPDATED",
|
|
37
|
+
|
|
38
|
+
// ── web → app ──
|
|
39
|
+
/** Web acknowledges the bridge is live (handshake completes). */
|
|
40
|
+
READY: "READY",
|
|
41
|
+
/** Web has no session and asks the app to run native login + install a session. */
|
|
42
|
+
REQUEST_SESSION_INSTALL: "REQUEST_SESSION_INSTALL",
|
|
43
|
+
/** Web tells the app its auth state changed (e.g. user logged in/out inside the webview). */
|
|
44
|
+
AUTH_STATE_CHANGED: "AUTH_STATE_CHANGED",
|
|
45
|
+
/** Web asks the app to forget the session (user logged out). */
|
|
46
|
+
LOGOUT: "LOGOUT",
|
|
47
|
+
/** Web asks the app to open a URL in the system browser instead of the webview. */
|
|
48
|
+
OPEN_EXTERNAL: "OPEN_EXTERNAL",
|
|
49
|
+
|
|
50
|
+
// ── reader fallback (not a wire message) ──
|
|
51
|
+
UNKNOWN: "UNKNOWN",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
export type MessageType = (typeof MSG)[keyof typeof MSG];
|
|
55
|
+
|
|
56
|
+
/** Fields shared by every envelope. */
|
|
57
|
+
const envelopeBase = {
|
|
58
|
+
ns: z.literal(SSB_NS),
|
|
59
|
+
v: z.number().int(),
|
|
60
|
+
/** Correlation id, present on request/response pairs. */
|
|
61
|
+
id: z.string().optional(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ───────────────────────── web → app (inbound) ─────────────────────────
|
|
65
|
+
const ReadyMessage = z.object({
|
|
66
|
+
...envelopeBase,
|
|
67
|
+
type: z.literal(MSG.READY),
|
|
68
|
+
payload: z.object({}).optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const RequestSessionInstallMessage = z.object({
|
|
72
|
+
...envelopeBase,
|
|
73
|
+
type: z.literal(MSG.REQUEST_SESSION_INSTALL),
|
|
74
|
+
payload: z.object({ redirectPath: z.string().optional() }).optional(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const AuthStateChangedMessage = z.object({
|
|
78
|
+
...envelopeBase,
|
|
79
|
+
type: z.literal(MSG.AUTH_STATE_CHANGED),
|
|
80
|
+
payload: z.object({ authenticated: z.boolean() }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const LogoutMessage = z.object({
|
|
84
|
+
...envelopeBase,
|
|
85
|
+
type: z.literal(MSG.LOGOUT),
|
|
86
|
+
payload: z.object({}).optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const OpenExternalMessage = z.object({
|
|
90
|
+
...envelopeBase,
|
|
91
|
+
type: z.literal(MSG.OPEN_EXTERNAL),
|
|
92
|
+
payload: z.object({ url: z.string() }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const InboundSchema = z.discriminatedUnion("type", [
|
|
96
|
+
ReadyMessage,
|
|
97
|
+
RequestSessionInstallMessage,
|
|
98
|
+
AuthStateChangedMessage,
|
|
99
|
+
LogoutMessage,
|
|
100
|
+
OpenExternalMessage,
|
|
101
|
+
]);
|
|
102
|
+
export type InboundMessage = z.infer<typeof InboundSchema>;
|
|
103
|
+
|
|
104
|
+
// ───────────────────────── app → web (outbound) ─────────────────────────
|
|
105
|
+
const HelloMessage = z.object({
|
|
106
|
+
...envelopeBase,
|
|
107
|
+
type: z.literal(MSG.HELLO),
|
|
108
|
+
payload: z.object({
|
|
109
|
+
protocolVersion: z.number().int(),
|
|
110
|
+
capabilities: z.array(z.string()),
|
|
111
|
+
appVersion: z.string(),
|
|
112
|
+
platform: z.enum(["ios", "android"]),
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const SessionInstalledMessage = z.object({
|
|
117
|
+
...envelopeBase,
|
|
118
|
+
type: z.literal(MSG.SESSION_INSTALLED),
|
|
119
|
+
payload: z.object({ ok: z.boolean() }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const LogoutDoneMessage = z.object({
|
|
123
|
+
...envelopeBase,
|
|
124
|
+
type: z.literal(MSG.LOGOUT_DONE),
|
|
125
|
+
payload: z.object({}).optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const PushTokenUpdatedMessage = z.object({
|
|
129
|
+
...envelopeBase,
|
|
130
|
+
type: z.literal(MSG.PUSH_TOKEN_UPDATED),
|
|
131
|
+
payload: z.object({ token: z.string() }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const OutboundSchema = z.discriminatedUnion("type", [
|
|
135
|
+
HelloMessage,
|
|
136
|
+
SessionInstalledMessage,
|
|
137
|
+
LogoutDoneMessage,
|
|
138
|
+
PushTokenUpdatedMessage,
|
|
139
|
+
]);
|
|
140
|
+
export type OutboundMessage = z.infer<typeof OutboundSchema>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The tolerant reader's fallback shape. Not a valid wire message — produced only
|
|
144
|
+
* when an inbound payload cannot be understood. See reader.ts.
|
|
145
|
+
*/
|
|
146
|
+
export type UnknownMessage = { type: typeof MSG.UNKNOWN; raw: unknown };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { InboundSchema, MSG, type InboundMessage, type UnknownMessage } from "./contract";
|
|
2
|
+
|
|
3
|
+
export type ReadResult = InboundMessage | UnknownMessage;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tolerant reader for messages arriving FROM the web (WebView `onMessage`).
|
|
7
|
+
*
|
|
8
|
+
* Hard contract: this function NEVER throws. Anything it cannot confidently
|
|
9
|
+
* understand — wrong namespace, non-JSON string, unknown `type`, missing/invalid
|
|
10
|
+
* fields, a number, null — collapses to `{ type: "UNKNOWN", raw }`. The native
|
|
11
|
+
* shell must survive hostile, outdated, or unrelated postMessage traffic.
|
|
12
|
+
*
|
|
13
|
+
* `raw` is whatever `event.nativeEvent.data` gave us (usually a JSON string, but
|
|
14
|
+
* a parsed object is accepted too).
|
|
15
|
+
*/
|
|
16
|
+
export function readInbound(raw: unknown): ReadResult {
|
|
17
|
+
let candidate: unknown = raw;
|
|
18
|
+
|
|
19
|
+
if (typeof raw === "string") {
|
|
20
|
+
try {
|
|
21
|
+
candidate = JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
return { type: MSG.UNKNOWN, raw };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parsed = InboundSchema.safeParse(candidate);
|
|
28
|
+
if (parsed.success) return parsed.data;
|
|
29
|
+
|
|
30
|
+
return { type: MSG.UNKNOWN, raw };
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import "server-only"; // 클라이언트 bundle 침투 시 빌드 에러로 차단
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { env as clientEnv, emptyAsUndefined } from "@/lib/env";
|
|
4
|
+
import { resolveAppEnv } from "@/lib/app-env";
|
|
5
|
+
|
|
6
|
+
// 통합은 전부 optional + 빈 문자열 정규화 → 키가 없으면 조용히 no-op(graceful degradation).
|
|
7
|
+
const ServerSchema = z.object({
|
|
8
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
9
|
+
// secret key 부재 시 admin 클라이언트 생성이 throw. parsing은 통과(CI test·미설정 통합 허용).
|
|
10
|
+
SUPABASE_SECRET_KEY: emptyAsUndefined(z.string().optional()),
|
|
11
|
+
SUPABASE_DEV_REF: emptyAsUndefined(z.string().optional()),
|
|
12
|
+
SUPABASE_PROD_REF: emptyAsUndefined(z.string().optional()),
|
|
13
|
+
SENTRY_AUTH_TOKEN: emptyAsUndefined(z.string().optional()),
|
|
14
|
+
SENTRY_ORG: emptyAsUndefined(z.string().optional()),
|
|
15
|
+
SENTRY_PROJECT: emptyAsUndefined(z.string().optional()),
|
|
16
|
+
// CRON_SECRET, RESEND_API_KEY 등은 해당 기능 추가 시 더한다.
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const serverOnly = ServerSchema.parse({
|
|
20
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
21
|
+
SUPABASE_SECRET_KEY: process.env.SUPABASE_SECRET_KEY,
|
|
22
|
+
SUPABASE_DEV_REF: process.env.SUPABASE_DEV_REF,
|
|
23
|
+
SUPABASE_PROD_REF: process.env.SUPABASE_PROD_REF,
|
|
24
|
+
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
|
25
|
+
SENTRY_ORG: process.env.SENTRY_ORG,
|
|
26
|
+
SENTRY_PROJECT: process.env.SENTRY_PROJECT,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 벤더 중립 배포 환경. Vercel은 VERCEL_ENV 자동 매핑, 그 외 호스트는 APP_ENV. 코드 분기는 이 값으로.
|
|
30
|
+
const APP_ENV = resolveAppEnv();
|
|
31
|
+
|
|
32
|
+
// client 필드도 같이 묶어 server 코드는 이 하나로 모든 env 접근.
|
|
33
|
+
export const env = { ...clientEnv, ...serverOnly, APP_ENV };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// 클라이언트·서버 어디서나 import 가능.
|
|
2
|
+
// 서버 전용 키(SUPABASE_SECRET_KEY 등)는 `lib/env.server.ts`에 분리.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
// Vercel은 미설정 변수를 undefined가 아니라 빈 문자열("")로 주입한다. ""를 undefined로 정규화해
|
|
6
|
+
// optional 스키마가 빈 값에서 throw하지 않게 하고, 미설정 통합이 깔끔히 비활성(no-op)되게 한다.
|
|
7
|
+
export const emptyAsUndefined = <T extends z.ZodType>(schema: T) =>
|
|
8
|
+
z.preprocess((v) => (v === "" ? undefined : v), schema);
|
|
9
|
+
|
|
10
|
+
// 코어 한 축(Supabase)만 부팅 필수. 나머지 통합은 optional이라 키가 없으면 조용히 비활성.
|
|
11
|
+
const ClientSchema = z.object({
|
|
12
|
+
NEXT_PUBLIC_SUPABASE_URL: z.url(),
|
|
13
|
+
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: z.string().min(1),
|
|
14
|
+
NEXT_PUBLIC_SENTRY_DSN: emptyAsUndefined(z.url().optional()),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const env = ClientSchema.parse({
|
|
18
|
+
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
19
|
+
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
|
20
|
+
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
21
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import "server-only"; // 클라이언트 컴포넌트 import 시 빌드 에러로 잡힘
|
|
2
|
+
import * as Sentry from "@sentry/nextjs";
|
|
3
|
+
import { createSupabaseAdmin } from "@/lib/supabase/admin";
|
|
4
|
+
import { env } from "@/lib/env.server";
|
|
5
|
+
|
|
6
|
+
type Level = "info" | "warn" | "error" | "audit";
|
|
7
|
+
|
|
8
|
+
async function write(level: Level, message: string, context?: Record<string, unknown>) {
|
|
9
|
+
const entry = { level, message, context, ts: new Date().toISOString() };
|
|
10
|
+
if (level === "error") console.error(entry); else console.log(entry);
|
|
11
|
+
|
|
12
|
+
if (level === "error") {
|
|
13
|
+
Sentry.captureException(new Error(message), { extra: context });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// RLS 정책이 service_role만 insert 허용하므로 admin 클라이언트.
|
|
17
|
+
if (env.SUPABASE_SECRET_KEY) {
|
|
18
|
+
try {
|
|
19
|
+
const admin = createSupabaseAdmin();
|
|
20
|
+
await admin.from("logs").insert({ level, message, context });
|
|
21
|
+
} catch {
|
|
22
|
+
/* logs 테이블 미존재·연결 실패는 무시 */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const logger = {
|
|
28
|
+
info: (msg: string, ctx?: Record<string, unknown>) => write("info", msg, ctx),
|
|
29
|
+
warn: (msg: string, ctx?: Record<string, unknown>) => write("warn", msg, ctx),
|
|
30
|
+
error: (msg: string, ctx?: Record<string, unknown>) => write("error", msg, ctx),
|
|
31
|
+
audit: (msg: string, ctx?: Record<string, unknown>) => write("audit", msg, ctx),
|
|
32
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import "server-only"; // 클라이언트 import 차단
|
|
2
|
+
// secret 키 사용. RLS 우회. 서버 전용.
|
|
3
|
+
// 일반 사용자 데이터 접근은 server.ts(publishable + 세션) 사용.
|
|
4
|
+
import { createClient } from "@supabase/supabase-js";
|
|
5
|
+
import { env } from "@/lib/env.server";
|
|
6
|
+
|
|
7
|
+
export function createSupabaseAdmin() {
|
|
8
|
+
if (!env.SUPABASE_SECRET_KEY) {
|
|
9
|
+
throw new Error("SUPABASE_SECRET_KEY missing — admin client unavailable");
|
|
10
|
+
}
|
|
11
|
+
return createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SECRET_KEY, {
|
|
12
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createServerClient } from "@supabase/ssr";
|
|
2
|
+
import { cookies } from "next/headers";
|
|
3
|
+
import { env } from "@/lib/env";
|
|
4
|
+
|
|
5
|
+
export async function createSupabaseServer() {
|
|
6
|
+
const cookieStore = await cookies();
|
|
7
|
+
return createServerClient(
|
|
8
|
+
env.NEXT_PUBLIC_SUPABASE_URL,
|
|
9
|
+
env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
|
10
|
+
{
|
|
11
|
+
cookies: {
|
|
12
|
+
getAll: () => cookieStore.getAll(),
|
|
13
|
+
setAll: (cookiesToSet) => {
|
|
14
|
+
try {
|
|
15
|
+
cookiesToSet.forEach(({ name, value, options }) =>
|
|
16
|
+
cookieStore.set(name, value, options));
|
|
17
|
+
} catch {
|
|
18
|
+
/* server component은 set 불가 — middleware가 갱신 책임 */
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
import { withSentryConfig } from "@sentry/nextjs";
|
|
3
|
+
|
|
4
|
+
const nextConfig: NextConfig = {
|
|
5
|
+
/* config options here */
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// Sentry env가 없으면 sourcemap upload만 스킵, 빌드는 통과.
|
|
9
|
+
export default withSentryConfig(nextConfig, {
|
|
10
|
+
org: process.env.SENTRY_ORG,
|
|
11
|
+
project: process.env.SENTRY_PROJECT,
|
|
12
|
+
silent: !process.env.CI,
|
|
13
|
+
widenClientFileUpload: true,
|
|
14
|
+
// Vercel Cron 추가 시 cron monitor 자동등록 옵션(webpack.automaticVercelMonitors)을 여기 더한다
|
|
15
|
+
// — cron 가이드 참조. cron이 없는 기본 상태에선 두지 않아 deprecation 경고를 피한다.
|
|
16
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Supply-chain 안전망
|
|
2
|
+
#
|
|
3
|
+
# 새로 publish된 패키지는 14일이 지나야 install·resolve 대상에 포함된다.
|
|
4
|
+
# 메인테이너 계정 탈취·typosquatting 같은 공격은 보통 publish 후 48시간 내에
|
|
5
|
+
# 발견·yank되므로, 14일 cooldown은 community 검증 윈도우를 확보한다.
|
|
6
|
+
#
|
|
7
|
+
# 단위: 분. 14일 = 14 * 24 * 60 = 20160.
|
|
8
|
+
# 요구: pnpm >= 10.10
|
|
9
|
+
minimum-release-age=20160
|
|
10
|
+
|
|
11
|
+
# 잠금파일 무결성: install 시 lockfile에 박힌 hash·버전 그대로만 사용.
|
|
12
|
+
# 누군가 임의로 lockfile을 수정하면 install이 실패한다.
|
|
13
|
+
# (CI는 별도로 --frozen-lockfile flag 사용)
|
|
14
|
+
verify-deps-before-run=true
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-space",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "eslint",
|
|
10
|
+
"lint:ci": "eslint --max-warnings 0",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@base-ui/react": "^1.4.1",
|
|
17
|
+
"@sentry/nextjs": "^10.52.0",
|
|
18
|
+
"@supabase/ssr": "^0.10.3",
|
|
19
|
+
"@supabase/supabase-js": "^2.105.4",
|
|
20
|
+
"class-variance-authority": "^0.7.1",
|
|
21
|
+
"clsx": "^2.1.1",
|
|
22
|
+
"lucide-react": "^1.14.0",
|
|
23
|
+
"next": "^16.2.6",
|
|
24
|
+
"react": "^19.2.4",
|
|
25
|
+
"react-dom": "^19.2.4",
|
|
26
|
+
"server-only": "^0.0.1",
|
|
27
|
+
"tailwind-merge": "^3.5.0",
|
|
28
|
+
"tw-animate-css": "^1.4.0",
|
|
29
|
+
"zod": "^4.4.3"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@tailwindcss/postcss": "^4.3.0",
|
|
33
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
34
|
+
"@testing-library/react": "^16.3.2",
|
|
35
|
+
"@testing-library/user-event": "^14.6.1",
|
|
36
|
+
"@types/node": "^20.19.40",
|
|
37
|
+
"@types/react": "^19.2.14",
|
|
38
|
+
"@types/react-dom": "^19.2.3",
|
|
39
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
40
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
41
|
+
"eslint": "^9.39.4",
|
|
42
|
+
"eslint-config-next": "^16.2.6",
|
|
43
|
+
"jsdom": "^29.1.1",
|
|
44
|
+
"shadcn": "^4.7.0",
|
|
45
|
+
"supabase": "^2.101.0",
|
|
46
|
+
"tailwindcss": "^4.3.0",
|
|
47
|
+
"typescript": "^5.9.3",
|
|
48
|
+
"vitest": "^4.1.5"
|
|
49
|
+
},
|
|
50
|
+
"pnpm": {
|
|
51
|
+
"overrides": {
|
|
52
|
+
"postcss@<8.5.10": "^8.5.10"
|
|
53
|
+
},
|
|
54
|
+
"onlyBuiltDependencies": [
|
|
55
|
+
"@sentry/cli",
|
|
56
|
+
"supabase"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"packageManager": "pnpm@10.33.2"
|
|
60
|
+
}
|