spfn 0.2.0-beta.9 → 0.3.0-beta.2
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/LICENSE +1 -1
- package/README.md +626 -204
- package/bin/spfn.js +53 -4
- package/dist/index.js +4183 -2281
- package/dist/templates/.dockerignore +1 -0
- package/dist/templates/Dockerfile +1 -1
- package/dist/templates/README.md +94 -0
- package/dist/templates/lib/api-client.ts +1 -1
- package/dist/templates/modes/full/src/app/auth/callback/page.tsx +1 -0
- package/dist/templates/modes/full/src/app/login/page.tsx +68 -0
- package/dist/templates/modes/full/src/i18n/catalogs.ts +16 -0
- package/dist/templates/modes/full/src/i18n/server.ts +9 -0
- package/dist/templates/modes/full/src/server/config/env.config.ts +23 -0
- package/dist/templates/modes/full/src/server/mcp.ts +75 -0
- package/dist/templates/modes/full/src/server/router.ts +32 -0
- package/dist/templates/modes/full/src/server/routes/examples.ts +94 -0
- package/dist/templates/modes/full/src/server/routes/health.ts +9 -0
- package/dist/templates/modes/full/src/server/routes/root.ts +15 -0
- package/dist/templates/modes/full/src/server/server.config.ts +14 -0
- package/dist/templates/server/config/env.config.ts +10 -75
- package/dist/templates/server/entities/config.ts +1 -1
- package/dist/templates/server/entities/example.entity.ts +1 -1
- package/dist/templates/server/repositories/example.repository.ts +1 -1
- package/dist/templates/server/router.ts +2 -3
- package/dist/templates/server/routes/examples.ts +2 -12
- package/dist/templates/server/routes/health.ts +1 -1
- package/dist/templates/server/routes/root.ts +1 -2
- package/dist/templates/server/server.config.ts +2 -2
- package/dist/templates/vercel/npmrc +4 -0
- package/dist/templates/vercel/route.ts +53 -0
- package/dist/templates/vercel/vercel.json +6 -0
- package/package.json +35 -23
- package/dist/commands/generate/templates/contract.template +0 -58
- package/dist/commands/generate/templates/entity.template +0 -28
- package/dist/commands/generate/templates/init-migration.template +0 -3
- package/dist/commands/generate/templates/repository.template +0 -56
- package/dist/commands/generate/templates/route.template +0 -65
- package/dist/commands/generate/templates/schema.template +0 -13
- package/dist/templates/Dockerfile.optimized +0 -67
- package/dist/templates/config/index.ts +0 -22
- package/dist/templates/config/schema.ts +0 -42
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
A full-stack app built with [SPFN](https://github.com/spfn/spfn) — a consistent
|
|
4
|
+
architecture for taking an AI-built prototype to production. Scaffold mode: `{{mode}}`.
|
|
5
|
+
|
|
6
|
+
## Getting started
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# 1. Start PostgreSQL + Redis
|
|
10
|
+
docker compose up -d
|
|
11
|
+
|
|
12
|
+
# 2. The env files are generated for you:
|
|
13
|
+
# .env.local — values loaded by Next.js, including auth session crypto (gitignored)
|
|
14
|
+
# .env.server — server secrets: DB, cache (gitignored, never loaded by Next.js)
|
|
15
|
+
# Review them and adjust as needed.
|
|
16
|
+
|
|
17
|
+
# 3. Start the dev server (Next.js :3790 + SPFN API :8790)
|
|
18
|
+
{{pmRun}} spfn:dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then open:
|
|
22
|
+
|
|
23
|
+
- Next.js — http://localhost:3790
|
|
24
|
+
- API health — http://localhost:8790/health
|
|
25
|
+
|
|
26
|
+
## Project structure
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
src/
|
|
30
|
+
app/ Next.js App Router
|
|
31
|
+
server/ SPFN backend (entities, repositories, routes, router)
|
|
32
|
+
generated/ codegen output (route map) — do not edit by hand
|
|
33
|
+
.env.local Next.js runtime env (gitignored)
|
|
34
|
+
.env.server server secrets (gitignored)
|
|
35
|
+
.env.example committed reference — keys only, placeholder values
|
|
36
|
+
.spfnrc.ts codegen configuration
|
|
37
|
+
spfn.config.js deployment config
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A feature is a vertical slice: `Entity` (Drizzle table) → `Repository` →
|
|
41
|
+
`Route` (TypeBox validation) → `Router` → `{{pmRun}} codegen` → typed client.
|
|
42
|
+
|
|
43
|
+
## Common commands
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
{{pmRun}} spfn:dev # dev: Next.js + SPFN API (add --watch to restart on changes)
|
|
47
|
+
{{pmRun}} spfn:build # production build
|
|
48
|
+
{{pmRun}} spfn:start # run the production build
|
|
49
|
+
{{pmExec}} spfn db generate # create a migration from schema changes
|
|
50
|
+
{{pmExec}} spfn db migrate # apply pending migrations
|
|
51
|
+
{{pmExec}} spfn env check # check .env files against the schema
|
|
52
|
+
{{pmExec}} spfn secret set DB_URL # store a secret (keychain locally, SOPS for deploys)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Environment & secrets
|
|
56
|
+
|
|
57
|
+
`.env.server` holds backend-only secrets and is gitignored — Next.js never loads it.
|
|
58
|
+
For a managed workflow use `spfn secret`: local values go to the OS keychain (only a
|
|
59
|
+
`secret:keychain:` reference lands in `.env.server`), and deployed secrets are stored
|
|
60
|
+
in encrypted SOPS files. See the [SPFN CLI docs](https://github.com/spfn/spfn).
|
|
61
|
+
|
|
62
|
+
<!-- {{#auth}} -->
|
|
63
|
+
## Authentication
|
|
64
|
+
|
|
65
|
+
This project includes `@spfn/auth`. Configure providers and session settings in your
|
|
66
|
+
generated env files, then run `{{pmExec}} spfn db migrate`. The lifecycle, router,
|
|
67
|
+
Next.js interceptor, `/login` starter UI, OAuth callback, and route map are already wired.
|
|
68
|
+
|
|
69
|
+
## Internationalization
|
|
70
|
+
|
|
71
|
+
Edit `src/i18n/catalogs.ts` to add application-owned messages. Server components
|
|
72
|
+
and handlers can import `getT` or `getClientMessages` from `@/i18n/server`.
|
|
73
|
+
|
|
74
|
+
## Agent operations with MCP
|
|
75
|
+
|
|
76
|
+
The SPFN API serves MCP at `http://localhost:8790/mcp`. Connect with the Bearer
|
|
77
|
+
token stored as `SPFN_MCP_API_KEY` in `.env.server`, then replace the starter
|
|
78
|
+
`app_status` tool in `src/server/mcp.ts` with operations from your domain layer.
|
|
79
|
+
Before third-party access, replace the generated operator-key validator with your
|
|
80
|
+
OAuth access-token validator and scope each tool to the resolved operator.
|
|
81
|
+
<!-- {{/auth}} -->
|
|
82
|
+
|
|
83
|
+
## Deployment
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Build + run locally
|
|
87
|
+
{{pmRun}} spfn:build && {{pmRun}} spfn:start
|
|
88
|
+
|
|
89
|
+
# Or with Docker
|
|
90
|
+
docker compose -f docker-compose.production.yml up --build -d
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Never commit real secrets. `.env.server` is gitignored; inject secrets in your
|
|
94
|
+
CI/CD pipeline or via `spfn secret` + your GitOps decryption step.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { authApi } from '@spfn/auth';
|
|
5
|
+
import { I18nProvider, useT } from '@spfn/i18n/client';
|
|
6
|
+
import { catalogs } from '@/i18n/catalogs';
|
|
7
|
+
|
|
8
|
+
const providers = ['google', 'github', 'kakao', 'naver'] as const;
|
|
9
|
+
|
|
10
|
+
export default function LoginPage()
|
|
11
|
+
{
|
|
12
|
+
return (
|
|
13
|
+
<I18nProvider locale="en" messages={catalogs.en ?? {}}>
|
|
14
|
+
<LoginContent />
|
|
15
|
+
</I18nProvider>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function LoginContent()
|
|
20
|
+
{
|
|
21
|
+
const [error, setError] = useState<string>();
|
|
22
|
+
const [pending, setPending] = useState<string>();
|
|
23
|
+
const t = useT('common');
|
|
24
|
+
|
|
25
|
+
async function signIn(provider: typeof providers[number]): Promise<void>
|
|
26
|
+
{
|
|
27
|
+
setError(undefined);
|
|
28
|
+
setPending(provider);
|
|
29
|
+
|
|
30
|
+
try
|
|
31
|
+
{
|
|
32
|
+
const { authUrl } = provider === 'google'
|
|
33
|
+
? await authApi.getGoogleOAuthUrl.call({ body: { returnUrl: '/' } })
|
|
34
|
+
: await authApi.getProviderOAuthUrl.call({
|
|
35
|
+
params: { provider },
|
|
36
|
+
body: { returnUrl: '/' },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
window.location.assign(authUrl);
|
|
40
|
+
}
|
|
41
|
+
catch (cause)
|
|
42
|
+
{
|
|
43
|
+
setPending(undefined);
|
|
44
|
+
setError(cause instanceof Error ? cause.message : 'Could not start sign in');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<main style={{ maxWidth: 420, margin: '10vh auto', padding: 24 }}>
|
|
50
|
+
<h1>{t('appName')} — Sign in</h1>
|
|
51
|
+
<p>Connect a provider key in <code>.env.server</code>, then continue.</p>
|
|
52
|
+
<div style={{ display: 'grid', gap: 12, marginTop: 24 }}>
|
|
53
|
+
{providers.map(provider => (
|
|
54
|
+
<button
|
|
55
|
+
key={provider}
|
|
56
|
+
type="button"
|
|
57
|
+
disabled={pending !== undefined}
|
|
58
|
+
onClick={() => void signIn(provider)}
|
|
59
|
+
style={{ padding: 12, textTransform: 'capitalize' }}
|
|
60
|
+
>
|
|
61
|
+
{pending === provider ? 'Connecting…' : `Continue with ${provider}`}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
66
|
+
</main>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LocaleCatalogs } from '@spfn/i18n';
|
|
2
|
+
|
|
3
|
+
export const catalogs: LocaleCatalogs = {
|
|
4
|
+
en: {
|
|
5
|
+
common: {
|
|
6
|
+
appName: 'My SPFN app',
|
|
7
|
+
welcome: 'Welcome, {name}',
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
ko: {
|
|
11
|
+
common: {
|
|
12
|
+
appName: '나의 SPFN 앱',
|
|
13
|
+
welcome: '환영합니다, {name}',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEnvRegistry,
|
|
3
|
+
defineEnvSchema,
|
|
4
|
+
envSecret,
|
|
5
|
+
envUrl,
|
|
6
|
+
} from '@spfn/core/env';
|
|
7
|
+
|
|
8
|
+
export const envSchema = defineEnvSchema({
|
|
9
|
+
SPFN_MCP_URL: envUrl({
|
|
10
|
+
description: 'Public base URL of the SPFN server that exposes /mcp',
|
|
11
|
+
default: 'http://localhost:8790',
|
|
12
|
+
required: false,
|
|
13
|
+
}),
|
|
14
|
+
SPFN_MCP_API_KEY: envSecret({
|
|
15
|
+
description: 'First-party Bearer token for the generated MCP operator endpoint',
|
|
16
|
+
required: true,
|
|
17
|
+
generate: 'base64url32',
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const env = createEnvRegistry(envSchema).validate();
|
|
22
|
+
|
|
23
|
+
export default env;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MCP endpoint for operating this application with an agent.
|
|
3
|
+
*
|
|
4
|
+
* The generated operator key makes the endpoint usable immediately. Replace
|
|
5
|
+
* `validateToken` with your OAuth access-token validator before granting access
|
|
6
|
+
* to third-party clients, and expose only tools the resolved operator may use.
|
|
7
|
+
*/
|
|
8
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import type { McpAuth, McpTool } from '@spfn/mcp';
|
|
10
|
+
import { createMcpRoute } from '@spfn/mcp/server';
|
|
11
|
+
import env from './config/env.config';
|
|
12
|
+
|
|
13
|
+
type OperatorAuth = McpAuth & {
|
|
14
|
+
operatorId: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type OperatorContext = {
|
|
18
|
+
operatorId: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const tools: McpTool<OperatorContext>[] = [
|
|
22
|
+
{
|
|
23
|
+
name: 'app_status',
|
|
24
|
+
title: 'Application status',
|
|
25
|
+
description: 'Check that the deployed application MCP endpoint is available.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {},
|
|
29
|
+
},
|
|
30
|
+
annotations: {
|
|
31
|
+
readOnlyHint: true,
|
|
32
|
+
destructiveHint: false,
|
|
33
|
+
},
|
|
34
|
+
handler: async (_args, context) => ({
|
|
35
|
+
status: 'ok',
|
|
36
|
+
operatorId: context.operatorId,
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const mcpRouter = createMcpRoute<OperatorAuth, OperatorContext>({
|
|
42
|
+
appUrl: env.SPFN_MCP_URL,
|
|
43
|
+
serverInfo: {
|
|
44
|
+
name: 'spfn-app',
|
|
45
|
+
version: '1.0.0',
|
|
46
|
+
description: 'Operate this SPFN application with an agent.',
|
|
47
|
+
},
|
|
48
|
+
validateToken: async (token) =>
|
|
49
|
+
{
|
|
50
|
+
// The registry validates this required secret at startup. Its public
|
|
51
|
+
// proxy type remains optional so schemas can also describe values that
|
|
52
|
+
// are not required in every environment.
|
|
53
|
+
if (!secretsMatch(token, env.SPFN_MCP_API_KEY!))
|
|
54
|
+
{
|
|
55
|
+
throw new Error('Invalid MCP access token');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
clientId: 'operator-agent',
|
|
60
|
+
operatorId: 'operator',
|
|
61
|
+
scopes: ['operate'],
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
resolveContext: async auth => ({ operatorId: auth.operatorId }),
|
|
65
|
+
listTools: () => tools,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function secretsMatch(actual: string, expected: string): boolean
|
|
69
|
+
{
|
|
70
|
+
const actualBytes = Buffer.from(actual);
|
|
71
|
+
const expectedBytes = Buffer.from(expected);
|
|
72
|
+
|
|
73
|
+
return actualBytes.length === expectedBytes.length
|
|
74
|
+
&& timingSafeEqual(actualBytes, expectedBytes);
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prototype-to-Production application router.
|
|
3
|
+
*
|
|
4
|
+
* Auth protects application routes by default. Public routes opt out with
|
|
5
|
+
* `.skip(['auth'])`; the MCP router owns its Bearer-token boundary.
|
|
6
|
+
*/
|
|
7
|
+
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
8
|
+
import { defineRouter } from '@spfn/core/route';
|
|
9
|
+
import { mcpRouter } from './mcp';
|
|
10
|
+
import { getRoot } from './routes/root';
|
|
11
|
+
import { getHealth } from './routes/health';
|
|
12
|
+
import {
|
|
13
|
+
listExamples,
|
|
14
|
+
getExample,
|
|
15
|
+
createExample,
|
|
16
|
+
updateExample,
|
|
17
|
+
deleteExample,
|
|
18
|
+
} from './routes/examples';
|
|
19
|
+
|
|
20
|
+
export const appRouter = defineRouter({
|
|
21
|
+
getRoot,
|
|
22
|
+
getHealth,
|
|
23
|
+
listExamples,
|
|
24
|
+
getExample,
|
|
25
|
+
createExample,
|
|
26
|
+
updateExample,
|
|
27
|
+
deleteExample,
|
|
28
|
+
})
|
|
29
|
+
.packages([authRouter, mcpRouter])
|
|
30
|
+
.use([authenticate]);
|
|
31
|
+
|
|
32
|
+
export type AppRouter = typeof appRouter;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { route } from '@spfn/core/route';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { ExampleRepository } from '../repositories/example.repository';
|
|
4
|
+
|
|
5
|
+
const exampleRepo = new ExampleRepository();
|
|
6
|
+
|
|
7
|
+
export const listExamples = route.get('/examples')
|
|
8
|
+
.input({
|
|
9
|
+
query: Type.Object({
|
|
10
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })),
|
|
11
|
+
offset: Type.Optional(Type.Number({ minimum: 0 })),
|
|
12
|
+
}),
|
|
13
|
+
})
|
|
14
|
+
.skip(['auth'])
|
|
15
|
+
.handler(async (c) =>
|
|
16
|
+
{
|
|
17
|
+
const { query } = await c.data();
|
|
18
|
+
const limit = query.limit ?? 10;
|
|
19
|
+
const offset = query.offset ?? 0;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
examples: await exampleRepo.findAll(limit, offset),
|
|
23
|
+
total: await exampleRepo.countAll(),
|
|
24
|
+
limit,
|
|
25
|
+
offset,
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const getExample = route.get('/examples/:id')
|
|
30
|
+
.input({
|
|
31
|
+
params: Type.Object({ id: Type.String() }),
|
|
32
|
+
})
|
|
33
|
+
.skip(['auth'])
|
|
34
|
+
.handler(async (c) =>
|
|
35
|
+
{
|
|
36
|
+
const { params } = await c.data();
|
|
37
|
+
const example = await exampleRepo.findById(params.id);
|
|
38
|
+
if (!example)
|
|
39
|
+
{
|
|
40
|
+
throw new Error('Example not found');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return example;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const createExample = route.post('/examples')
|
|
47
|
+
.input({
|
|
48
|
+
body: Type.Object({
|
|
49
|
+
name: Type.String(),
|
|
50
|
+
description: Type.String(),
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
.handler(async (c) =>
|
|
54
|
+
{
|
|
55
|
+
const { body } = await c.data();
|
|
56
|
+
|
|
57
|
+
return exampleRepo.createExample(body);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const updateExample = route.put('/examples/:id')
|
|
61
|
+
.input({
|
|
62
|
+
params: Type.Object({ id: Type.String() }),
|
|
63
|
+
body: Type.Object({
|
|
64
|
+
name: Type.Optional(Type.String()),
|
|
65
|
+
description: Type.Optional(Type.String()),
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
.handler(async (c) =>
|
|
69
|
+
{
|
|
70
|
+
const { params, body } = await c.data();
|
|
71
|
+
const example = await exampleRepo.updateExample(params.id, body);
|
|
72
|
+
if (!example)
|
|
73
|
+
{
|
|
74
|
+
throw new Error('Example not found');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return example;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const deleteExample = route.delete('/examples/:id')
|
|
81
|
+
.input({
|
|
82
|
+
params: Type.Object({ id: Type.String() }),
|
|
83
|
+
})
|
|
84
|
+
.handler(async (c) =>
|
|
85
|
+
{
|
|
86
|
+
const { params } = await c.data();
|
|
87
|
+
const example = await exampleRepo.deleteExample(params.id);
|
|
88
|
+
if (!example)
|
|
89
|
+
{
|
|
90
|
+
throw new Error('Example not found');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { success: true, id: params.id };
|
|
94
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { route } from '@spfn/core/route';
|
|
2
|
+
|
|
3
|
+
export const getRoot = route.get('/')
|
|
4
|
+
.skip(['auth'])
|
|
5
|
+
.handler(async () => ({
|
|
6
|
+
name: 'SPFN API',
|
|
7
|
+
version: '1.0.0',
|
|
8
|
+
status: 'running',
|
|
9
|
+
endpoints: {
|
|
10
|
+
health: '/health',
|
|
11
|
+
examples: '/examples',
|
|
12
|
+
auth: '/_auth',
|
|
13
|
+
mcp: '/mcp',
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prototype-to-Production server configuration.
|
|
3
|
+
*/
|
|
4
|
+
import '@/i18n/server';
|
|
5
|
+
import { createAuthLifecycle } from '@spfn/auth/server';
|
|
6
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
7
|
+
import { appRouter } from '@/server/router';
|
|
8
|
+
|
|
9
|
+
export default defineServerConfig()
|
|
10
|
+
.port(8790)
|
|
11
|
+
.host('0.0.0.0')
|
|
12
|
+
.routes(appRouter)
|
|
13
|
+
.lifecycle(createAuthLifecycle())
|
|
14
|
+
.build();
|
|
@@ -36,88 +36,23 @@ export const envSchema = defineEnvSchema({
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Type-safe Environment
|
|
39
|
+
* Type-safe Environment Variables
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* Variables are lazily validated on access — if a required variable is
|
|
42
|
+
* missing or invalid, an error is thrown at the first access point.
|
|
43
|
+
* This is timing-safe with dotenv loading.
|
|
43
44
|
*
|
|
44
45
|
* @example
|
|
45
46
|
* ```typescript
|
|
46
47
|
* import env from '@/server/config/env.config';
|
|
47
48
|
*
|
|
48
|
-
*
|
|
49
|
-
* const
|
|
50
|
-
*
|
|
51
|
-
* // Get optional variable (returns undefined if missing)
|
|
52
|
-
* const redisUrl = env.get('REDIS_URL');
|
|
53
|
-
*
|
|
54
|
-
* // Get with default value
|
|
55
|
-
* const port = env.get('PORT') ?? 8790;
|
|
49
|
+
* const dbUrl = env.DATABASE_URL; // throws if required and missing
|
|
50
|
+
* const port = env.PORT ?? 8790; // optional with fallback
|
|
56
51
|
* ```
|
|
57
|
-
*/
|
|
58
|
-
export const env = createEnvRegistry(envSchema);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Validate Environment Variables
|
|
62
52
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* or if there are configuration errors.
|
|
53
|
+
* For explicit upfront validation (e.g. in a CLI script), call
|
|
54
|
+
* `createEnvRegistry(envSchema).validateAll()` to get `{ errors, warnings }`.
|
|
66
55
|
*/
|
|
67
|
-
|
|
68
|
-
{
|
|
69
|
-
const validation = env.validate();
|
|
70
|
-
|
|
71
|
-
// Critical errors (missing required variables)
|
|
72
|
-
if (!validation.valid)
|
|
73
|
-
{
|
|
74
|
-
console.error('\n❌ Environment Validation Failed\n');
|
|
75
|
-
console.error('The following required environment variables are missing or invalid:\n');
|
|
76
|
-
|
|
77
|
-
validation.errors.forEach((error) =>
|
|
78
|
-
{
|
|
79
|
-
console.error(` • ${error.key}`);
|
|
80
|
-
console.error(` ${error.message}`);
|
|
81
|
-
|
|
82
|
-
if (error.suggestion)
|
|
83
|
-
{
|
|
84
|
-
console.error(` 💡 ${error.suggestion}`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
console.error('');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
console.error('Please check your .env files and ensure all required variables are set.');
|
|
91
|
-
console.error('See .env.example for reference.\n');
|
|
92
|
-
|
|
93
|
-
process.exit(1);
|
|
94
|
-
}
|
|
56
|
+
export const env = createEnvRegistry(envSchema).validate();
|
|
95
57
|
|
|
96
|
-
|
|
97
|
-
if (validation.warnings.length > 0)
|
|
98
|
-
{
|
|
99
|
-
console.warn('\n⚠️ Environment Warnings:\n');
|
|
100
|
-
|
|
101
|
-
validation.warnings.forEach((warning) =>
|
|
102
|
-
{
|
|
103
|
-
console.warn(` • ${warning.key}: ${warning.message}`);
|
|
104
|
-
|
|
105
|
-
if (warning.suggestion)
|
|
106
|
-
{
|
|
107
|
-
console.warn(` 💡 ${warning.suggestion}`);
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
console.warn('');
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Export the registry as default
|
|
117
|
-
*
|
|
118
|
-
* Import this in your application to access environment variables:
|
|
119
|
-
* ```typescript
|
|
120
|
-
* import env from '@/server/config/env.config';
|
|
121
|
-
* ```
|
|
122
|
-
*/
|
|
123
|
-
export default env;
|
|
58
|
+
export default env;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { examples } from './example.entity';
|
|
1
|
+
export { examples } from './example.entity';
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
/**
|
|
3
2
|
* Application Router
|
|
4
3
|
*
|
|
@@ -20,10 +19,10 @@ export const appRouter = defineRouter({
|
|
|
20
19
|
getExample,
|
|
21
20
|
createExample,
|
|
22
21
|
updateExample,
|
|
23
|
-
deleteExample
|
|
22
|
+
deleteExample,
|
|
24
23
|
});
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
26
|
* Router type for client usage
|
|
28
27
|
*/
|
|
29
|
-
export type AppRouter = typeof appRouter;
|
|
28
|
+
export type AppRouter = typeof appRouter;
|
|
@@ -39,26 +39,16 @@ export const listExamples = route.get('/examples')
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* GET /examples/:id - Get single example
|
|
42
|
-
*
|
|
43
|
-
* This route requires authorization header for testing header validation
|
|
44
42
|
*/
|
|
45
43
|
export const getExample = route.get('/examples/:id')
|
|
46
44
|
.input({
|
|
47
|
-
headers: Type.Object({
|
|
48
|
-
test: Type.String({
|
|
49
|
-
description: 'Bearer token for authentication'
|
|
50
|
-
})
|
|
51
|
-
}),
|
|
52
45
|
params: Type.Object({
|
|
53
46
|
id: Type.String(),
|
|
54
47
|
}),
|
|
55
48
|
})
|
|
56
49
|
.handler(async (c) =>
|
|
57
50
|
{
|
|
58
|
-
const { params
|
|
59
|
-
|
|
60
|
-
// Log authorization header for testing
|
|
61
|
-
console.log('Authorization:', headers.test);
|
|
51
|
+
const { params } = await c.data();
|
|
62
52
|
|
|
63
53
|
const example = await exampleRepo.findById(params.id);
|
|
64
54
|
|
|
@@ -137,4 +127,4 @@ export const deleteExample = route.delete('/examples/:id')
|
|
|
137
127
|
success: true,
|
|
138
128
|
id: params.id,
|
|
139
129
|
};
|
|
140
|
-
});
|
|
130
|
+
});
|