@oaklandzoo/ostup 0.10.0 → 0.11.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 CHANGED
@@ -24,6 +24,14 @@ When you run this tool, it will:
24
24
  (Better Auth + guarded dashboard + settings), blog (MDX posts + RSS
25
25
  + sitemap). Your first deploy shows a working homepage and day-one
26
26
  flow, not a blank Next.js welcome.
27
+ 8. Optionally pass `--private` to ship an **app-level default-deny**
28
+ project on Vercel Hobby: middleware blocks every request except
29
+ sign-in/up + audit-health, blob helpers default to `access: 'private'`,
30
+ image optimizer cannot proxy external URLs, rate limiting and audit
31
+ logging are wired. Run `ostup doctor --privacy <url>` after deploy to
32
+ verify. Toggle on/off an existing project with `ostup private add` /
33
+ `ostup private remove` (manifest at `.ostup/private.json` makes the
34
+ remove cleanly revertable).
27
35
 
28
36
  ## Quick Start
29
37
 
package/bin/cli.mjs CHANGED
@@ -11,7 +11,7 @@ loadDotEnv();
11
11
 
12
12
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
13
 
14
- const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor', 'bootstrap']);
14
+ const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor', 'bootstrap', 'private']);
15
15
 
16
16
  async function readPkg() {
17
17
  const raw = await readFile(resolve(PKG_ROOT, 'package.json'), 'utf8');
@@ -45,6 +45,10 @@ 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 === '--private') flags.private = true;
49
+ else if (a === '--privacy') flags.privacy = true;
50
+ else if (a === '--url') flags.url = argv[++i];
51
+ else if (a.startsWith('--url=')) flags.url = a.slice('--url='.length);
48
52
  else if (a === '--no-log') flags.noLog = true;
49
53
  else if (a === '--skip-bootstrap') flags.skipBootstrap = true;
50
54
  else if (a === '--no-install') flags.noInstall = true;
