create-aelith-app 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/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # create-aelith-app
2
+
3
+ Scaffold a Next.js starter wired for Aelith hosted auth and SDK wallet auth.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npm create aelith-app@latest my-app
9
+ ```
10
+
11
+ Or run the local checkout directly while developing the CLI:
12
+
13
+ ```bash
14
+ node ./bin/index.js my-app
15
+ ```
16
+
17
+ ## What it generates
18
+
19
+ - Next.js 16 App Router starter with TypeScript and Tailwind CSS v4
20
+ - Solana wallet adapter wiring for Phantom, Solflare, and Backpack
21
+ - Hosted login callback route at `/api/auth/aelith/callback`
22
+ - SDK session route at `/api/auth/aelith/session`
23
+ - A small demo UI that shows both login modes and the verified session state
24
+
25
+ ## Local development
26
+
27
+ ```bash
28
+ npm install
29
+ node ./bin/index.js demo-app --skip-install
30
+ ```
31
+
32
+ The scaffold replaces `__APP_NAME__` and `__APP_ID__` tokens throughout the template before writing the new project.
package/bin/index.js ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { existsSync } from 'node:fs'
5
+ import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
6
+ import { createInterface } from 'node:readline/promises'
7
+ import path from 'node:path'
8
+ import process from 'node:process'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const __filename = fileURLToPath(import.meta.url)
12
+ const __dirname = path.dirname(__filename)
13
+ const TEMPLATE_DIR = path.resolve(__dirname, '..', 'template')
14
+ const TEXT_FILE_EXTENSIONS = new Set([
15
+ '.css',
16
+ '.example',
17
+ '.gitignore',
18
+ '.js',
19
+ '.json',
20
+ '.md',
21
+ '.mjs',
22
+ '.ts',
23
+ '.tsx',
24
+ '.txt',
25
+ ])
26
+
27
+ function getErrorMessage(error, fallback) {
28
+ if (error instanceof Error && error.message.trim()) {
29
+ return error.message
30
+ }
31
+
32
+ return fallback
33
+ }
34
+
35
+ function isTextFile(filePath) {
36
+ const extension = path.extname(filePath).toLowerCase()
37
+ return (
38
+ TEXT_FILE_EXTENSIONS.has(extension) ||
39
+ path.basename(filePath) === '.gitignore' ||
40
+ path.basename(filePath).startsWith('.env')
41
+ )
42
+ }
43
+
44
+ function slugifyAppId(value) {
45
+ const normalized = value
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, '-')
48
+ .replace(/^-+|-+$/g, '')
49
+ .slice(0, 50)
50
+
51
+ return normalized || 'aelith-app'
52
+ }
53
+
54
+ async function promptForProjectName() {
55
+ const rl = createInterface({
56
+ input: process.stdin,
57
+ output: process.stdout,
58
+ })
59
+
60
+ try {
61
+ const answer = await rl.question('Project name: ')
62
+ return answer.trim()
63
+ } finally {
64
+ rl.close()
65
+ }
66
+ }
67
+
68
+ async function ensureTargetDirectory(targetDir) {
69
+ if (!existsSync(targetDir)) {
70
+ return
71
+ }
72
+
73
+ const entries = await readdir(targetDir)
74
+ if (entries.length > 0) {
75
+ throw new Error(`Target directory already exists and is not empty: ${targetDir}`)
76
+ }
77
+ }
78
+
79
+ async function replaceTokensInDirectory(targetDir, replacements) {
80
+ const entries = await readdir(targetDir, { withFileTypes: true })
81
+
82
+ for (const entry of entries) {
83
+ const absolutePath = path.join(targetDir, entry.name)
84
+
85
+ if (entry.isDirectory()) {
86
+ await replaceTokensInDirectory(absolutePath, replacements)
87
+ continue
88
+ }
89
+
90
+ if (!entry.isFile() || !isTextFile(absolutePath)) {
91
+ continue
92
+ }
93
+
94
+ let content = await readFile(absolutePath, 'utf8')
95
+ for (const [token, value] of Object.entries(replacements)) {
96
+ content = content.split(token).join(value)
97
+ }
98
+
99
+ await writeFile(absolutePath, content, 'utf8')
100
+ }
101
+ }
102
+
103
+ async function installDependencies(targetDir) {
104
+ await new Promise((resolve, reject) => {
105
+ const command = process.platform === 'win32' ? 'npm.cmd' : 'npm'
106
+ const child = spawn(command, ['install'], {
107
+ cwd: targetDir,
108
+ stdio: 'inherit',
109
+ })
110
+
111
+ child.on('error', reject)
112
+ child.on('exit', (code) => {
113
+ if (code === 0) {
114
+ resolve()
115
+ return
116
+ }
117
+
118
+ reject(new Error(`npm install exited with code ${code ?? 'unknown'}.`))
119
+ })
120
+ })
121
+ }
122
+
123
+ async function main() {
124
+ const args = process.argv.slice(2)
125
+ const skipInstall = args.includes('--skip-install')
126
+ const rawProjectName = args.find((arg) => !arg.startsWith('-')) ?? await promptForProjectName()
127
+
128
+ if (!rawProjectName) {
129
+ throw new Error('Project name is required.')
130
+ }
131
+
132
+ const projectName = rawProjectName.trim()
133
+ const targetDir = path.resolve(process.cwd(), projectName)
134
+ const projectDirName = path.basename(targetDir)
135
+ const appId = slugifyAppId(projectDirName)
136
+
137
+ await ensureTargetDirectory(targetDir)
138
+ await mkdir(targetDir, { recursive: true })
139
+ await cp(TEMPLATE_DIR, targetDir, { recursive: true })
140
+ await replaceTokensInDirectory(targetDir, {
141
+ __APP_NAME__: projectDirName,
142
+ __APP_ID__: appId,
143
+ })
144
+
145
+ if (!skipInstall) {
146
+ console.log('')
147
+ console.log(`Installing dependencies in ${projectDirName}...`)
148
+ await installDependencies(targetDir)
149
+ }
150
+
151
+ console.log('')
152
+ console.log(`Aelith starter created at ${targetDir}`)
153
+ console.log('')
154
+ console.log('Next steps:')
155
+ console.log(` cd ${projectName}`)
156
+ if (skipInstall) {
157
+ console.log(' npm install')
158
+ }
159
+ console.log(' cp .env.example .env.local')
160
+ console.log(' npm run dev')
161
+ console.log('')
162
+ console.log(
163
+ 'Register http://localhost:3000/api/auth/aelith/callback at auth.aelith.ai -> your app -> redirect URIs'
164
+ )
165
+ }
166
+
167
+ main().catch((error) => {
168
+ console.error('')
169
+ console.error(getErrorMessage(error, 'Failed to create the Aelith starter.'))
170
+ process.exit(1)
171
+ })
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "create-aelith-app",
3
+ "version": "0.1.0",
4
+ "description": "CLI to scaffold a Next.js starter for Aelith authentication.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-aelith-app": "./bin/index.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "template",
13
+ "template/.env.example",
14
+ "template/.gitignore",
15
+ "template/next-env.d.ts",
16
+ "README.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20.9.0"
20
+ }
21
+ }
@@ -0,0 +1,6 @@
1
+ NEXT_PUBLIC_AELITH_APP_ID=__APP_ID__
2
+ NEXT_PUBLIC_AELITH_AUTH_ORIGIN=https://auth.aelith.ai
3
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
4
+ NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
5
+ AELITH_API_KEY=replace_with_your_server_side_api_key
6
+ AELITH_SESSION_COOKIE_NAME=__APP_ID___session
@@ -0,0 +1,8 @@
1
+ node_modules
2
+ .next
3
+ out
4
+ build
5
+ .DS_Store
6
+ .env.local
7
+ next-env.d.ts
8
+ tsconfig.tsbuildinfo
@@ -0,0 +1,49 @@
1
+ # __APP_NAME__
2
+
3
+ Starter project for Aelith hosted auth and SDK wallet auth with Next.js 16.
4
+
5
+ Wallet support is wired with Phantom and Solflare adapters, and Wallet Standard wallets such as Backpack can still appear through the shared wallet-standard integration.
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ cp .env.example .env.local
11
+ npm install
12
+ npm run dev
13
+ ```
14
+
15
+ Update `.env.local` before testing auth:
16
+
17
+ ```bash
18
+ NEXT_PUBLIC_AELITH_APP_ID=__APP_ID__
19
+ NEXT_PUBLIC_AELITH_AUTH_ORIGIN=https://auth.aelith.ai
20
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
21
+ NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
22
+ AELITH_API_KEY=replace_with_your_server_side_api_key
23
+ ```
24
+
25
+ ## Portal wiring
26
+
27
+ Register this redirect URI for your Aelith app:
28
+
29
+ ```text
30
+ http://localhost:3000/api/auth/aelith/callback
31
+ ```
32
+
33
+ The template demonstrates two paths:
34
+
35
+ - Hosted mode: browser redirects to `auth.aelith.ai/login`, then returns to `/api/auth/aelith/callback`
36
+ - SDK mode: the wallet signs locally, the browser exchanges the signature through `@aelith/auth-core`, then the app stores a verified session via `/api/auth/aelith/session`
37
+
38
+ ## Session route behavior
39
+
40
+ `/api/auth/aelith/session` verifies tokens against `https://auth.aelith.ai/api/verify`, so `AELITH_API_KEY` must be set on the server side.
41
+
42
+ ## Useful files
43
+
44
+ - `app/page.tsx`: starter landing page and setup notes
45
+ - `app/api/auth/aelith/callback/route.ts`: hosted flow callback
46
+ - `app/api/auth/aelith/session/route.ts`: local session store and verification route
47
+ - `components/wallet-provider.tsx`: Solana wallet adapter wiring
48
+ - `lib/aelith.ts`: public Aelith client config
49
+ - `lib/session.ts`: server-only token verification helpers
@@ -0,0 +1,36 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import {
3
+ buildAuthenticatedSessionState,
4
+ getSessionCookieName,
5
+ getSessionCookieOptions,
6
+ getSessionErrorMessage,
7
+ } from '@/lib/session'
8
+
9
+ export const dynamic = 'force-dynamic'
10
+
11
+ export async function GET(request: NextRequest) {
12
+ const redirectUrl = new URL('/', request.url)
13
+ const token = request.nextUrl.searchParams.get('token')?.trim()
14
+ const onboarding = request.nextUrl.searchParams.get('onboarding')
15
+
16
+ if (!token) {
17
+ redirectUrl.searchParams.set('error', 'Hosted login did not include a token.')
18
+ return NextResponse.redirect(redirectUrl)
19
+ }
20
+
21
+ try {
22
+ await buildAuthenticatedSessionState(token)
23
+ redirectUrl.searchParams.set('flow', 'hosted')
24
+
25
+ if (onboarding === '1') {
26
+ redirectUrl.searchParams.set('onboarding', '1')
27
+ }
28
+
29
+ const response = NextResponse.redirect(redirectUrl)
30
+ response.cookies.set(getSessionCookieName(), token, getSessionCookieOptions())
31
+ return response
32
+ } catch (error) {
33
+ redirectUrl.searchParams.set('error', getSessionErrorMessage(error))
34
+ return NextResponse.redirect(redirectUrl)
35
+ }
36
+ }
@@ -0,0 +1,91 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import {
3
+ buildAuthenticatedSessionState,
4
+ getBaseSessionState,
5
+ getClearedSessionCookieOptions,
6
+ getSessionCookieName,
7
+ getSessionCookieOptions,
8
+ getSessionErrorMessage,
9
+ } from '@/lib/session'
10
+
11
+ export const dynamic = 'force-dynamic'
12
+
13
+ function json(body: unknown, init?: ResponseInit) {
14
+ return NextResponse.json(body, {
15
+ ...init,
16
+ headers: {
17
+ 'Cache-Control': 'no-store',
18
+ ...(init?.headers ?? {}),
19
+ },
20
+ })
21
+ }
22
+
23
+ export async function GET(request: NextRequest) {
24
+ const token = request.cookies.get(getSessionCookieName())?.value
25
+ if (!token) {
26
+ return json(getBaseSessionState())
27
+ }
28
+
29
+ try {
30
+ const session = await buildAuthenticatedSessionState(token)
31
+ return json(session)
32
+ } catch (error) {
33
+ const response = json(
34
+ {
35
+ ...getBaseSessionState(),
36
+ error: getSessionErrorMessage(error),
37
+ },
38
+ { status: 200 }
39
+ )
40
+
41
+ response.cookies.set(getSessionCookieName(), '', getClearedSessionCookieOptions())
42
+ return response
43
+ }
44
+ }
45
+
46
+ export async function POST(request: NextRequest) {
47
+ const contentType = request.headers.get('content-type')?.toLowerCase() ?? ''
48
+ if (!contentType.includes('application/json')) {
49
+ return json(
50
+ {
51
+ ...getBaseSessionState(),
52
+ error: 'Expected an application/json request body.',
53
+ },
54
+ { status: 415 }
55
+ )
56
+ }
57
+
58
+ const body = (await request.json().catch(() => null)) as { token?: unknown } | null
59
+ const token = typeof body?.token === 'string' ? body.token.trim() : ''
60
+
61
+ if (!token) {
62
+ return json(
63
+ {
64
+ ...getBaseSessionState(),
65
+ error: 'token is required.',
66
+ },
67
+ { status: 400 }
68
+ )
69
+ }
70
+
71
+ try {
72
+ const session = await buildAuthenticatedSessionState(token)
73
+ const response = json(session)
74
+ response.cookies.set(getSessionCookieName(), token, getSessionCookieOptions())
75
+ return response
76
+ } catch (error) {
77
+ return json(
78
+ {
79
+ ...getBaseSessionState(),
80
+ error: getSessionErrorMessage(error),
81
+ },
82
+ { status: 400 }
83
+ )
84
+ }
85
+ }
86
+
87
+ export async function DELETE() {
88
+ const response = json(getBaseSessionState())
89
+ response.cookies.set(getSessionCookieName(), '', getClearedSessionCookieOptions())
90
+ return response
91
+ }
@@ -0,0 +1,170 @@
1
+ @import 'tailwindcss';
2
+
3
+ :root {
4
+ --font-sans: 'Avenir Next', 'Inter Tight', 'Segoe UI', sans-serif;
5
+ --font-mono: 'IBM Plex Mono', 'SFMono-Regular', 'Menlo', monospace;
6
+ --bg-0: #051019;
7
+ --bg-1: #0a1823;
8
+ --panel: rgba(7, 19, 29, 0.78);
9
+ --panel-strong: rgba(9, 24, 36, 0.92);
10
+ --border: rgba(255, 244, 228, 0.1);
11
+ --text-strong: #f9f5eb;
12
+ --text-soft: rgba(249, 245, 235, 0.72);
13
+ --signal: #ff936a;
14
+ --signal-soft: rgba(255, 147, 106, 0.18);
15
+ --mint: #73ffd1;
16
+ --mint-soft: rgba(115, 255, 209, 0.16);
17
+ --danger: #ff857d;
18
+ }
19
+
20
+ * {
21
+ box-sizing: border-box;
22
+ }
23
+
24
+ html {
25
+ color-scheme: dark;
26
+ }
27
+
28
+ body {
29
+ min-height: 100vh;
30
+ margin: 0;
31
+ background:
32
+ radial-gradient(circle at 18% 18%, rgba(255, 147, 106, 0.22), transparent 28%),
33
+ radial-gradient(circle at 80% 14%, rgba(115, 255, 209, 0.15), transparent 22%),
34
+ linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 48%, #0b202c 100%);
35
+ color: var(--text-strong);
36
+ font-family: var(--font-sans), sans-serif;
37
+ }
38
+
39
+ body::before {
40
+ content: '';
41
+ position: fixed;
42
+ inset: 0;
43
+ pointer-events: none;
44
+ opacity: 0.26;
45
+ background-image:
46
+ linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
47
+ linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
48
+ background-size: 96px 96px;
49
+ mask-image: radial-gradient(circle at center, black, transparent 82%);
50
+ }
51
+
52
+ a {
53
+ color: inherit;
54
+ text-decoration: none;
55
+ }
56
+
57
+ ::selection {
58
+ background: rgba(255, 147, 106, 0.28);
59
+ color: var(--text-strong);
60
+ }
61
+
62
+ .mono {
63
+ font-family: var(--font-mono), monospace;
64
+ }
65
+
66
+ .surface {
67
+ position: relative;
68
+ overflow: hidden;
69
+ border: 1px solid var(--border);
70
+ background: var(--panel);
71
+ box-shadow:
72
+ 0 24px 80px rgba(0, 0, 0, 0.28),
73
+ inset 0 1px 0 rgba(255, 255, 255, 0.03);
74
+ backdrop-filter: blur(18px);
75
+ }
76
+
77
+ .hero-glow::after {
78
+ content: '';
79
+ position: absolute;
80
+ inset: auto -18% -44% auto;
81
+ width: 320px;
82
+ height: 320px;
83
+ border-radius: 999px;
84
+ background: radial-gradient(circle, rgba(255, 147, 106, 0.22), transparent 70%);
85
+ pointer-events: none;
86
+ }
87
+
88
+ .eyebrow {
89
+ font-family: var(--font-mono), monospace;
90
+ font-size: 0.76rem;
91
+ font-weight: 600;
92
+ letter-spacing: 0.22em;
93
+ text-transform: uppercase;
94
+ color: rgba(249, 245, 235, 0.55);
95
+ }
96
+
97
+ .meta-chip {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ border-radius: 999px;
101
+ border: 1px solid rgba(255, 255, 255, 0.08);
102
+ background: rgba(255, 255, 255, 0.04);
103
+ padding: 0.65rem 0.9rem;
104
+ font-family: var(--font-mono), monospace;
105
+ font-size: 0.78rem;
106
+ color: rgba(249, 245, 235, 0.72);
107
+ }
108
+
109
+ .flow-card {
110
+ border-radius: 1.6rem;
111
+ border: 1px solid rgba(255, 255, 255, 0.08);
112
+ background: var(--panel-strong);
113
+ padding: 1.25rem;
114
+ }
115
+
116
+ .flow-card p:last-child {
117
+ margin-bottom: 0;
118
+ }
119
+
120
+ .notice {
121
+ border-radius: 1.2rem;
122
+ padding: 0.9rem 1rem;
123
+ font-size: 0.95rem;
124
+ line-height: 1.6;
125
+ }
126
+
127
+ .notice-success {
128
+ border: 1px solid rgba(115, 255, 209, 0.24);
129
+ background: rgba(115, 255, 209, 0.08);
130
+ color: #d9fff2;
131
+ }
132
+
133
+ .notice-error {
134
+ border: 1px solid rgba(255, 133, 125, 0.24);
135
+ background: rgba(255, 133, 125, 0.08);
136
+ color: #ffe3e0;
137
+ }
138
+
139
+ .session-grid {
140
+ display: grid;
141
+ grid-template-columns: auto 1fr;
142
+ gap: 0.75rem 1rem;
143
+ }
144
+
145
+ .session-grid dt {
146
+ font-family: var(--font-mono), monospace;
147
+ font-size: 0.75rem;
148
+ letter-spacing: 0.14em;
149
+ text-transform: uppercase;
150
+ color: rgba(249, 245, 235, 0.46);
151
+ }
152
+
153
+ .session-grid dd {
154
+ margin: 0;
155
+ min-width: 0;
156
+ color: var(--text-strong);
157
+ word-break: break-word;
158
+ }
159
+
160
+ .wallet-button {
161
+ min-height: 48px !important;
162
+ border-radius: 999px !important;
163
+ background: linear-gradient(135deg, #ff936a 0%, #ffd165 100%) !important;
164
+ color: #041019 !important;
165
+ font-family: var(--font-sans), sans-serif !important;
166
+ font-size: 0.95rem !important;
167
+ font-weight: 700 !important;
168
+ line-height: 1 !important;
169
+ box-shadow: 0 20px 40px rgba(255, 147, 106, 0.22) !important;
170
+ }
@@ -0,0 +1,21 @@
1
+ import './globals.css'
2
+ import '@solana/wallet-adapter-react-ui/styles.css'
3
+
4
+ import type { Metadata } from 'next'
5
+ import type { ReactNode } from 'react'
6
+ import { AelithWalletProvider } from '@/components/wallet-provider'
7
+
8
+ export const metadata: Metadata = {
9
+ title: '__APP_NAME__',
10
+ description: 'Starter app for Aelith hosted auth and SDK wallet auth.',
11
+ }
12
+
13
+ export default function RootLayout({ children }: { children: ReactNode }) {
14
+ return (
15
+ <html lang="en">
16
+ <body>
17
+ <AelithWalletProvider>{children}</AelithWalletProvider>
18
+ </body>
19
+ </html>
20
+ )
21
+ }
@@ -0,0 +1,81 @@
1
+ import { AuthDemo } from '@/components/auth-demo'
2
+
3
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>
4
+
5
+ function pickFirst(value: string | string[] | undefined) {
6
+ if (Array.isArray(value)) {
7
+ return value[0] ?? null
8
+ }
9
+
10
+ return value ?? null
11
+ }
12
+
13
+ export default async function Page({
14
+ searchParams,
15
+ }: {
16
+ searchParams: SearchParams
17
+ }) {
18
+ const params = await searchParams
19
+ const error = pickFirst(params.error)
20
+ const flow = pickFirst(params.flow)
21
+ const onboarding = pickFirst(params.onboarding)
22
+
23
+ const initialNotice = error
24
+ ? { tone: 'error' as const, message: error }
25
+ : flow === 'hosted'
26
+ ? {
27
+ tone: 'success' as const,
28
+ message:
29
+ onboarding === '1'
30
+ ? 'Hosted login finished and returned an onboarding token.'
31
+ : 'Hosted login finished and the callback route created a local session.',
32
+ }
33
+ : null
34
+
35
+ return (
36
+ <main className="mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-8 px-6 pb-16 pt-10 sm:px-10 lg:px-12">
37
+ <section className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
38
+ <div className="surface hero-glow rounded-[2rem] p-8 sm:p-10">
39
+ <p className="eyebrow">Aelith Auth Starter</p>
40
+ <h1 className="mt-4 max-w-3xl text-4xl font-semibold tracking-[-0.04em] text-[var(--text-strong)] sm:text-6xl">
41
+ Ship wallet auth without rebuilding the boring parts.
42
+ </h1>
43
+ <p className="mt-5 max-w-2xl text-base leading-7 text-[var(--text-soft)] sm:text-lg">
44
+ This starter gives you a hosted login redirect, a local wallet signing flow,
45
+ and a server-verified demo session in one Next.js app.
46
+ </p>
47
+
48
+ <div className="mt-8 flex flex-wrap gap-3">
49
+ <span className="meta-chip">Next.js 16 App Router</span>
50
+ <span className="meta-chip">Tailwind CSS v4</span>
51
+ <span className="meta-chip">Phantom, Solflare, Backpack</span>
52
+ </div>
53
+ </div>
54
+
55
+ <aside className="surface rounded-[2rem] p-8">
56
+ <p className="eyebrow">First run</p>
57
+ <ol className="mt-5 space-y-4 text-sm leading-6 text-[var(--text-soft)]">
58
+ <li>
59
+ Copy <span className="mono">.env.example</span> to{' '}
60
+ <span className="mono">.env.local</span>.
61
+ </li>
62
+ <li>
63
+ Set <span className="mono">AELITH_API_KEY</span> so the session route can
64
+ verify returned tokens.
65
+ </li>
66
+ <li>
67
+ Register{' '}
68
+ <span className="mono">
69
+ http://localhost:3000/api/auth/aelith/callback
70
+ </span>{' '}
71
+ in the Aelith portal.
72
+ </li>
73
+ <li>Test hosted login, then test the SDK wallet flow after connecting a wallet.</li>
74
+ </ol>
75
+ </aside>
76
+ </section>
77
+
78
+ <AuthDemo initialNotice={initialNotice} />
79
+ </main>
80
+ )
81
+ }
@@ -0,0 +1,325 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useWallet } from '@solana/wallet-adapter-react'
5
+ import { WalletMultiButton } from '@solana/wallet-adapter-react-ui'
6
+ import {
7
+ aelithClient,
8
+ getDefaultHostedRedirectUri,
9
+ getErrorMessage,
10
+ getHostedRedirectUri,
11
+ parseJson,
12
+ SESSION_ENDPOINT,
13
+ type AelithSessionState,
14
+ type NoticeState,
15
+ } from '@/lib/aelith'
16
+ import { LoginButton } from './login-button'
17
+
18
+ type BusyState = 'session' | 'hosted' | 'sdk' | 'signout' | null
19
+
20
+ async function requestSessionState() {
21
+ const response = await fetch(SESSION_ENDPOINT, {
22
+ method: 'GET',
23
+ credentials: 'include',
24
+ cache: 'no-store',
25
+ })
26
+
27
+ return parseJson<AelithSessionState>(response)
28
+ }
29
+
30
+ export function AuthDemo({
31
+ initialNotice,
32
+ }: {
33
+ initialNotice: NoticeState | null
34
+ }) {
35
+ const { connected, publicKey, signMessage } = useWallet()
36
+ const [session, setSession] = useState<AelithSessionState | null>(null)
37
+ const [notice, setNotice] = useState<NoticeState | null>(initialNotice)
38
+ const [busy, setBusy] = useState<BusyState>('session')
39
+
40
+ useEffect(() => {
41
+ let cancelled = false
42
+
43
+ void (async () => {
44
+ try {
45
+ const nextSession = await requestSessionState()
46
+ if (cancelled) {
47
+ return
48
+ }
49
+
50
+ setSession(nextSession)
51
+
52
+ if (nextSession.error && !nextSession.authenticated) {
53
+ setNotice({
54
+ tone: 'error',
55
+ message: nextSession.error,
56
+ })
57
+ }
58
+ } catch (error) {
59
+ if (cancelled) {
60
+ return
61
+ }
62
+
63
+ setNotice({
64
+ tone: 'error',
65
+ message: getErrorMessage(error, 'Failed to load the current session.'),
66
+ })
67
+ } finally {
68
+ if (!cancelled) {
69
+ setBusy(null)
70
+ }
71
+ }
72
+ })()
73
+
74
+ return () => {
75
+ cancelled = true
76
+ }
77
+ }, [])
78
+
79
+ async function refreshSession() {
80
+ setBusy('session')
81
+
82
+ try {
83
+ const nextSession = await requestSessionState()
84
+ setSession(nextSession)
85
+
86
+ if (nextSession.error && !nextSession.authenticated) {
87
+ setNotice({
88
+ tone: 'error',
89
+ message: nextSession.error,
90
+ })
91
+ }
92
+ } catch (error) {
93
+ setNotice({
94
+ tone: 'error',
95
+ message: getErrorMessage(error, 'Failed to load the current session.'),
96
+ })
97
+ } finally {
98
+ setBusy(null)
99
+ }
100
+ }
101
+
102
+ function startHostedLogin() {
103
+ setBusy('hosted')
104
+ window.location.assign(
105
+ aelithClient.buildHostedLoginUrl({
106
+ redirectUri: getHostedRedirectUri(),
107
+ })
108
+ )
109
+ }
110
+
111
+ async function handleSdkLogin() {
112
+ if (!publicKey || !signMessage) {
113
+ setNotice({
114
+ tone: 'error',
115
+ message: 'Connect a wallet with message signing before starting the SDK flow.',
116
+ })
117
+ return
118
+ }
119
+
120
+ setBusy('sdk')
121
+ setNotice(null)
122
+
123
+ try {
124
+ const result = await aelithClient.loginWithWallet({
125
+ walletAddress: publicKey.toBase58(),
126
+ signMessage,
127
+ })
128
+
129
+ const response = await fetch(SESSION_ENDPOINT, {
130
+ method: 'POST',
131
+ credentials: 'include',
132
+ headers: {
133
+ 'Content-Type': 'application/json',
134
+ },
135
+ body: JSON.stringify({ token: result.token }),
136
+ })
137
+
138
+ const nextSession = await parseJson<AelithSessionState>(response)
139
+ if (!response.ok || !nextSession.authenticated) {
140
+ throw new Error(nextSession.error ?? 'Failed to create a verified local session.')
141
+ }
142
+
143
+ setSession(nextSession)
144
+ setNotice({
145
+ tone: 'success',
146
+ message: result.isNew
147
+ ? 'SDK login finished and Aelith created a new identity for this wallet.'
148
+ : 'SDK login finished and the local session is now verified.',
149
+ })
150
+ } catch (error) {
151
+ setNotice({
152
+ tone: 'error',
153
+ message: getErrorMessage(error, 'SDK wallet login failed.'),
154
+ })
155
+ } finally {
156
+ setBusy(null)
157
+ }
158
+ }
159
+
160
+ async function handleSignOut() {
161
+ setBusy('signout')
162
+
163
+ try {
164
+ const response = await fetch(SESSION_ENDPOINT, {
165
+ method: 'DELETE',
166
+ credentials: 'include',
167
+ })
168
+
169
+ const nextSession = await parseJson<AelithSessionState>(response)
170
+ setSession(nextSession)
171
+ setNotice({
172
+ tone: 'success',
173
+ message: 'The local demo session cookie has been cleared.',
174
+ })
175
+ } catch (error) {
176
+ setNotice({
177
+ tone: 'error',
178
+ message: getErrorMessage(error, 'Failed to clear the current session.'),
179
+ })
180
+ } finally {
181
+ setBusy(null)
182
+ }
183
+ }
184
+
185
+ const callbackUri = getDefaultHostedRedirectUri()
186
+ const walletAddress = publicKey?.toBase58() ?? 'Wallet not connected'
187
+ const canUseSdk = Boolean(publicKey && signMessage)
188
+
189
+ return (
190
+ <section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
191
+ <div className="surface rounded-[2rem] p-8">
192
+ <div className="flex flex-wrap items-center justify-between gap-4">
193
+ <div>
194
+ <p className="eyebrow">Live auth demo</p>
195
+ <h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-[var(--text-strong)]">
196
+ Compare hosted redirect against local wallet signing.
197
+ </h2>
198
+ </div>
199
+
200
+ <WalletMultiButton className="wallet-button" />
201
+ </div>
202
+
203
+ {notice ? (
204
+ <div
205
+ className={[
206
+ 'notice mt-6',
207
+ notice.tone === 'success' ? 'notice-success' : 'notice-error',
208
+ ].join(' ')}
209
+ >
210
+ {notice.message}
211
+ </div>
212
+ ) : null}
213
+
214
+ <div className="mt-6 grid gap-4 md:grid-cols-2">
215
+ <article className="flow-card">
216
+ <p className="eyebrow">Hosted flow</p>
217
+ <h3 className="mt-3 text-xl font-semibold text-[var(--text-strong)]">
218
+ Redirect to the Aelith login app
219
+ </h3>
220
+ <p className="mt-3 text-sm leading-6 text-[var(--text-soft)]">
221
+ Uses the hosted login screen, returns to the callback route, then stores a
222
+ verified local session cookie.
223
+ </p>
224
+ <p className="mono mt-4 text-xs leading-6 text-[var(--text-soft)]">
225
+ callback: {callbackUri}
226
+ </p>
227
+ <LoginButton
228
+ className="mt-6 w-full"
229
+ onClick={startHostedLogin}
230
+ disabled={busy !== null}
231
+ >
232
+ {busy === 'hosted' ? 'Redirecting...' : 'Start hosted login'}
233
+ </LoginButton>
234
+ </article>
235
+
236
+ <article className="flow-card">
237
+ <p className="eyebrow">SDK flow</p>
238
+ <h3 className="mt-3 text-xl font-semibold text-[var(--text-strong)]">
239
+ Sign the challenge on your own origin
240
+ </h3>
241
+ <p className="mt-3 text-sm leading-6 text-[var(--text-soft)]">
242
+ Connect a wallet here, sign locally, exchange the signature through
243
+ <span className="mono"> @aelith/auth-core</span>, then verify and store the
244
+ session on the server.
245
+ </p>
246
+ <p className="mono mt-4 text-xs leading-6 text-[var(--text-soft)]">
247
+ wallet: {walletAddress}
248
+ </p>
249
+ <LoginButton
250
+ tone="mint"
251
+ className="mt-6 w-full"
252
+ onClick={handleSdkLogin}
253
+ disabled={busy !== null || !canUseSdk}
254
+ >
255
+ {busy === 'sdk' ? 'Signing and verifying...' : 'Run SDK wallet login'}
256
+ </LoginButton>
257
+ </article>
258
+ </div>
259
+
260
+ <div className="mt-6 rounded-[1.5rem] border border-white/10 bg-white/5 px-5 py-4 text-sm leading-6 text-[var(--text-soft)]">
261
+ {connected
262
+ ? 'Wallet connection is live. Use the SDK button when you are ready to sign the Aelith challenge.'
263
+ : 'Use the wallet button above to connect Phantom, Solflare, or Backpack before testing the SDK flow.'}
264
+ </div>
265
+ </div>
266
+
267
+ <aside className="surface rounded-[2rem] p-8">
268
+ <div className="flex items-start justify-between gap-4">
269
+ <div>
270
+ <p className="eyebrow">Verified session</p>
271
+ <h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-[var(--text-strong)]">
272
+ What the app currently knows
273
+ </h2>
274
+ </div>
275
+
276
+ <LoginButton
277
+ tone="ghost"
278
+ className="shrink-0"
279
+ onClick={() => void refreshSession()}
280
+ disabled={busy !== null}
281
+ >
282
+ {busy === 'session' ? 'Refreshing...' : 'Refresh'}
283
+ </LoginButton>
284
+ </div>
285
+
286
+ <div className="mt-6 session-grid text-sm leading-6">
287
+ <dt>Status</dt>
288
+ <dd>{session?.authenticated ? 'Authenticated' : 'Anonymous'}</dd>
289
+
290
+ <dt>Configured</dt>
291
+ <dd>{session?.configured ? 'Ready for verify calls' : 'Missing AELITH_API_KEY'}</dd>
292
+
293
+ <dt>User ID</dt>
294
+ <dd>{session?.userId ?? '-'}</dd>
295
+
296
+ <dt>Wallet</dt>
297
+ <dd>{session?.walletAddress ?? '-'}</dd>
298
+
299
+ <dt>Email</dt>
300
+ <dd>{session?.email ?? '-'}</dd>
301
+
302
+ <dt>App claim</dt>
303
+ <dd>{session?.app ?? '-'}</dd>
304
+
305
+ <dt>Auth origin</dt>
306
+ <dd className="mono text-xs">{session?.authOrigin ?? aelithClient.authOrigin}</dd>
307
+ </div>
308
+
309
+ <div className="mt-6 rounded-[1.5rem] border border-[var(--signal-soft)] bg-[var(--signal-soft)] px-5 py-4 text-sm leading-6 text-[var(--text-soft)]">
310
+ The local session route verifies tokens against{' '}
311
+ <span className="mono">/api/verify</span> using your server-side API key.
312
+ </div>
313
+
314
+ <LoginButton
315
+ tone="ghost"
316
+ className="mt-6 w-full"
317
+ onClick={handleSignOut}
318
+ disabled={busy !== null || !session?.authenticated}
319
+ >
320
+ {busy === 'signout' ? 'Clearing session...' : 'Clear local session'}
321
+ </LoginButton>
322
+ </aside>
323
+ </section>
324
+ )
325
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import type { ButtonHTMLAttributes } from 'react'
4
+
5
+ type LoginButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
6
+ tone?: 'signal' | 'mint' | 'ghost'
7
+ }
8
+
9
+ function getToneClasses(tone: NonNullable<LoginButtonProps['tone']>) {
10
+ switch (tone) {
11
+ case 'mint':
12
+ return 'border-transparent bg-[var(--mint)] text-[#051019] shadow-[0_18px_40px_rgba(115,255,209,0.15)]'
13
+ case 'ghost':
14
+ return 'border-white/10 bg-white/5 text-[var(--text-strong)]'
15
+ case 'signal':
16
+ default:
17
+ return 'border-transparent bg-[var(--signal)] text-[#051019] shadow-[0_18px_40px_rgba(255,147,106,0.16)]'
18
+ }
19
+ }
20
+
21
+ export function LoginButton({
22
+ tone = 'signal',
23
+ className,
24
+ type = 'button',
25
+ ...props
26
+ }: LoginButtonProps) {
27
+ return (
28
+ <button
29
+ type={type}
30
+ className={[
31
+ 'inline-flex min-h-12 items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition-transform duration-200 hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-55',
32
+ getToneClasses(tone),
33
+ className ?? '',
34
+ ].join(' ')}
35
+ {...props}
36
+ />
37
+ )
38
+ }
@@ -0,0 +1,31 @@
1
+ 'use client'
2
+
3
+ import {
4
+ PhantomWalletAdapter,
5
+ SolflareWalletAdapter,
6
+ } from '@solana/wallet-adapter-wallets'
7
+ import {
8
+ ConnectionProvider,
9
+ WalletProvider,
10
+ } from '@solana/wallet-adapter-react'
11
+ import { WalletModalProvider } from '@solana/wallet-adapter-react-ui'
12
+ import type { ReactNode } from 'react'
13
+ import { useState } from 'react'
14
+
15
+ const RPC_ENDPOINT =
16
+ process.env.NEXT_PUBLIC_SOLANA_RPC_URL?.trim() || 'https://api.mainnet-beta.solana.com'
17
+
18
+ export function AelithWalletProvider({ children }: { children: ReactNode }) {
19
+ const [wallets] = useState(() => [
20
+ new PhantomWalletAdapter(),
21
+ new SolflareWalletAdapter(),
22
+ ])
23
+
24
+ return (
25
+ <ConnectionProvider endpoint={RPC_ENDPOINT}>
26
+ <WalletProvider wallets={wallets} autoConnect={false}>
27
+ <WalletModalProvider>{children}</WalletModalProvider>
28
+ </WalletProvider>
29
+ </ConnectionProvider>
30
+ )
31
+ }
@@ -0,0 +1,17 @@
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
+ globalIgnores([
9
+ '.next/**',
10
+ 'out/**',
11
+ 'build/**',
12
+ 'next-env.d.ts',
13
+ 'tsconfig.tsbuildinfo',
14
+ ]),
15
+ ])
16
+
17
+ export default eslintConfig
@@ -0,0 +1,59 @@
1
+ import { AelithAuthClient } from '@aelith/auth-core'
2
+
3
+ export type AelithSessionState = {
4
+ authenticated: boolean
5
+ configured: boolean
6
+ appId: string
7
+ authOrigin: string
8
+ userId?: string
9
+ walletAddress?: string | null
10
+ email?: string | null
11
+ app?: string | null
12
+ error?: string
13
+ }
14
+
15
+ export type NoticeState = {
16
+ tone: 'success' | 'error'
17
+ message: string
18
+ }
19
+
20
+ export const AELITH_APP_ID =
21
+ process.env.NEXT_PUBLIC_AELITH_APP_ID?.trim() || '__APP_ID__'
22
+
23
+ export const AELITH_AUTH_ORIGIN =
24
+ process.env.NEXT_PUBLIC_AELITH_AUTH_ORIGIN?.trim() || 'https://auth.aelith.ai'
25
+
26
+ export const DEFAULT_APP_ORIGIN =
27
+ process.env.NEXT_PUBLIC_APP_URL?.trim() || 'http://localhost:3000'
28
+
29
+ export const HOSTED_CALLBACK_PATH = '/api/auth/aelith/callback'
30
+ export const SESSION_ENDPOINT = '/api/auth/aelith/session'
31
+
32
+ export const aelithClient = new AelithAuthClient({
33
+ appId: AELITH_APP_ID,
34
+ authOrigin: AELITH_AUTH_ORIGIN,
35
+ })
36
+
37
+ export function getDefaultHostedRedirectUri() {
38
+ return `${DEFAULT_APP_ORIGIN}${HOSTED_CALLBACK_PATH}`
39
+ }
40
+
41
+ export function getHostedRedirectUri() {
42
+ if (typeof window === 'undefined') {
43
+ return getDefaultHostedRedirectUri()
44
+ }
45
+
46
+ return `${window.location.origin}${HOSTED_CALLBACK_PATH}`
47
+ }
48
+
49
+ export function getErrorMessage(error: unknown, fallback: string) {
50
+ if (error instanceof Error && error.message.trim()) {
51
+ return error.message
52
+ }
53
+
54
+ return fallback
55
+ }
56
+
57
+ export async function parseJson<T>(response: Response) {
58
+ return (await response.json().catch(() => ({}))) as T
59
+ }
@@ -0,0 +1,94 @@
1
+ import 'server-only'
2
+
3
+ import { AELITH_APP_ID, AELITH_AUTH_ORIGIN, type AelithSessionState } from './aelith'
4
+
5
+ type VerifyResponse = {
6
+ valid?: boolean
7
+ userId?: string
8
+ walletAddress?: string | null
9
+ email?: string | null
10
+ app?: string | null
11
+ error?: string
12
+ }
13
+
14
+ const SESSION_COOKIE_NAME =
15
+ process.env.AELITH_SESSION_COOKIE_NAME?.trim() || '__APP_ID___session'
16
+
17
+ const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
18
+
19
+ export function getSessionCookieName() {
20
+ return SESSION_COOKIE_NAME
21
+ }
22
+
23
+ export function getSessionCookieOptions() {
24
+ return {
25
+ httpOnly: true,
26
+ path: '/',
27
+ sameSite: 'lax' as const,
28
+ secure: process.env.NODE_ENV === 'production',
29
+ maxAge: SESSION_MAX_AGE_SECONDS,
30
+ }
31
+ }
32
+
33
+ export function getClearedSessionCookieOptions() {
34
+ return {
35
+ ...getSessionCookieOptions(),
36
+ maxAge: 0,
37
+ }
38
+ }
39
+
40
+ export function getBaseSessionState(): AelithSessionState {
41
+ return {
42
+ authenticated: false,
43
+ configured: Boolean(process.env.AELITH_API_KEY?.trim()),
44
+ appId: AELITH_APP_ID,
45
+ authOrigin: AELITH_AUTH_ORIGIN,
46
+ }
47
+ }
48
+
49
+ export function getSessionErrorMessage(error: unknown) {
50
+ if (error instanceof Error && error.message.trim()) {
51
+ return error.message
52
+ }
53
+
54
+ return 'Aelith token verification failed.'
55
+ }
56
+
57
+ export async function verifyAelithToken(token: string) {
58
+ const apiKey = process.env.AELITH_API_KEY?.trim()
59
+ if (!apiKey) {
60
+ throw new Error('Set AELITH_API_KEY in .env.local before using the session routes.')
61
+ }
62
+
63
+ const response = await fetch(new URL('/api/verify', AELITH_AUTH_ORIGIN), {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ 'x-api-key': apiKey,
68
+ },
69
+ body: JSON.stringify({ token }),
70
+ cache: 'no-store',
71
+ })
72
+
73
+ const body = (await response.json().catch(() => ({}))) as VerifyResponse
74
+ if (!response.ok || !body.valid || !body.userId) {
75
+ throw new Error(body.error ?? 'Aelith token verification failed.')
76
+ }
77
+
78
+ return body
79
+ }
80
+
81
+ export async function buildAuthenticatedSessionState(token: string): Promise<AelithSessionState> {
82
+ const verified = await verifyAelithToken(token)
83
+
84
+ return {
85
+ authenticated: true,
86
+ configured: true,
87
+ appId: AELITH_APP_ID,
88
+ authOrigin: AELITH_AUTH_ORIGIN,
89
+ userId: verified.userId,
90
+ walletAddress: verified.walletAddress ?? null,
91
+ email: verified.email ?? null,
92
+ app: verified.app ?? null,
93
+ }
94
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,5 @@
1
+ import type { NextConfig } from 'next'
2
+
3
+ const nextConfig: NextConfig = {}
4
+
5
+ export default nextConfig
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "__APP_ID__",
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
+ },
11
+ "dependencies": {
12
+ "@aelith/auth-core": "^0.1.3",
13
+ "@solana/wallet-adapter-base": "latest",
14
+ "@solana/wallet-adapter-react": "latest",
15
+ "@solana/wallet-adapter-react-ui": "latest",
16
+ "@solana/wallet-adapter-wallets": "latest",
17
+ "@solana/web3.js": "^1.98.4",
18
+ "next": "16.2.2",
19
+ "react": "19.2.4",
20
+ "react-dom": "19.2.4"
21
+ },
22
+ "devDependencies": {
23
+ "@tailwindcss/postcss": "^4",
24
+ "@types/node": "^20",
25
+ "@types/react": "^19",
26
+ "@types/react-dom": "^19",
27
+ "eslint": "^9",
28
+ "eslint-config-next": "16.2.2",
29
+ "tailwindcss": "^4",
30
+ "typescript": "^5"
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ }
6
+
7
+ export default config
@@ -0,0 +1,33 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts"
31
+ ],
32
+ "exclude": ["node_modules"]
33
+ }