@oaklandzoo/ostup 0.11.0 → 0.12.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 +12 -0
- package/bin/cli.mjs +12 -0
- package/package.json +1 -1
- package/scripts/verify-auth.sh +30 -0
- package/src/auth-clerk.mjs +146 -0
- package/src/auth-google.mjs +137 -0
- package/src/credential-prompts-npm.mjs +154 -0
- package/src/credential-prompts.mjs +5 -0
- package/src/mvp-flow.mjs +17 -0
- package/templates/.claude/commands/add-auth.md +87 -0
- package/templates/.claude/commands/publish.md +77 -0
- package/templates/auth-clerk/env.example.additions +10 -0
- package/templates/auth-clerk/layout.tsx +25 -0
- package/templates/auth-clerk/middleware.ts +20 -0
- package/templates/auth-clerk/package.json.additions +5 -0
- package/templates/auth-clerk/sign-in-page.tsx +9 -0
- package/templates/auth-clerk/sign-up-page.tsx +9 -0
- package/templates/auth-google/auth.ts +12 -0
- package/templates/auth-google/env.example.additions +9 -0
- package/templates/auth-google/package.json.additions +5 -0
- package/templates/auth-google/route.ts +2 -0
- package/templates/auth-google/sign-in-page.tsx +32 -0
- package/templates/auth-google/sign-up-page.tsx +32 -0
- package/templates/private/middleware.ts +8 -1
package/README.md
CHANGED
|
@@ -32,6 +32,18 @@ When you run this tool, it will:
|
|
|
32
32
|
verify. Toggle on/off an existing project with `ostup private add` /
|
|
33
33
|
`ostup private remove` (manifest at `.ostup/private.json` makes the
|
|
34
34
|
remove cleanly revertable).
|
|
35
|
+
9. Optionally pass `--auth=clerk` or `--auth=google` to wire **a
|
|
36
|
+
working login flow** into the scaffold. Clerk uses `@clerk/nextjs`
|
|
37
|
+
(hosted UI, free up to 10k MAU); Google uses NextAuth v5 with the
|
|
38
|
+
Google provider (free; you create the OAuth app on Google Cloud).
|
|
39
|
+
Both ship sign-in / sign-up pages out of the box. `--auth` composes
|
|
40
|
+
cleanly with `--private` (the default-deny middleware recognizes
|
|
41
|
+
both providers' session cookies).
|
|
42
|
+
10. Optionally pass `--publish-ready` if this CLI will be used to
|
|
43
|
+
publish an npm package later. ostup walks you through getting an
|
|
44
|
+
npm Bypass-2FA token (browser flow or guided token paste) and
|
|
45
|
+
saves it to `~/.npmrc`. After that, the `/publish` slash command
|
|
46
|
+
lets your agent ship a new version with one command.
|
|
35
47
|
|
|
36
48
|
## Quick Start
|
|
37
49
|
|
package/bin/cli.mjs
CHANGED
|
@@ -45,6 +45,9 @@ function parseArgs(argv) {
|
|
|
45
45
|
else if (a === '--output') flags.output = argv[++i];
|
|
46
46
|
else if (a.startsWith('--output=')) flags.output = a.slice('--output='.length);
|
|
47
47
|
else if (a === '--white-label') flags.whiteLabel = true;
|
|
48
|
+
else if (a === '--auth') flags.auth = argv[++i];
|
|
49
|
+
else if (a.startsWith('--auth=')) flags.auth = a.slice('--auth='.length);
|
|
50
|
+
else if (a === '--publish-ready') flags.publishReady = true;
|
|
48
51
|
else if (a === '--private') flags.private = true;
|
|
49
52
|
else if (a === '--privacy') flags.privacy = true;
|
|
50
53
|
else if (a === '--url') flags.url = argv[++i];
|
|
@@ -96,6 +99,8 @@ function printHelp() {
|
|
|
96
99
|
' --brief <path> Load a brief.json from <path> and write brief files + apply profile overlay.',
|
|
97
100
|
' --white-label Strip OSTUP / Goodshin attribution from generated docs (Studio tier).',
|
|
98
101
|
' --private App-level default-deny: middleware, blob proxy, image-optimizer lock, audit + rate-limit, CLAUDE.md Part 20. Refused with --profile saas-dashboard.',
|
|
102
|
+
' --auth <provider> Wire authentication. Values: clerk (Clerk + @clerk/nextjs), google (NextAuth v5 + Google), none. Refused with --profile saas-dashboard.',
|
|
103
|
+
' --publish-ready Walk through npm token setup so this CLI can publish later (writes ~/.npmrc).',
|
|
99
104
|
' --kit-only Drop the markdown kit into a target dir, no GitHub or Vercel.',
|
|
100
105
|
' --config <path> Read .ostup-config.yml from this path (kit-only mode).',
|
|
101
106
|
' --skip-bootstrap Skip the in-CLI tool detection / install step (advanced).',
|
|
@@ -283,6 +288,13 @@ try {
|
|
|
283
288
|
'INGEST_PATH_NOT_FOUND',
|
|
284
289
|
'BRIEF_NOT_FOUND',
|
|
285
290
|
'BRIEF_INVALID',
|
|
291
|
+
'AUTH_ALREADY_APPLIED',
|
|
292
|
+
'AUTH_PROFILE_CONFLICT',
|
|
293
|
+
'AUTH_DIRTY',
|
|
294
|
+
'AUTH_UNKNOWN_PROVIDER',
|
|
295
|
+
'PRIVATE_SAAS_CONFLICT',
|
|
296
|
+
'PRIVATE_ALREADY_APPLIED',
|
|
297
|
+
'PRIVATE_DIRTY',
|
|
286
298
|
'NO_TTY_BOOTSTRAP',
|
|
287
299
|
'BOOTSTRAP_DECLINED',
|
|
288
300
|
'BOOTSTRAP_FAILED',
|
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# verify-auth.sh: apply an auth overlay to a fresh temp dir and assert files + manifest land.
|
|
3
|
+
#
|
|
4
|
+
# Usage: bash scripts/verify-auth.sh <clerk|google>
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
PROVIDER="${1:-}"
|
|
9
|
+
case "$PROVIDER" in
|
|
10
|
+
clerk|google) ;;
|
|
11
|
+
*)
|
|
12
|
+
echo "usage: $0 <clerk|google>" >&2
|
|
13
|
+
exit 2
|
|
14
|
+
;;
|
|
15
|
+
esac
|
|
16
|
+
|
|
17
|
+
OSTUP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
18
|
+
TEST_FILE="$OSTUP_ROOT/test/auth-$PROVIDER.test.mjs"
|
|
19
|
+
|
|
20
|
+
echo ">> verifying auth provider: $PROVIDER"
|
|
21
|
+
echo ">> running: node --test $TEST_FILE"
|
|
22
|
+
cd "$OSTUP_ROOT"
|
|
23
|
+
node --test "$TEST_FILE"
|
|
24
|
+
|
|
25
|
+
echo ">> ok: $PROVIDER overlay applies and asserts pass"
|
|
26
|
+
echo ""
|
|
27
|
+
echo ">> for live verification (operator only):"
|
|
28
|
+
echo " 1. ostup init --yes --name verify-auth --brief test/fixtures/brief-leadgen.json --auth=$PROVIDER"
|
|
29
|
+
echo " 2. cd verify-auth && npm install && npx next build"
|
|
30
|
+
echo " 3. open the deployed URL, sign in, verify session lands on /dashboard"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// auth-clerk.mjs: applyAuthClerk post-processor. Drops Clerk integration into a scaffold.
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates', 'auth-clerk');
|
|
10
|
+
|
|
11
|
+
export const AUTH_MANIFEST_PATH = '.ostup/auth.json';
|
|
12
|
+
|
|
13
|
+
class AuthError extends Error {
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function plannedOps(targetDir) {
|
|
21
|
+
return [
|
|
22
|
+
{ kind: 'create-or-overwrite', dest: 'middleware.ts', src: 'middleware.ts' },
|
|
23
|
+
{ kind: 'create-or-overwrite', dest: 'app/layout.tsx', src: 'layout.tsx' },
|
|
24
|
+
{ kind: 'create', dest: 'app/sign-in/[[...sign-in]]/page.tsx', src: 'sign-in-page.tsx' },
|
|
25
|
+
{ kind: 'create', dest: 'app/sign-up/[[...sign-up]]/page.tsx', src: 'sign-up-page.tsx' },
|
|
26
|
+
{ kind: 'package-merge', dest: 'package.json', src: 'package.json.additions' },
|
|
27
|
+
{ kind: 'env-append', dest: '.env.example', src: 'env.example.additions' },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function loadTemplate(rel) {
|
|
32
|
+
return readFile(join(TEMPLATES_ROOT, rel), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tokenSubstitute(body, tokens) {
|
|
36
|
+
if (!tokens) return body;
|
|
37
|
+
return body.replace(/\{\{([A-Z_][A-Z_0-9]*)\}\}/g, (m, k) => (tokens[k] != null ? String(tokens[k]) : 'TBD'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mergePackageJson(existing, additionsText) {
|
|
41
|
+
const additions = JSON.parse(additionsText);
|
|
42
|
+
const target = existing ? JSON.parse(existing) : {};
|
|
43
|
+
for (const section of ['dependencies', 'devDependencies', 'scripts']) {
|
|
44
|
+
if (additions[section]) {
|
|
45
|
+
target[section] = { ...(target[section] || {}), ...additions[section] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return JSON.stringify(target, null, 2) + '\n';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function applyAuthClerk({ targetDir, profile = null, tokens = {}, force = false } = {}) {
|
|
52
|
+
const manifestPath = join(targetDir, AUTH_MANIFEST_PATH);
|
|
53
|
+
if (existsSync(manifestPath) && !force) {
|
|
54
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
55
|
+
throw new AuthError(
|
|
56
|
+
'AUTH_ALREADY_APPLIED',
|
|
57
|
+
`${AUTH_MANIFEST_PATH} already exists (provider: ${manifest.provider}). Run remove first, or pass --force.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (profile === 'saas-dashboard') {
|
|
61
|
+
throw new AuthError(
|
|
62
|
+
'AUTH_PROFILE_CONFLICT',
|
|
63
|
+
'--auth=clerk is not compatible with --profile saas-dashboard. saas-dashboard ships Better Auth. Pick one.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ops = plannedOps(targetDir);
|
|
68
|
+
const performed = [];
|
|
69
|
+
|
|
70
|
+
for (const op of ops) {
|
|
71
|
+
const destPath = join(targetDir, op.dest);
|
|
72
|
+
const tmpl = await loadTemplate(op.src);
|
|
73
|
+
const content = tokenSubstitute(tmpl, tokens);
|
|
74
|
+
|
|
75
|
+
if (op.kind === 'create') {
|
|
76
|
+
if (existsSync(destPath) && !force) {
|
|
77
|
+
throw new AuthError('AUTH_DIRTY', `${op.dest} already exists; refuse to overwrite without --force.`);
|
|
78
|
+
}
|
|
79
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
80
|
+
await writeFile(destPath, content, 'utf8');
|
|
81
|
+
performed.push({ kind: 'created', path: op.dest });
|
|
82
|
+
} else if (op.kind === 'create-or-overwrite') {
|
|
83
|
+
const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
|
|
84
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
85
|
+
await writeFile(destPath, content, 'utf8');
|
|
86
|
+
if (prior !== null) {
|
|
87
|
+
performed.push({ kind: 'replaced', path: op.dest, prior });
|
|
88
|
+
} else {
|
|
89
|
+
performed.push({ kind: 'created', path: op.dest });
|
|
90
|
+
}
|
|
91
|
+
} else if (op.kind === 'package-merge') {
|
|
92
|
+
const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
|
|
93
|
+
const merged = mergePackageJson(prior, content);
|
|
94
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
95
|
+
await writeFile(destPath, merged, 'utf8');
|
|
96
|
+
if (prior !== null) {
|
|
97
|
+
performed.push({ kind: 'patched', path: op.dest, prior });
|
|
98
|
+
} else {
|
|
99
|
+
performed.push({ kind: 'created', path: op.dest });
|
|
100
|
+
}
|
|
101
|
+
} else if (op.kind === 'env-append') {
|
|
102
|
+
const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
|
|
103
|
+
const next = prior ? prior + (prior.endsWith('\n') ? '' : '\n') + '\n' + content : content;
|
|
104
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
105
|
+
await writeFile(destPath, next, 'utf8');
|
|
106
|
+
if (prior !== null) {
|
|
107
|
+
performed.push({ kind: 'patched', path: op.dest, prior });
|
|
108
|
+
} else {
|
|
109
|
+
performed.push({ kind: 'created', path: op.dest });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await mkdir(dirname(manifestPath), { recursive: true });
|
|
115
|
+
await writeFile(
|
|
116
|
+
manifestPath,
|
|
117
|
+
JSON.stringify({ appliedAt: new Date().toISOString(), provider: 'clerk', ops: performed }, null, 2) + '\n',
|
|
118
|
+
'utf8',
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return { applied: true, provider: 'clerk', touched: performed.map((p) => p.path) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function removeAuthClerk({ targetDir } = {}) {
|
|
125
|
+
const manifestPath = join(targetDir, AUTH_MANIFEST_PATH);
|
|
126
|
+
if (!existsSync(manifestPath)) {
|
|
127
|
+
throw new AuthError('AUTH_NOT_APPLIED', `${AUTH_MANIFEST_PATH} not found.`);
|
|
128
|
+
}
|
|
129
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
130
|
+
const restored = [];
|
|
131
|
+
for (const op of [...manifest.ops].reverse()) {
|
|
132
|
+
const p = join(targetDir, op.path);
|
|
133
|
+
if (op.kind === 'created' && existsSync(p)) {
|
|
134
|
+
await rm(p, { force: true });
|
|
135
|
+
restored.push({ kind: 'deleted', path: op.path });
|
|
136
|
+
} else if ((op.kind === 'replaced' || op.kind === 'patched') && existsSync(p)) {
|
|
137
|
+
await writeFile(p, op.prior, 'utf8');
|
|
138
|
+
restored.push({ kind: 'restored', path: op.path });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await rm(manifestPath, { force: true });
|
|
142
|
+
try { await rm(join(targetDir, '.ostup'), { recursive: false }); } catch {}
|
|
143
|
+
return { restored: restored.map((r) => r.path), provider: manifest.provider };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { AuthError };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// auth-google.mjs: applyAuthGoogle post-processor. Drops NextAuth v5 + Google provider into a scaffold.
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates', 'auth-google');
|
|
10
|
+
|
|
11
|
+
export const AUTH_MANIFEST_PATH = '.ostup/auth.json';
|
|
12
|
+
|
|
13
|
+
class AuthError extends Error {
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function plannedOps() {
|
|
21
|
+
return [
|
|
22
|
+
{ kind: 'create', dest: 'auth.ts', src: 'auth.ts' },
|
|
23
|
+
{ kind: 'create', dest: 'app/api/auth/[...nextauth]/route.ts', src: 'route.ts' },
|
|
24
|
+
{ kind: 'create', dest: 'app/sign-in/page.tsx', src: 'sign-in-page.tsx' },
|
|
25
|
+
{ kind: 'create', dest: 'app/sign-up/page.tsx', src: 'sign-up-page.tsx' },
|
|
26
|
+
{ kind: 'package-merge', dest: 'package.json', src: 'package.json.additions' },
|
|
27
|
+
{ kind: 'env-append', dest: '.env.example', src: 'env.example.additions' },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function loadTemplate(rel) {
|
|
32
|
+
return readFile(join(TEMPLATES_ROOT, rel), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tokenSubstitute(body, tokens) {
|
|
36
|
+
if (!tokens) return body;
|
|
37
|
+
return body.replace(/\{\{([A-Z_][A-Z_0-9]*)\}\}/g, (m, k) => (tokens[k] != null ? String(tokens[k]) : 'TBD'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mergePackageJson(existing, additionsText) {
|
|
41
|
+
const additions = JSON.parse(additionsText);
|
|
42
|
+
const target = existing ? JSON.parse(existing) : {};
|
|
43
|
+
for (const section of ['dependencies', 'devDependencies', 'scripts']) {
|
|
44
|
+
if (additions[section]) {
|
|
45
|
+
target[section] = { ...(target[section] || {}), ...additions[section] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return JSON.stringify(target, null, 2) + '\n';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function applyAuthGoogle({ targetDir, profile = null, tokens = {}, force = false } = {}) {
|
|
52
|
+
const manifestPath = join(targetDir, AUTH_MANIFEST_PATH);
|
|
53
|
+
if (existsSync(manifestPath) && !force) {
|
|
54
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
55
|
+
throw new AuthError(
|
|
56
|
+
'AUTH_ALREADY_APPLIED',
|
|
57
|
+
`${AUTH_MANIFEST_PATH} already exists (provider: ${manifest.provider}). Run remove first, or pass --force.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (profile === 'saas-dashboard') {
|
|
61
|
+
throw new AuthError(
|
|
62
|
+
'AUTH_PROFILE_CONFLICT',
|
|
63
|
+
'--auth=google is not compatible with --profile saas-dashboard. saas-dashboard ships Better Auth. Pick one.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ops = plannedOps();
|
|
68
|
+
const performed = [];
|
|
69
|
+
|
|
70
|
+
for (const op of ops) {
|
|
71
|
+
const destPath = join(targetDir, op.dest);
|
|
72
|
+
const tmpl = await loadTemplate(op.src);
|
|
73
|
+
const content = tokenSubstitute(tmpl, tokens);
|
|
74
|
+
|
|
75
|
+
if (op.kind === 'create') {
|
|
76
|
+
if (existsSync(destPath) && !force) {
|
|
77
|
+
throw new AuthError('AUTH_DIRTY', `${op.dest} already exists; refuse to overwrite without --force.`);
|
|
78
|
+
}
|
|
79
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
80
|
+
await writeFile(destPath, content, 'utf8');
|
|
81
|
+
performed.push({ kind: 'created', path: op.dest });
|
|
82
|
+
} else if (op.kind === 'package-merge') {
|
|
83
|
+
const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
|
|
84
|
+
const merged = mergePackageJson(prior, content);
|
|
85
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
86
|
+
await writeFile(destPath, merged, 'utf8');
|
|
87
|
+
if (prior !== null) {
|
|
88
|
+
performed.push({ kind: 'patched', path: op.dest, prior });
|
|
89
|
+
} else {
|
|
90
|
+
performed.push({ kind: 'created', path: op.dest });
|
|
91
|
+
}
|
|
92
|
+
} else if (op.kind === 'env-append') {
|
|
93
|
+
const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
|
|
94
|
+
const next = prior ? prior + (prior.endsWith('\n') ? '' : '\n') + '\n' + content : content;
|
|
95
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
96
|
+
await writeFile(destPath, next, 'utf8');
|
|
97
|
+
if (prior !== null) {
|
|
98
|
+
performed.push({ kind: 'patched', path: op.dest, prior });
|
|
99
|
+
} else {
|
|
100
|
+
performed.push({ kind: 'created', path: op.dest });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await mkdir(dirname(manifestPath), { recursive: true });
|
|
106
|
+
await writeFile(
|
|
107
|
+
manifestPath,
|
|
108
|
+
JSON.stringify({ appliedAt: new Date().toISOString(), provider: 'google', ops: performed }, null, 2) + '\n',
|
|
109
|
+
'utf8',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return { applied: true, provider: 'google', touched: performed.map((p) => p.path) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function removeAuthGoogle({ targetDir } = {}) {
|
|
116
|
+
const manifestPath = join(targetDir, AUTH_MANIFEST_PATH);
|
|
117
|
+
if (!existsSync(manifestPath)) {
|
|
118
|
+
throw new AuthError('AUTH_NOT_APPLIED', `${AUTH_MANIFEST_PATH} not found.`);
|
|
119
|
+
}
|
|
120
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
121
|
+
const restored = [];
|
|
122
|
+
for (const op of [...manifest.ops].reverse()) {
|
|
123
|
+
const p = join(targetDir, op.path);
|
|
124
|
+
if (op.kind === 'created' && existsSync(p)) {
|
|
125
|
+
await rm(p, { force: true });
|
|
126
|
+
restored.push({ kind: 'deleted', path: op.path });
|
|
127
|
+
} else if (op.kind === 'patched' && existsSync(p)) {
|
|
128
|
+
await writeFile(p, op.prior, 'utf8');
|
|
129
|
+
restored.push({ kind: 'restored', path: op.path });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await rm(manifestPath, { force: true });
|
|
133
|
+
try { await rm(join(targetDir, '.ostup'), { recursive: false }); } catch {}
|
|
134
|
+
return { restored: restored.map((r) => r.path), provider: manifest.provider };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { AuthError };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// credential-prompts-npm.mjs: detect npm credentials, walk operator through token-paste or browser flow.
|
|
2
|
+
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import { readFileSync, existsSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { run as exec } from './exec.mjs';
|
|
9
|
+
|
|
10
|
+
const NPMRC_PATH = join(homedir(), '.npmrc');
|
|
11
|
+
|
|
12
|
+
export function checkNpmAuth({ env = process.env, runner = defaultCmdOk, npmrcReader = defaultNpmrcReader } = {}) {
|
|
13
|
+
if (env.NPM_TOKEN) return { ok: true, source: 'env' };
|
|
14
|
+
const npmrc = npmrcReader();
|
|
15
|
+
if (npmrc && /_authToken=/.test(npmrc)) return { ok: true, source: 'npmrc' };
|
|
16
|
+
if (runner('npm whoami')) return { ok: true, source: 'npm-cli' };
|
|
17
|
+
return { ok: false };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultCmdOk(cmd) {
|
|
21
|
+
try {
|
|
22
|
+
execSync(cmd, { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultNpmrcReader() {
|
|
30
|
+
try {
|
|
31
|
+
return existsSync(NPMRC_PATH) ? readFileSync(NPMRC_PATH, 'utf8') : '';
|
|
32
|
+
} catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NPM_BLURB = [
|
|
38
|
+
'',
|
|
39
|
+
'npm credentials not found.',
|
|
40
|
+
'',
|
|
41
|
+
'Why this matters: if you ever want to publish a package from this machine,',
|
|
42
|
+
'you need a token. Skip this step if you are only building apps (most users).',
|
|
43
|
+
'',
|
|
44
|
+
'If you do not have an npm account yet:',
|
|
45
|
+
' 1. Open https://www.npmjs.com/signup',
|
|
46
|
+
' 2. Create an account, then return here.',
|
|
47
|
+
'',
|
|
48
|
+
'To create a granular Bypass-2FA token (recommended; survives 2FA-required publishes):',
|
|
49
|
+
' 1. Open https://www.npmjs.com/settings/<your-username>/tokens/new',
|
|
50
|
+
' 2. Token type: Granular access token',
|
|
51
|
+
' 3. Expiration: 90 days (or longer)',
|
|
52
|
+
' 4. Packages and scopes: select what you plan to publish',
|
|
53
|
+
' 5. Permissions: Read and Write',
|
|
54
|
+
' 6. 2FA: select "Bypass 2FA"',
|
|
55
|
+
' 7. Generate token, copy the value (starts with npm_)',
|
|
56
|
+
'',
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
59
|
+
export async function promptForNpmToken() {
|
|
60
|
+
process.stdout.write(NPM_BLURB);
|
|
61
|
+
const token = await p.password({
|
|
62
|
+
message: 'Paste your npm token (starts with npm_; leave blank to skip)',
|
|
63
|
+
});
|
|
64
|
+
if (p.isCancel(token)) return null;
|
|
65
|
+
const trimmed = typeof token === 'string' ? token.trim() : '';
|
|
66
|
+
if (!trimmed) return null;
|
|
67
|
+
return trimmed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function binaryOnPath(bin) {
|
|
71
|
+
try {
|
|
72
|
+
execSync(`${bin} --version`, { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function chooseNpmAuthMethod() {
|
|
80
|
+
const choice = await p.select({
|
|
81
|
+
message: 'npm: how would you like to set up publishing?',
|
|
82
|
+
options: [
|
|
83
|
+
{ value: 'browser', label: 'Sign in via npm login --auth-type=web (recommended)' },
|
|
84
|
+
{ value: 'token', label: 'Paste a Bypass-2FA token instead' },
|
|
85
|
+
{ value: 'skip', label: 'Skip for now (no publishing from this machine)' },
|
|
86
|
+
],
|
|
87
|
+
initialValue: 'browser',
|
|
88
|
+
});
|
|
89
|
+
if (p.isCancel(choice)) return 'skip';
|
|
90
|
+
return choice;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function attemptBrowserAuth() {
|
|
94
|
+
try {
|
|
95
|
+
await exec('npm-login', 'npm', ['login', '--auth-type=web'], { stdio: 'inherit' });
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
process.stdout.write('npm browser sign-in did not complete. Falling back to token paste.\n');
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeNpmrcToken(token, npmrcPath = NPMRC_PATH) {
|
|
104
|
+
const line = `//registry.npmjs.org/:_authToken=${token}\n`;
|
|
105
|
+
if (existsSync(npmrcPath)) {
|
|
106
|
+
const existing = readFileSync(npmrcPath, 'utf8');
|
|
107
|
+
if (existing.includes('//registry.npmjs.org/:_authToken=')) {
|
|
108
|
+
const replaced = existing.replace(/^\/\/registry\.npmjs\.org\/:_authToken=.*$/m, line.trim());
|
|
109
|
+
writeFileSync(npmrcPath, replaced, 'utf8');
|
|
110
|
+
} else {
|
|
111
|
+
appendFileSync(npmrcPath, (existing.endsWith('\n') ? '' : '\n') + line, 'utf8');
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
writeFileSync(npmrcPath, line, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function ensureNpmCredentials({ flags = {} } = {}) {
|
|
119
|
+
const result = checkNpmAuth();
|
|
120
|
+
if (result.ok) {
|
|
121
|
+
process.stdout.write(`npm: using existing auth (${result.source}).\n`);
|
|
122
|
+
return { ok: true, source: result.source };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!binaryOnPath('npm')) {
|
|
126
|
+
process.stdout.write('npm CLI not on PATH; skipping npm setup.\n');
|
|
127
|
+
return { ok: false, source: 'missing-npm' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const method = flags.yes ? 'skip' : await chooseNpmAuthMethod();
|
|
131
|
+
if (method === 'skip') {
|
|
132
|
+
process.stdout.write('npm: setup skipped. Run `npm login` later if you need to publish.\n');
|
|
133
|
+
return { ok: false, source: 'skipped' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (method === 'browser') {
|
|
137
|
+
const ok = await attemptBrowserAuth();
|
|
138
|
+
if (ok && checkNpmAuth().ok) {
|
|
139
|
+
process.stdout.write('npm: signed in via browser.\n');
|
|
140
|
+
return { ok: true, source: 'browser' };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const token = await promptForNpmToken();
|
|
145
|
+
if (!token) {
|
|
146
|
+
process.stdout.write('npm: no token provided. Skipping.\n');
|
|
147
|
+
return { ok: false, source: 'skipped' };
|
|
148
|
+
}
|
|
149
|
+
writeNpmrcToken(token);
|
|
150
|
+
process.stdout.write(`npm: token saved to ${NPMRC_PATH}.\n`);
|
|
151
|
+
return { ok: true, source: 'npmrc-write' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { writeNpmrcToken, NPMRC_PATH };
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import * as p from '@clack/prompts';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { run as exec } from './exec.mjs';
|
|
5
|
+
import { ensureNpmCredentials } from './credential-prompts-npm.mjs';
|
|
5
6
|
|
|
6
7
|
export function checkGithubAuth({ env = process.env, runner = defaultCmdOk } = {}) {
|
|
7
8
|
if (env.GH_TOKEN) return { ok: true, source: 'env-or-dotenv' };
|
|
@@ -178,5 +179,9 @@ export async function ensureCredentials({ stack = 'next', flags = {} } = {}) {
|
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
if (flags.publishReady) {
|
|
183
|
+
await ensureNpmCredentials({ flags });
|
|
184
|
+
}
|
|
185
|
+
|
|
181
186
|
return { collected };
|
|
182
187
|
}
|
package/src/mvp-flow.mjs
CHANGED
|
@@ -134,6 +134,23 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
if (flags.auth && flags.auth !== 'none') {
|
|
138
|
+
const profile = brief?.scaffold?.profile || null;
|
|
139
|
+
if (flags.auth === 'clerk') {
|
|
140
|
+
const { applyAuthClerk } = await import('./auth-clerk.mjs');
|
|
141
|
+
const r = await applyAuthClerk({ targetDir, profile, tokens });
|
|
142
|
+
process.stdout.write(`[auth] applied clerk overlay. ${r.touched.length} file(s) touched\n`);
|
|
143
|
+
} else if (flags.auth === 'google') {
|
|
144
|
+
const { applyAuthGoogle } = await import('./auth-google.mjs');
|
|
145
|
+
const r = await applyAuthGoogle({ targetDir, profile, tokens });
|
|
146
|
+
process.stdout.write(`[auth] applied google overlay. ${r.touched.length} file(s) touched\n`);
|
|
147
|
+
} else {
|
|
148
|
+
const err = new Error(`Unknown --auth value '${flags.auth}'. Use clerk, google, or none.`);
|
|
149
|
+
err.code = 'AUTH_UNKNOWN_PROVIDER';
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
137
154
|
if (flags.private) {
|
|
138
155
|
const { applyPrivate } = await import('./private.mjs');
|
|
139
156
|
const profile = brief?.scaffold?.profile || null;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Add authentication to this project after the fact. Clerk or Google OAuth (NextAuth v5).
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Add auth
|
|
6
|
+
|
|
7
|
+
Use this when the project was scaffolded without `--auth` and the operator now wants login.
|
|
8
|
+
|
|
9
|
+
## Step 1: Which provider?
|
|
10
|
+
|
|
11
|
+
If the operator named one (e.g. `/add-auth clerk`), use it. Otherwise ask:
|
|
12
|
+
|
|
13
|
+
| Provider | Cost | Best for |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `clerk` | Free tier up to 10k MAU | Production-grade hosted auth UI; faster setup |
|
|
16
|
+
| `google` | Free | Sign in with Google only (no custom auth); requires Google Cloud Console |
|
|
17
|
+
|
|
18
|
+
## Step 2: Apply the overlay
|
|
19
|
+
|
|
20
|
+
Run from the project root:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
ostup private add # if you also want default-deny middleware (skip if already on)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then for Clerk:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Manually run the auth-clerk overlay path (or rerun ostup with --auth=clerk via update)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or scaffold the files by hand from the templates at `node_modules/@oaklandzoo/ostup/templates/auth-clerk/` (or `auth-google/`).
|
|
33
|
+
|
|
34
|
+
## Step 3: Get the keys
|
|
35
|
+
|
|
36
|
+
### Clerk
|
|
37
|
+
|
|
38
|
+
1. Open https://dashboard.clerk.com/apps
|
|
39
|
+
2. Click **Create application** if you do not have one. Name it the project name. Select Email + Google.
|
|
40
|
+
3. Once created, go to **API Keys**. Copy:
|
|
41
|
+
- **Publishable key** → `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
|
|
42
|
+
- **Secret key** → `CLERK_SECRET_KEY`
|
|
43
|
+
4. Paste both into `.env.local`.
|
|
44
|
+
|
|
45
|
+
### Google OAuth
|
|
46
|
+
|
|
47
|
+
1. Open https://console.cloud.google.com/apis/credentials/oauthclient
|
|
48
|
+
2. **Application type**: Web application.
|
|
49
|
+
3. **Authorized redirect URIs**: add `http://localhost:3000/api/auth/callback/google` AND your production URL with the same path.
|
|
50
|
+
4. Click **Create**. Copy:
|
|
51
|
+
- **Client ID** → `GOOGLE_CLIENT_ID`
|
|
52
|
+
- **Client secret** → `GOOGLE_CLIENT_SECRET`
|
|
53
|
+
5. Generate a NextAuth secret:
|
|
54
|
+
```bash
|
|
55
|
+
openssl rand -base64 32
|
|
56
|
+
```
|
|
57
|
+
Paste as `AUTH_SECRET`.
|
|
58
|
+
6. Paste all three into `.env.local`.
|
|
59
|
+
|
|
60
|
+
## Step 4: Verify
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm run dev
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Open http://localhost:3000/sign-in. You should see the Clerk widget or the Google sign-in button. Sign in. You should land on `/dashboard` or `/`.
|
|
67
|
+
|
|
68
|
+
If the sign-in page renders but sign-in fails, double-check the env vars are loaded (`npm run dev` reads `.env.local` automatically).
|
|
69
|
+
|
|
70
|
+
## Step 5: Deploy the env vars to Vercel
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
vercel env add NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY production # repeat per env per key
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or paste them into the Vercel dashboard under **Settings → Environment Variables**.
|
|
77
|
+
|
|
78
|
+
## Report
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
Added <provider> auth:
|
|
82
|
+
- Provider: <clerk|google>
|
|
83
|
+
- Sign-in: /sign-in
|
|
84
|
+
- Sign-up: /sign-up
|
|
85
|
+
- Protected: /dashboard
|
|
86
|
+
- Env vars: added to .env.local (also push to Vercel before deploying)
|
|
87
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Publish a new version of this package to npm. Bumps version, runs tests, publishes, tags, pushes.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Publish to npm
|
|
6
|
+
|
|
7
|
+
Use this when the operator says "publish a patch / minor / major" or "ship a new version".
|
|
8
|
+
|
|
9
|
+
## Preflight
|
|
10
|
+
|
|
11
|
+
- This command only makes sense if `package.json` has a `version` field and the project is an npm package (not a private app).
|
|
12
|
+
- Confirm `~/.npmrc` has an authToken or `npm whoami` succeeds. If neither, stop and tell the operator to run `npm login` or paste a Bypass-2FA token from `https://www.npmjs.com/settings/<username>/tokens/new`.
|
|
13
|
+
- Working tree must be clean (`git status --porcelain` returns empty). If dirty, refuse and ask the operator to commit or stash first.
|
|
14
|
+
|
|
15
|
+
## Steps
|
|
16
|
+
|
|
17
|
+
### 1. Pick the version bump
|
|
18
|
+
|
|
19
|
+
If the operator named a level (patch / minor / major), use it. Otherwise ask:
|
|
20
|
+
|
|
21
|
+
| Level | When |
|
|
22
|
+
|---|---|
|
|
23
|
+
| patch | Bug fixes, no API change |
|
|
24
|
+
| minor | New backwards-compatible features |
|
|
25
|
+
| major | Breaking changes |
|
|
26
|
+
|
|
27
|
+
### 2. Bump the version
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm version <patch|minor|major> --no-git-tag-version
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This updates `package.json` only. We tag manually at the end so the commit message + tag stay in sync.
|
|
34
|
+
|
|
35
|
+
### 3. Run tests
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm test
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If tests fail, stop. Revert the version bump (`git checkout package.json package-lock.json`) and report to the operator.
|
|
42
|
+
|
|
43
|
+
### 4. Publish
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm publish
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If npm asks for an OTP and the bypass token is missing, fail clearly and tell the operator to set up a token first.
|
|
50
|
+
|
|
51
|
+
### 5. Commit + tag + push
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
55
|
+
git add package.json package-lock.json
|
|
56
|
+
git commit -m "chore: release v$VERSION"
|
|
57
|
+
git tag "v$VERSION"
|
|
58
|
+
git push
|
|
59
|
+
git push origin "v$VERSION"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 6. Verify
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm view <package-name> version
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Expect the new version. If npm's CDN hasn't propagated yet, retry after 30s.
|
|
69
|
+
|
|
70
|
+
## Report
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
Published <package-name>@<version>
|
|
74
|
+
- npm: https://www.npmjs.com/package/<package-name>
|
|
75
|
+
- Tag: v<version> pushed to origin
|
|
76
|
+
- Verify: npm view <package-name> version
|
|
77
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# --- Clerk auth additions ---
|
|
2
|
+
# Get keys at https://dashboard.clerk.com/apps (create an app, copy from API Keys)
|
|
3
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
|
4
|
+
CLERK_SECRET_KEY=
|
|
5
|
+
|
|
6
|
+
# Optional: where Clerk redirects after sign-in / sign-up
|
|
7
|
+
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
|
8
|
+
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
|
9
|
+
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
|
|
10
|
+
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { ClerkProvider } from '@clerk/nextjs';
|
|
3
|
+
import './globals.css';
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: '{{DISPLAY_NAME}}',
|
|
7
|
+
description: '{{PROJECT_PURPOSE_ONE_SENTENCE}}',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Build-safe: wrap with ClerkProvider only when the publishable key is set.
|
|
11
|
+
// This lets `next build` succeed in CI / fresh scaffolds before the operator
|
|
12
|
+
// has configured the env. In production the key is present and Clerk handles auth.
|
|
13
|
+
const Wrapper = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
|
14
|
+
? ClerkProvider
|
|
15
|
+
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
|
16
|
+
|
|
17
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
18
|
+
return (
|
|
19
|
+
<Wrapper>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<body className="bg-white text-slate-900 antialiased">{children}</body>
|
|
22
|
+
</html>
|
|
23
|
+
</Wrapper>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
|
|
2
|
+
|
|
3
|
+
const isProtectedRoute = createRouteMatcher([
|
|
4
|
+
'/dashboard(.*)',
|
|
5
|
+
'/api/account(.*)',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export default clerkMiddleware(async (auth, req) => {
|
|
9
|
+
if (isProtectedRoute(req)) {
|
|
10
|
+
await auth.protect();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const config = {
|
|
15
|
+
matcher: [
|
|
16
|
+
// Run middleware on all non-static routes.
|
|
17
|
+
'/((?!_next/static|_next/image|favicon\\.ico).*)',
|
|
18
|
+
'/(api|trpc)(.*)',
|
|
19
|
+
],
|
|
20
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import NextAuth from 'next-auth';
|
|
2
|
+
import Google from 'next-auth/providers/google';
|
|
3
|
+
|
|
4
|
+
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
5
|
+
providers: [
|
|
6
|
+
Google({
|
|
7
|
+
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
|
8
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
|
9
|
+
}),
|
|
10
|
+
],
|
|
11
|
+
trustHost: true,
|
|
12
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# --- Google OAuth (NextAuth v5) additions ---
|
|
2
|
+
# Create an OAuth 2.0 Client ID at:
|
|
3
|
+
# https://console.cloud.google.com/apis/credentials/oauthclient
|
|
4
|
+
# Authorized redirect URI: <your-prod-url>/api/auth/callback/google
|
|
5
|
+
GOOGLE_CLIENT_ID=
|
|
6
|
+
GOOGLE_CLIENT_SECRET=
|
|
7
|
+
|
|
8
|
+
# NextAuth secret. Generate one with: openssl rand -base64 32
|
|
9
|
+
AUTH_SECRET=
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { signIn } from '@/auth';
|
|
2
|
+
|
|
3
|
+
export default function SignInPage() {
|
|
4
|
+
return (
|
|
5
|
+
<main className="flex min-h-screen items-center justify-center p-6">
|
|
6
|
+
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-8">
|
|
7
|
+
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
|
8
|
+
<p className="mt-2 text-sm text-slate-600">Continue to {`{{DISPLAY_NAME}}`}.</p>
|
|
9
|
+
<form
|
|
10
|
+
action={async () => {
|
|
11
|
+
'use server';
|
|
12
|
+
await signIn('google', { redirectTo: '/dashboard' });
|
|
13
|
+
}}
|
|
14
|
+
className="mt-8"
|
|
15
|
+
>
|
|
16
|
+
<button
|
|
17
|
+
type="submit"
|
|
18
|
+
className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 font-medium text-slate-900 hover:bg-slate-50"
|
|
19
|
+
>
|
|
20
|
+
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
|
21
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09Z" fill="#4285F4"/>
|
|
22
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z" fill="#34A853"/>
|
|
23
|
+
<path d="M5.84 14.1A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.43.34-2.1V7.07H2.18A11 11 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.83Z" fill="#FBBC05"/>
|
|
24
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.65l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.07l3.66 2.83C6.71 7.3 9.14 5.38 12 5.38Z" fill="#EA4335"/>
|
|
25
|
+
</svg>
|
|
26
|
+
Sign in with Google
|
|
27
|
+
</button>
|
|
28
|
+
</form>
|
|
29
|
+
</div>
|
|
30
|
+
</main>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { signIn } from '@/auth';
|
|
2
|
+
|
|
3
|
+
export default function SignUpPage() {
|
|
4
|
+
return (
|
|
5
|
+
<main className="flex min-h-screen items-center justify-center p-6">
|
|
6
|
+
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-8">
|
|
7
|
+
<h1 className="text-2xl font-semibold tracking-tight">Create account</h1>
|
|
8
|
+
<p className="mt-2 text-sm text-slate-600">Sign up with Google to get started.</p>
|
|
9
|
+
<form
|
|
10
|
+
action={async () => {
|
|
11
|
+
'use server';
|
|
12
|
+
await signIn('google', { redirectTo: '/dashboard' });
|
|
13
|
+
}}
|
|
14
|
+
className="mt-8"
|
|
15
|
+
>
|
|
16
|
+
<button
|
|
17
|
+
type="submit"
|
|
18
|
+
className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 font-medium text-slate-900 hover:bg-slate-50"
|
|
19
|
+
>
|
|
20
|
+
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
|
21
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09Z" fill="#4285F4"/>
|
|
22
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z" fill="#34A853"/>
|
|
23
|
+
<path d="M5.84 14.1A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.43.34-2.1V7.07H2.18A11 11 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.83Z" fill="#FBBC05"/>
|
|
24
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.65l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.07l3.66 2.83C6.71 7.3 9.14 5.38 12 5.38Z" fill="#EA4335"/>
|
|
25
|
+
</svg>
|
|
26
|
+
Continue with Google
|
|
27
|
+
</button>
|
|
28
|
+
</form>
|
|
29
|
+
</div>
|
|
30
|
+
</main>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -3,7 +3,14 @@ import type { NextRequest } from 'next/server';
|
|
|
3
3
|
import { rateLimit } from '@/lib/rate-limit';
|
|
4
4
|
import { audit } from '@/lib/audit';
|
|
5
5
|
|
|
6
|
-
const SESSION_COOKIE_NAMES = [
|
|
6
|
+
const SESSION_COOKIE_NAMES = [
|
|
7
|
+
'__session', // Clerk
|
|
8
|
+
'__clerk_db_jwt', // Clerk db cookie
|
|
9
|
+
'authjs.session-token', // NextAuth v5
|
|
10
|
+
'__Secure-authjs.session-token', // NextAuth v5 secure
|
|
11
|
+
'better-auth.session_token', // Better Auth
|
|
12
|
+
'session_token', // Generic fallback
|
|
13
|
+
];
|
|
7
14
|
|
|
8
15
|
function getIp(req: NextRequest): string {
|
|
9
16
|
const xff = req.headers.get('x-forwarded-for');
|