@@ -78,6 +82,8 @@ function printHelp() {
78
82
  ' brief Run the 10-question operator intake; write docs/brief.md + brief.json.',
79
83
  ' export-pro Bundle brief + brand + content + initial PRD into a ZIP for client handoff.',
80
84
  ' doctor Self-diagnosis: tool detection + auth + permissions + disk + Chrome. Read-only.',
85
+ ' doctor --privacy Probe a deployed URL for default-deny enforcement (privacy mode).',
86
+ ' private add|remove|status Apply, undo, or report on app-level privacy (default-deny middleware).',
81
87
  ' update Refresh bundled templates from the pinned source.',
82
88
  '',
83
89
  'Flags for `ostup init`:',
@@ -89,6 +95,7 @@ function printHelp() {
89
95
  ' --ingest <path> Copy operator materials from <path> into inputs/.',
90
96
  ' --brief <path> Load a brief.json from <path> and write brief files + apply profile overlay.',
91
97
  ' --white-label Strip OSTUP / Goodshin attribution from generated docs (Studio tier).',
98
+ ' --private App-level default-deny: middleware, blob proxy, image-optimizer lock, audit + rate-limit, CLAUDE.md Part 20. Refused with --profile saas-dashboard.',
92
99
  ' --kit-only Drop the markdown kit into a target dir, no GitHub or Vercel.',
93
100
  ' --config <path> Read .ostup-config.yml from this path (kit-only mode).',
94
101
  ' --skip-bootstrap Skip the in-CLI tool detection / install step (advanced).',
@@ -193,7 +200,7 @@ if (subcommand === 'export-pro') {
193
200
  if (subcommand === 'doctor') {
194
201
  const { runDoctor, printDoctorReport } = await import('../src/doctor.mjs');
195
202
  try {
196
- const result = await runDoctor();
203
+ const result = await runDoctor({ flags });
197
204
  process.stdout.write(printDoctorReport(result));
198
205
  process.exit(result.failCount > 0 ? 1 : 0);
199
206
  } catch (err) {
@@ -202,6 +209,25 @@ if (subcommand === 'doctor') {
202
209
  }
203
210
  }
204
211
 
212
+ if (subcommand === 'private') {
213
+ const action = subPositional[0] || 'status';
214
+ const { runPrivate } = await import('../src/private-cmd.mjs');
215
+ try {
216
+ await runPrivate({ action, flags, cwd: process.cwd() });
217
+ process.exit(0);
218
+ } catch (err) {
219
+ process.stderr.write(`${err.message}\n`);
220
+ const userErrors = new Set([
221
+ 'PRIVATE_SAAS_CONFLICT',
222
+ 'PRIVATE_NOT_APPLIED',
223
+ 'PRIVATE_ALREADY_APPLIED',
224
+ 'PRIVATE_DIRTY',
225
+ 'PRIVATE_UNKNOWN_ACTION',
226
+ ]);
227
+ process.exit(userErrors.has(err.code) ? 1 : 2);
228
+ }
229
+ }
230
+
205
231
  if (subcommand === 'bootstrap') {
206
232
  const { runBootstrapStandalone } = await import('../src/bootstrap.mjs');
207
233
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Scaffolds a new repo with the Ostup Agent Kit pre-installed: slash commands, doc templates, and a clean working state.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,16 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
134
134
  }
135
135
  }
136
136
 
137
+ if (flags.private) {
138
+ const { applyPrivate } = await import('./private.mjs');
139
+ const profile = brief?.scaffold?.profile || null;
140
+ const priv = await applyPrivate({ targetDir, profile });
141
+ process.stdout.write(
142
+ `[private] applied default-deny. ${priv.touched.length} file(s) touched. ` +
143
+ `rate-limit backend: ${priv.useKvRateLimit ? 'kv' : 'in-memory'}\n`,
144
+ );
145
+ }
146
+
137
147
  await exec('git-init', 'git', ['init'], { cwd: targetDir });
138
148
  await exec('git-branch','git', ['branch', '-M', 'main'], { cwd: targetDir });
139
149
  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
+ }
@@ -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 };
@@ -62,7 +62,17 @@ Confirm `.env.local` got the new keys. Common ones:
62
62
  npm install @vercel/<type>
63
63
  ```
64
64
 
65
- Create `src/lib/<type>.ts` with typed helpers. Example for blob:
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,30 @@
1
+
2
+ ---
3
+
4
+ # PART 20: PRIVACY DEFAULT-DENY (--private mode)
5
+
6
+ This project was scaffolded with `--private` (or `ostup private add` was later run on it). Default-deny is mandatory. Treat the rules below as hard rules per Part 3.
7
+
8
+ ## Hard rules
9
+
10
+ 1. **Do NOT widen the middleware matcher.** The `middleware.ts` matcher at the project root is intentionally strict. Adding paths to its exclusion list ungates them across the entire app. Any change MUST be reviewed for whether it accidentally exposes user content.
11
+
12
+ 2. **All `@vercel/blob` helpers default to `access: 'private'`.** Generated `src/lib/blob.ts` and any blob helper code uses `access: 'private'`. To serve a blob to authenticated users, route it through `app/api/blob/[...path]/route.ts` (the auth-checked proxy). Never set `access: 'public'` unless the file is truly safe to expose to the open internet.
13
+
14
+ 3. **Do NOT change `next.config.ts` -> `images.remotePatterns`.** It is intentionally empty (`[]`). Adding blob hosts would let `/_next/image?url=<blob-url>` proxy private blobs around auth.
15
+
16
+ 4. **`/_next/image` is auth-gated.** The matcher deliberately does NOT exclude `/_next/image`. This blocks public abuse of the image optimizer for any URL the matcher does not already allow.
17
+
18
+ 5. **Rate limiting is wired in middleware.** Default: `/api/*` 60 req/min/IP, everything else 600 req/min/IP. Adjust `src/lib/rate-limit.ts` constants. Removing the rate-limit call from middleware requires explicit operator review.
19
+
20
+ 6. **Audit log every blocked request.** Every 401/429 goes through `src/lib/audit.ts` -> `console.log` -> Vercel Logs. Do not silence the audit logger.
21
+
22
+ 7. **The audit-health endpoint at `/api/audit/health` is the only intentionally public route in privacy mode.** It confirms the audit pipeline is wired. Used by `ostup doctor --privacy` to verify the deploy.
23
+
24
+ ## Verification
25
+
26
+ Run `ostup doctor --privacy <deployed-url>` after every deploy. It probes a list of suspicious paths without cookies and asserts they return 401 or 404. Any 200 is a privacy regression.
27
+
28
+ ## To undo
29
+
30
+ If privacy mode is no longer needed, run `ostup private remove` from the project root. The applier captured original file contents in `.ostup/private.json`; remove will restore them.
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({
5
+ ok: true,
6
+ pipeline: 'wired',
7
+ privacy: '--private',
8
+ });
9
+ }
@@ -0,0 +1,40 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { head } from '@vercel/blob';
3
+ import { audit } from '@/lib/audit';
4
+
5
+ const SESSION_COOKIE_NAMES = ['better-auth.session_token', 'authjs.session-token', 'session_token'];
6
+
7
+ export async function GET(req: Request, { params }: { params: Promise<{ path: string[] }> }) {
8
+ const cookieHeader = req.headers.get('cookie') || '';
9
+ const hasSession = SESSION_COOKIE_NAMES.some((name) => cookieHeader.includes(`${name}=`));
10
+ if (!hasSession) {
11
+ audit({
12
+ ts: new Date().toISOString(),
13
+ path: new URL(req.url).pathname,
14
+ ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown',
15
+ method: 'GET',
16
+ ua: req.headers.get('user-agent') || '',
17
+ reason: 'no_session',
18
+ status: 401,
19
+ });
20
+ return new NextResponse('Unauthorized', { status: 401 });
21
+ }
22
+
23
+ const { path } = await params;
24
+ const blobPath = path.join('/');
25
+ try {
26
+ const blob = await head(blobPath);
27
+ if (!blob) return new NextResponse('Not Found', { status: 404 });
28
+ const upstream = await fetch(blob.url);
29
+ if (!upstream.ok || !upstream.body) return new NextResponse('Not Found', { status: 404 });
30
+ return new NextResponse(upstream.body, {
31
+ headers: {
32
+ 'content-type': blob.contentType || 'application/octet-stream',
33
+ 'cache-control': 'private, no-store',
34
+ 'x-robots-tag': 'noindex, nofollow',
35
+ },
36
+ });
37
+ } catch {
38
+ return new NextResponse('Not Found', { status: 404 });
39
+ }
40
+ }
@@ -0,0 +1,18 @@
1
+ export type AuditEntry = {
2
+ ts: string;
3
+ path: string;
4
+ ip: string;
5
+ method: string;
6
+ ua: string;
7
+ reason: 'no_session' | 'rate_limit' | 'invalid_token';
8
+ status: number;
9
+ };
10
+
11
+ const IGNORE_PATHS: string[] = [
12
+ '/api/audit/health',
13
+ ];
14
+
15
+ export function audit(entry: AuditEntry): void {
16
+ if (IGNORE_PATHS.some((p) => entry.path.startsWith(p))) return;
17
+ console.log(`[audit] ${JSON.stringify(entry)}`);
18
+ }
@@ -0,0 +1,21 @@
1
+ import { kv } from '@vercel/kv';
2
+
3
+ const LIMITS: { test: (path: string) => boolean; perMin: number }[] = [
4
+ { test: (p) => p.startsWith('/api/'), perMin: 60 },
5
+ { test: () => true, perMin: 600 },
6
+ ];
7
+
8
+ export async function rateLimit({ ip, path }: { ip: string; path: string }): Promise<{ ok: boolean; retryAfterSeconds: number }> {
9
+ const limit = LIMITS.find((l) => l.test(path))!;
10
+ const minute = Math.floor(Date.now() / 60_000);
11
+ const key = `rl:${ip}:${minute}:${limit.perMin}`;
12
+ const count = await kv.incr(key);
13
+ if (count === 1) {
14
+ await kv.expire(key, 65);
15
+ }
16
+ if (count > limit.perMin) {
17
+ const retryAfterSeconds = 60 - (Math.floor(Date.now() / 1000) % 60);
18
+ return { ok: false, retryAfterSeconds };
19
+ }
20
+ return { ok: true, retryAfterSeconds: 0 };
21
+ }
@@ -0,0 +1,25 @@
1
+ type Bucket = { count: number; resetAt: number };
2
+
3
+ const buckets = new Map<string, Bucket>();
4
+
5
+ const LIMITS: { test: (path: string) => boolean; perMin: number }[] = [
6
+ { test: (p) => p.startsWith('/api/'), perMin: 60 },
7
+ { test: () => true, perMin: 600 },
8
+ ];
9
+
10
+ export async function rateLimit({ ip, path }: { ip: string; path: string }): Promise<{ ok: boolean; retryAfterSeconds: number }> {
11
+ const limit = LIMITS.find((l) => l.test(path))!;
12
+ const key = `${ip}::${limit.perMin}`;
13
+ const now = Date.now();
14
+ const bucket = buckets.get(key);
15
+
16
+ if (!bucket || bucket.resetAt < now) {
17
+ buckets.set(key, { count: 1, resetAt: now + 60_000 });
18
+ return { ok: true, retryAfterSeconds: 0 };
19
+ }
20
+ if (bucket.count >= limit.perMin) {
21
+ return { ok: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
22
+ }
23
+ bucket.count += 1;
24
+ return { ok: true, retryAfterSeconds: 0 };
25
+ }
@@ -0,0 +1,60 @@
1
+ import { NextResponse } from 'next/server';
2
+ import type { NextRequest } from 'next/server';
3
+ import { rateLimit } from '@/lib/rate-limit';
4
+ import { audit } from '@/lib/audit';
5
+
6
+ const SESSION_COOKIE_NAMES = ['better-auth.session_token', 'authjs.session-token', 'session_token'];
7
+
8
+ function getIp(req: NextRequest): string {
9
+ const xff = req.headers.get('x-forwarded-for');
10
+ if (xff) return xff.split(',')[0].trim();
11
+ return req.headers.get('x-real-ip') || 'unknown';
12
+ }
13
+
14
+ export async function middleware(req: NextRequest) {
15
+ const ip = getIp(req);
16
+ const path = req.nextUrl.pathname;
17
+ const method = req.method;
18
+ const ua = req.headers.get('user-agent') || '';
19
+
20
+ const rl = await rateLimit({ ip, path });
21
+ if (!rl.ok) {
22
+ audit({ ts: new Date().toISOString(), path, ip, method, ua, reason: 'rate_limit', status: 429 });
23
+ return new NextResponse('Too Many Requests', {
24
+ status: 429,
25
+ headers: {
26
+ 'retry-after': String(rl.retryAfterSeconds),
27
+ 'x-robots-tag': 'noindex, nofollow',
28
+ },
29
+ });
30
+ }
31
+
32
+ const hasSession = SESSION_COOKIE_NAMES.some((name) => req.cookies.get(name)?.value);
33
+ if (!hasSession) {
34
+ audit({ ts: new Date().toISOString(), path, ip, method, ua, reason: 'no_session', status: 401 });
35
+ if (method === 'GET' && req.headers.get('accept')?.includes('text/html')) {
36
+ const url = req.nextUrl.clone();
37
+ url.pathname = '/sign-in';
38
+ url.searchParams.set('next', path);
39
+ return NextResponse.redirect(url);
40
+ }
41
+ return new NextResponse('Unauthorized', {
42
+ status: 401,
43
+ headers: { 'x-robots-tag': 'noindex, nofollow' },
44
+ });
45
+ }
46
+
47
+ const res = NextResponse.next();
48
+ res.headers.set('x-robots-tag', 'noindex, nofollow');
49
+ return res;
50
+ }
51
+
52
+ export const config = {
53
+ // Default-deny matcher.
54
+ // Bypassed: /login, /signup, /sign-in, /sign-up, /api/auth/*, /api/audit/health,
55
+ // /_next/static/*, /favicon.ico, /robots.txt.
56
+ // NOTE: /_next/image is deliberately NOT excluded. The image optimizer must be
57
+ // auth-gated too. Otherwise /_next/image?url=<blob-url> would proxy private blobs
58
+ // around auth.
59
+ matcher: ['/((?!login|signup|sign-in|sign-up|api/auth|api/audit/health|_next/static|favicon\\.ico|robots\\.txt).*)'],
60
+ };
@@ -0,0 +1,14 @@
1
+ import type { NextConfig } from 'next';
2
+
3
+ const nextConfig: NextConfig = {
4
+ images: {
5
+ // Intentionally empty. The Next.js image optimizer cannot proxy any external
6
+ // host. This is required for privacy mode: if a private blob URL were added
7
+ // here, /_next/image?url=<blob-url> would serve the blob around auth.
8
+ //
9
+ // To serve user uploads with auth, use the /api/blob/[...path] proxy route.
10
+ remotePatterns: [],
11
+ },
12
+ };
13
+
14
+ export default nextConfig;