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 +32 -0
- package/bin/index.js +171 -0
- package/package.json +21 -0
- package/template/.env.example +6 -0
- package/template/.gitignore +8 -0
- package/template/README.md +49 -0
- package/template/app/api/auth/aelith/callback/route.ts +36 -0
- package/template/app/api/auth/aelith/session/route.ts +91 -0
- package/template/app/globals.css +170 -0
- package/template/app/layout.tsx +21 -0
- package/template/app/page.tsx +81 -0
- package/template/components/auth-demo.tsx +325 -0
- package/template/components/login-button.tsx +38 -0
- package/template/components/wallet-provider.tsx +31 -0
- package/template/eslint.config.mjs +17 -0
- package/template/lib/aelith.ts +59 -0
- package/template/lib/session.ts +94 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.ts +5 -0
- package/template/package.json +32 -0
- package/template/postcss.config.mjs +7 -0
- package/template/tsconfig.json +33 -0
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,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,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,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
|
+
}
|