@oaklandzoo/ostup 0.10.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 +20 -0
- package/bin/cli.mjs +40 -2
- 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/doctor-privacy.mjs +103 -0
- package/src/doctor.mjs +7 -1
- package/src/mvp-flow.mjs +27 -0
- package/src/private-cmd.mjs +35 -0
- package/src/private.mjs +215 -0
- package/templates/.claude/commands/add-auth.md +87 -0
- package/templates/.claude/commands/add-storage.md +31 -1
- 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/CLAUDE_PART_20.md +30 -0
- package/templates/private/api-audit-health.ts +9 -0
- package/templates/private/api-blob-proxy.ts +40 -0
- package/templates/private/lib-audit.ts +18 -0
- package/templates/private/lib-rate-limit-kv.ts +21 -0
- package/templates/private/lib-rate-limit-memory.ts +25 -0
- package/templates/private/middleware.ts +67 -0
- package/templates/private/next.config.ts +14 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// doctor-privacy.mjs: HTTP probes for ostup doctor --privacy.
|
|
2
|
+
// Auto-detects deployed URL from .vercel/project.json; --url overrides.
|
|
3
|
+
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const PROBE_PATHS = [
|
|
9
|
+
'/api/health',
|
|
10
|
+
'/api/contact',
|
|
11
|
+
'/api/booking/request',
|
|
12
|
+
'/api/account/settings',
|
|
13
|
+
'/api/blob/test.jpg',
|
|
14
|
+
'/uploads/test.jpg',
|
|
15
|
+
'/_next/image?url=https%3A%2F%2Fexample.com%2Ftest.jpg&w=64&q=75',
|
|
16
|
+
'/dashboard',
|
|
17
|
+
'/dashboard/settings',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const AUDIT_HEALTH_PATH = '/api/audit/health';
|
|
21
|
+
|
|
22
|
+
export async function detectDeployedUrl(cwd = process.cwd()) {
|
|
23
|
+
const projectJson = join(cwd, '.vercel', 'project.json');
|
|
24
|
+
if (!existsSync(projectJson)) return null;
|
|
25
|
+
try {
|
|
26
|
+
const raw = await readFile(projectJson, 'utf8');
|
|
27
|
+
const data = JSON.parse(raw);
|
|
28
|
+
if (data.productionDomain) {
|
|
29
|
+
const host = String(data.productionDomain).replace(/^https?:\/\//, '');
|
|
30
|
+
return `https://${host}`;
|
|
31
|
+
}
|
|
32
|
+
if (data.production_alias) {
|
|
33
|
+
const host = String(data.production_alias).replace(/^https?:\/\//, '');
|
|
34
|
+
return `https://${host}`;
|
|
35
|
+
}
|
|
36
|
+
if (data.projectId) {
|
|
37
|
+
return null; // We could shell out to `vercel inspect`, but skip in v1.
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function probeOne(baseUrl, path, fetchImpl = fetch) {
|
|
46
|
+
const url = `${baseUrl.replace(/\/+$/, '')}${path}`;
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetchImpl(url, {
|
|
49
|
+
method: 'GET',
|
|
50
|
+
redirect: 'manual',
|
|
51
|
+
headers: { 'user-agent': 'ostup-doctor-privacy/1.0' },
|
|
52
|
+
});
|
|
53
|
+
return { url, status: res.status, ok: res.status === 401 || res.status === 404 || res.status === 403 || (res.status >= 300 && res.status < 400) };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { url, status: 0, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function probeAuditHealth(baseUrl, fetchImpl = fetch) {
|
|
60
|
+
const url = `${baseUrl.replace(/\/+$/, '')}${AUDIT_HEALTH_PATH}`;
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetchImpl(url, { method: 'GET', headers: { 'user-agent': 'ostup-doctor-privacy/1.0' } });
|
|
63
|
+
if (!res.ok) return { url, status: res.status, ok: false };
|
|
64
|
+
const body = await res.json().catch(() => null);
|
|
65
|
+
return { url, status: res.status, ok: body?.ok === true, body };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { url, status: 0, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runPrivacyChecks({ url, cwd = process.cwd(), report, fetchImpl = fetch } = {}) {
|
|
72
|
+
let baseUrl = url;
|
|
73
|
+
if (!baseUrl) baseUrl = await detectDeployedUrl(cwd);
|
|
74
|
+
if (!baseUrl) {
|
|
75
|
+
report('fail', 'privacy url', 'could not resolve deployed URL. Pass --url <vercel-url> or run from a Vercel-linked project dir.');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
report('ok', 'privacy url', baseUrl);
|
|
79
|
+
|
|
80
|
+
for (const p of PROBE_PATHS) {
|
|
81
|
+
const result = await probeOne(baseUrl, p, fetchImpl);
|
|
82
|
+
if (result.error) {
|
|
83
|
+
report('warn', `probe ${p}`, `network error: ${result.error}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (result.ok) {
|
|
87
|
+
report('ok', `probe ${p}`, `${result.status} (denied as expected)`);
|
|
88
|
+
} else {
|
|
89
|
+
report('fail', `probe ${p}`, `${result.status} (expected 401/403/404/redirect; got 2xx)`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const audit = await probeAuditHealth(baseUrl, fetchImpl);
|
|
94
|
+
if (audit.error) {
|
|
95
|
+
report('warn', 'probe audit-health', `network error: ${audit.error}`);
|
|
96
|
+
} else if (audit.ok) {
|
|
97
|
+
report('ok', 'probe audit-health', `${audit.status} (audit pipeline wired)`);
|
|
98
|
+
} else {
|
|
99
|
+
report('fail', 'probe audit-health', `${audit.status} (audit pipeline NOT wired)`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { PROBE_PATHS, AUDIT_HEALTH_PATH };
|
package/src/doctor.mjs
CHANGED
|
@@ -25,7 +25,7 @@ function runCapture(cmd) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export async function runDoctor() {
|
|
28
|
+
export async function runDoctor({ flags = {} } = {}) {
|
|
29
29
|
const findings = [];
|
|
30
30
|
let okCount = 0;
|
|
31
31
|
let failCount = 0;
|
|
@@ -38,6 +38,12 @@ export async function runDoctor() {
|
|
|
38
38
|
else warnCount++;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
if (flags.privacy) {
|
|
42
|
+
const { runPrivacyChecks } = await import('./doctor-privacy.mjs');
|
|
43
|
+
await runPrivacyChecks({ url: flags.url, report });
|
|
44
|
+
return { findings, okCount, warnCount, failCount };
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
// === Tool detection (shared with bootstrap) ===
|
|
42
48
|
const detection = detectAll();
|
|
43
49
|
for (const f of detection.found) {
|
package/src/mvp-flow.mjs
CHANGED
|
@@ -134,6 +134,33 @@ 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
|
+
|
|
154
|
+
if (flags.private) {
|
|
155
|
+
const { applyPrivate } = await import('./private.mjs');
|
|
156
|
+
const profile = brief?.scaffold?.profile || null;
|
|
157
|
+
const priv = await applyPrivate({ targetDir, profile });
|
|
158
|
+
process.stdout.write(
|
|
159
|
+
`[private] applied default-deny. ${priv.touched.length} file(s) touched. ` +
|
|
160
|
+
`rate-limit backend: ${priv.useKvRateLimit ? 'kv' : 'in-memory'}\n`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
137
164
|
await exec('git-init', 'git', ['init'], { cwd: targetDir });
|
|
138
165
|
await exec('git-branch','git', ['branch', '-M', 'main'], { cwd: targetDir });
|
|
139
166
|
await exec('git-add', 'git', ['add', '.'], { cwd: targetDir });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// private-cmd.mjs: ostup private add|remove|status subcommand.
|
|
2
|
+
|
|
3
|
+
import { applyPrivate, removePrivate, statusPrivate, PrivateError } from './private.mjs';
|
|
4
|
+
|
|
5
|
+
export async function runPrivate({ action = 'status', flags = {}, cwd = process.cwd() } = {}) {
|
|
6
|
+
if (action === 'add') {
|
|
7
|
+
const result = await applyPrivate({ targetDir: cwd, force: !!flags.force });
|
|
8
|
+
process.stdout.write(
|
|
9
|
+
`[private] applied. ${result.touched.length} file(s) touched. ` +
|
|
10
|
+
`rate-limit backend: ${result.useKvRateLimit ? 'kv' : 'in-memory'}\n`,
|
|
11
|
+
);
|
|
12
|
+
process.stdout.write(`[private] manifest: .ostup/private.json (run 'ostup private remove' to undo)\n`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (action === 'remove') {
|
|
16
|
+
const result = await removePrivate({ targetDir: cwd, force: !!flags.force });
|
|
17
|
+
process.stdout.write(`[private] removed. ${result.restored.length} file(s) reverted.\n`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (action === 'status') {
|
|
21
|
+
const result = await statusPrivate({ targetDir: cwd });
|
|
22
|
+
if (!result.applied) {
|
|
23
|
+
process.stdout.write(`[private] not applied. Run 'ostup private add' to enable.\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
process.stdout.write(`[private] applied at ${result.manifest.appliedAt}\n`);
|
|
27
|
+
process.stdout.write(`[private] rate-limit backend: ${result.manifest.useKvRateLimit ? 'kv' : 'in-memory'}\n`);
|
|
28
|
+
process.stdout.write(`[private] files under management:\n`);
|
|
29
|
+
for (const op of result.manifest.ops) {
|
|
30
|
+
process.stdout.write(` ${op.kind === 'created' ? '+' : '~'} ${op.path}\n`);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
throw new PrivateError('PRIVATE_UNKNOWN_ACTION', `unknown action '${action}'. Use: add | remove | status`);
|
|
35
|
+
}
|
package/src/private.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// private.mjs: applyPrivate post-processor. Lays down default-deny middleware,
|
|
2
|
+
// blob proxy, audit + rate-limit libs, locked next.config, CLAUDE.md Part 20.
|
|
3
|
+
// Captures original content of patched files for clean rollback by `ostup private remove`.
|
|
4
|
+
|
|
5
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const PRIVATE_TEMPLATES = resolve(PKG_ROOT, 'templates', 'private');
|
|
12
|
+
|
|
13
|
+
const SAAS_CONFLICT_MSG =
|
|
14
|
+
'--private is not compatible with the saas-dashboard profile. ' +
|
|
15
|
+
'saas-dashboard already provides Better Auth + a session-checking middleware.ts. ' +
|
|
16
|
+
'Layering default-deny on top creates two competing matchers.\n\n' +
|
|
17
|
+
'Pick one:\n' +
|
|
18
|
+
' - Use --private alone for a generic single-user-protected app\n' +
|
|
19
|
+
' - Use --profile saas-dashboard alone for the SaaS shell';
|
|
20
|
+
|
|
21
|
+
export const PRIVATE_MANIFEST_PATH = '.ostup/private.json';
|
|
22
|
+
|
|
23
|
+
class PrivateError extends Error {
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function detectSaas(targetDir, profile) {
|
|
31
|
+
if (profile === 'saas-dashboard') return true;
|
|
32
|
+
const pkgPath = join(targetDir, 'package.json');
|
|
33
|
+
if (!existsSync(pkgPath)) return false;
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
36
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
37
|
+
return Boolean(deps['better-auth']);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function plannedOperations(targetDir, useKvRateLimit) {
|
|
44
|
+
// Each op: { kind: 'create'|'patch', dest: <relative path>, src: <template relpath>|null, patcher?: fn }
|
|
45
|
+
const rateLimitSrc = useKvRateLimit ? 'lib-rate-limit-kv.ts' : 'lib-rate-limit-memory.ts';
|
|
46
|
+
return [
|
|
47
|
+
{ kind: 'create', dest: 'middleware.ts', src: 'middleware.ts' },
|
|
48
|
+
{ kind: 'create', dest: 'next.config.ts', src: 'next.config.ts' },
|
|
49
|
+
{ kind: 'create', dest: 'app/api/blob/[...path]/route.ts', src: 'api-blob-proxy.ts' },
|
|
50
|
+
{ kind: 'create', dest: 'app/api/audit/health/route.ts', src: 'api-audit-health.ts' },
|
|
51
|
+
{ kind: 'create', dest: 'lib/audit.ts', src: 'lib-audit.ts' },
|
|
52
|
+
{ kind: 'create', dest: 'lib/rate-limit.ts', src: rateLimitSrc },
|
|
53
|
+
{ kind: 'patch', dest: 'CLAUDE.md', src: 'CLAUDE_PART_20.md', mode: 'append' },
|
|
54
|
+
{ kind: 'patch', dest: 'app/layout.tsx', src: null, mode: 'layout-robots' },
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function loadTemplate(rel) {
|
|
59
|
+
return readFile(join(PRIVATE_TEMPLATES, rel), 'utf8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function patchLayoutRobots(body) {
|
|
63
|
+
// Inject `robots: { index: false, follow: false }` into the metadata export, if present.
|
|
64
|
+
// Idempotent: skip if already contains "robots:".
|
|
65
|
+
if (/robots\s*:/.test(body)) return body;
|
|
66
|
+
// Match: export const metadata: Metadata = { ... };
|
|
67
|
+
const re = /(export\s+const\s+metadata\s*:\s*Metadata\s*=\s*\{)([^}]*)(\})/;
|
|
68
|
+
if (!re.test(body)) return body;
|
|
69
|
+
return body.replace(re, (_full, open, inner, close) => {
|
|
70
|
+
const trimmed = inner.replace(/\s*$/, '');
|
|
71
|
+
const sep = trimmed.endsWith(',') ? '' : (trimmed.length > 0 ? ',' : '');
|
|
72
|
+
const insert = `${trimmed}${sep}\n robots: { index: false, follow: false },\n`;
|
|
73
|
+
return `${open}${insert}${close}`;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function envMentions(targetDir, names) {
|
|
78
|
+
for (const file of ['.env', '.env.local', '.env.production', '.env.example']) {
|
|
79
|
+
const p = join(targetDir, file);
|
|
80
|
+
if (!existsSync(p)) continue;
|
|
81
|
+
try {
|
|
82
|
+
const body = readFileSync(p, 'utf8');
|
|
83
|
+
for (const n of names) {
|
|
84
|
+
if (new RegExp(`^${n}=`, 'm').test(body)) return true;
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function applyPrivate({ targetDir, profile = null, force = false } = {}) {
|
|
92
|
+
if (detectSaas(targetDir, profile)) {
|
|
93
|
+
throw new PrivateError('PRIVATE_SAAS_CONFLICT', SAAS_CONFLICT_MSG);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const manifestPath = join(targetDir, PRIVATE_MANIFEST_PATH);
|
|
97
|
+
if (existsSync(manifestPath) && !force) {
|
|
98
|
+
throw new PrivateError(
|
|
99
|
+
'PRIVATE_ALREADY_APPLIED',
|
|
100
|
+
`${PRIVATE_MANIFEST_PATH} already exists. Run 'ostup private remove' first, or pass --force.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const useKv = envMentions(targetDir, ['KV_URL', 'KV_REST_API_URL']);
|
|
105
|
+
const ops = plannedOperations(targetDir, useKv);
|
|
106
|
+
|
|
107
|
+
const performed = [];
|
|
108
|
+
|
|
109
|
+
for (const op of ops) {
|
|
110
|
+
const destPath = join(targetDir, op.dest);
|
|
111
|
+
|
|
112
|
+
if (op.kind === 'create') {
|
|
113
|
+
if (existsSync(destPath) && !force) {
|
|
114
|
+
throw new PrivateError(
|
|
115
|
+
'PRIVATE_DIRTY',
|
|
116
|
+
`${op.dest} already exists; refusing to overwrite without --force.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const content = await loadTemplate(op.src);
|
|
120
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
121
|
+
await writeFile(destPath, content, 'utf8');
|
|
122
|
+
performed.push({ kind: 'created', path: op.dest });
|
|
123
|
+
} else if (op.kind === 'patch') {
|
|
124
|
+
if (!existsSync(destPath)) {
|
|
125
|
+
// Patching a missing file is a soft-skip (e.g. no app/layout.tsx exists).
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const prior = await readFile(destPath, 'utf8');
|
|
129
|
+
let next;
|
|
130
|
+
if (op.mode === 'append') {
|
|
131
|
+
const addition = await loadTemplate(op.src);
|
|
132
|
+
if (prior.includes('# PART 20: PRIVACY DEFAULT-DENY')) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
next = prior + addition;
|
|
136
|
+
} else if (op.mode === 'layout-robots') {
|
|
137
|
+
next = patchLayoutRobots(prior);
|
|
138
|
+
if (next === prior) continue;
|
|
139
|
+
} else {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
await writeFile(destPath, next, 'utf8');
|
|
143
|
+
performed.push({ kind: 'patched', path: op.dest, prior });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await mkdir(dirname(manifestPath), { recursive: true });
|
|
148
|
+
await writeFile(
|
|
149
|
+
manifestPath,
|
|
150
|
+
JSON.stringify(
|
|
151
|
+
{
|
|
152
|
+
appliedAt: new Date().toISOString(),
|
|
153
|
+
useKvRateLimit: useKv,
|
|
154
|
+
ops: performed,
|
|
155
|
+
},
|
|
156
|
+
null,
|
|
157
|
+
2,
|
|
158
|
+
) + '\n',
|
|
159
|
+
'utf8',
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return { applied: true, touched: performed.map((p) => p.path), useKvRateLimit: useKv };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function removePrivate({ targetDir, force = false } = {}) {
|
|
166
|
+
const manifestPath = join(targetDir, PRIVATE_MANIFEST_PATH);
|
|
167
|
+
if (!existsSync(manifestPath)) {
|
|
168
|
+
throw new PrivateError('PRIVATE_NOT_APPLIED', `${PRIVATE_MANIFEST_PATH} not found; nothing to remove.`);
|
|
169
|
+
}
|
|
170
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
171
|
+
const restored = [];
|
|
172
|
+
|
|
173
|
+
// Walk in reverse so dependent files come back in the right order.
|
|
174
|
+
for (const op of [...manifest.ops].reverse()) {
|
|
175
|
+
const p = join(targetDir, op.path);
|
|
176
|
+
if (op.kind === 'created') {
|
|
177
|
+
if (existsSync(p)) {
|
|
178
|
+
await rm(p, { force: true });
|
|
179
|
+
restored.push({ kind: 'deleted', path: op.path });
|
|
180
|
+
}
|
|
181
|
+
} else if (op.kind === 'patched') {
|
|
182
|
+
if (!existsSync(p)) {
|
|
183
|
+
if (!force) {
|
|
184
|
+
throw new PrivateError(
|
|
185
|
+
'PRIVATE_DIRTY',
|
|
186
|
+
`${op.path} is gone; cannot restore. Re-add the file or pass --force to skip.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// Optional: integrity check against prior. Skipped in v1.
|
|
192
|
+
await writeFile(p, op.prior, 'utf8');
|
|
193
|
+
restored.push({ kind: 'restored', path: op.path });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await rm(manifestPath, { force: true });
|
|
198
|
+
// Best-effort: remove empty .ostup/ dir.
|
|
199
|
+
try {
|
|
200
|
+
await rm(join(targetDir, '.ostup'), { recursive: false });
|
|
201
|
+
} catch {}
|
|
202
|
+
|
|
203
|
+
return { restored: restored.map((r) => r.path) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function statusPrivate({ targetDir } = {}) {
|
|
207
|
+
const manifestPath = join(targetDir, PRIVATE_MANIFEST_PATH);
|
|
208
|
+
if (!existsSync(manifestPath)) {
|
|
209
|
+
return { applied: false, manifest: null };
|
|
210
|
+
}
|
|
211
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
212
|
+
return { applied: true, manifest };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { PrivateError };
|
|
@@ -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
|
+
```
|
|
@@ -62,7 +62,17 @@ Confirm `.env.local` got the new keys. Common ones:
|
|
|
62
62
|
npm install @vercel/<type>
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
### Privacy mode check (before writing blob helpers)
|
|
66
|
+
|
|
67
|
+
Before writing `src/lib/blob.ts`, check whether this project is in privacy mode by looking for `.ostup/private.json` at the project root OR a `# PART 20: PRIVACY DEFAULT-DENY` section in `CLAUDE.md`. If either is present:
|
|
68
|
+
|
|
69
|
+
- Default the helper to `access: 'private'` (not `'public'`).
|
|
70
|
+
- Also scaffold `app/api/blob/[...path]/route.ts` as the auth-checked proxy if it does not already exist (the `--private` applier may have already created it). Do NOT serve blob URLs directly to the client.
|
|
71
|
+
- Skip the `vercel blob create-store --access public` flag from Step 3. Re-create the store without `--access public` if it was already created public.
|
|
72
|
+
|
|
73
|
+
### Blob helper template
|
|
74
|
+
|
|
75
|
+
Default (no privacy mode):
|
|
66
76
|
|
|
67
77
|
```typescript
|
|
68
78
|
// src/lib/blob.ts
|
|
@@ -81,6 +91,26 @@ export async function deleteFile(url: string) {
|
|
|
81
91
|
}
|
|
82
92
|
```
|
|
83
93
|
|
|
94
|
+
Privacy mode:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// src/lib/blob.ts
|
|
98
|
+
import { put, list, del } from '@vercel/blob';
|
|
99
|
+
|
|
100
|
+
export async function uploadFile(path: string, body: Blob | ArrayBuffer | string) {
|
|
101
|
+
// access: 'private' is mandatory in privacy mode. Serve via /api/blob/[...path] proxy.
|
|
102
|
+
return put(path, body, { access: 'private' as 'private' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function listFiles(prefix?: string) {
|
|
106
|
+
return list({ prefix });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function deleteFile(url: string) {
|
|
110
|
+
return del(url);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
84
114
|
Match the project's existing TypeScript conventions (named exports, no defaults, etc.).
|
|
85
115
|
|
|
86
116
|
## Step 6: update AGENTS.md
|
|
@@ -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
|
+
};
|