hazo_files 1.5.2 → 2.0.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.
@@ -0,0 +1,968 @@
1
+ # Test-App v2 Provider Pages Implementation Plan
2
+
3
+ ## Implementation Progress
4
+
5
+ - [x] Task 1: Export `AppFileServerProvider` from `hazo_files/server` + rebuild — c2a17c3 (2026-05-23)
6
+ - [x] Task 2: Sidebar — v2 Providers section — 39d68fa (2026-05-23)
7
+ - [x] Task 3: API route `/api/providers/app-file-server` — 0c2d598 (2026-05-23)
8
+ - [x] Task 4: Signed-URL serve route — 0983dfa (2026-05-23)
9
+ - [x] Task 5: AppFileServerProvider scenario page — fdff8ec (2026-05-23)
10
+ - [x] Task 6: Stub pages (InMemory + GDrive) — d815df6 (2026-05-23)
11
+ - [ ] Task 7: Smoke test — manual browser verification needed
12
+
13
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
14
+
15
+ **Goal:** Add interactive test pages for the Phase 1.16 `FileStorageProvider` abstraction to the hazo_files test-app — a live AppFileServerProvider page with 8 runnable scenarios, a signed-URL serve route, and stub pages for InMemoryProvider and GoogleDriveProvider.
16
+
17
+ **Architecture:** The test-app's Next.js API routes instantiate `AppFileServerProvider` directly (no TrackedFileManager layer); a new `/api/files/serve/[token]/[...path]` route verifies HMAC tokens and streams files; the sidebar is extended to support optional section labels. No changes to the `hazo_files` package source logic — only exports and the test-app.
18
+
19
+ **Tech Stack:** Next.js 14 (App Router), TypeScript, `hazo_files/server` (symlinked local package), Lucide React icons, Tailwind CSS, shadcn/ui Button component. All work is in `/Users/pubs/Local/01.code/00.lib/hazo_files/` unless stated otherwise. Test-app lives at `test-app/` within that root.
20
+
21
+ ---
22
+
23
+ ## File map
24
+
25
+ | File | Action | Purpose |
26
+ |---|---|---|
27
+ | `src/server/index.ts` | Modify | Export `AppFileServerProvider` + `AppFileServerOpts` |
28
+ | `test-app/app/components/Sidebar.tsx` | Modify | Add `section?` label support + 3 new nav entries |
29
+ | `test-app/app/api/providers/app-file-server/route.ts` | Create | Action-dispatched API for all 8 provider operations |
30
+ | `test-app/app/api/files/serve/[token]/[...path]/route.ts` | Create | HMAC-verified file serving |
31
+ | `test-app/app/providers/app-file-server/page.tsx` | Create | 8 scenario cards + signed-URL link |
32
+ | `test-app/app/providers/in-memory/page.tsx` | Create | Stub: interface listing + coming-soon badge |
33
+ | `test-app/app/providers/gdrive/page.tsx` | Create | Stub: interface listing + coming-soon badge |
34
+
35
+ ---
36
+
37
+ ## Task 1: Export `AppFileServerProvider` from `hazo_files/server` and rebuild
38
+
39
+ **Files:**
40
+ - Modify: `src/server/index.ts`
41
+
42
+ The test-app imports from `hazo_files/server` (the compiled `dist/server/index.js`). `AppFileServerProvider` is currently only at `src/providers/app-file-server.ts` with no export from the entry points. This task adds the export and rebuilds so the test-app can import it.
43
+
44
+ - [ ] **Step 1: Add exports to `src/server/index.ts`**
45
+
46
+ After the `// Schema exports` block (around line 93), add:
47
+
48
+ ```ts
49
+ // Storage providers
50
+ export { AppFileServerProvider } from '../providers/app-file-server';
51
+ export type { AppFileServerOpts } from '../providers/app-file-server';
52
+ export type {
53
+ FileStorageProvider,
54
+ StoragePath,
55
+ PutOpts,
56
+ PutResult,
57
+ SignedUrlOpts,
58
+ ProbeResult,
59
+ } from '../providers/types';
60
+ export { StorageCollisionExhausted, StorageNotConfigured, StorageUnavailable } from '../providers/types';
61
+ ```
62
+
63
+ - [ ] **Step 2: Rebuild the package**
64
+
65
+ ```bash
66
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files
67
+ npm run build
68
+ ```
69
+
70
+ Expected: build completes with no errors.
71
+
72
+ - [ ] **Step 3: Verify the export is reachable**
73
+
74
+ ```bash
75
+ node -e "const { AppFileServerProvider } = require('./dist/server/index.js'); console.log(typeof AppFileServerProvider);"
76
+ ```
77
+
78
+ Expected output: `function`
79
+
80
+ - [ ] **Step 4: Commit**
81
+
82
+ ```bash
83
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files
84
+ git add src/server/index.ts
85
+ git commit -m "feat(hazo_files): export AppFileServerProvider from server entry point"
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Task 2: Sidebar — add v2 Providers section
91
+
92
+ **Files:**
93
+ - Modify: `test-app/app/components/Sidebar.tsx`
94
+
95
+ The sidebar renders a flat `menuItems` array. Extend the item shape with an optional `section` field; when a section label changes, render a `<Separator />` + small label before that group.
96
+
97
+ - [ ] **Step 1: Update `Sidebar.tsx`**
98
+
99
+ Replace the entire file content:
100
+
101
+ ```tsx
102
+ 'use client';
103
+
104
+ import React from 'react';
105
+ import Link from 'next/link';
106
+ import { usePathname } from 'next/navigation';
107
+ import {
108
+ FolderOpen,
109
+ Cloud,
110
+ Inbox,
111
+ Settings,
112
+ HardDrive,
113
+ Home,
114
+ ChevronLeft,
115
+ ChevronRight,
116
+ FileText,
117
+ FlaskConical,
118
+ Database,
119
+ Tags,
120
+ Upload,
121
+ Bot,
122
+ MessageSquare,
123
+ Tag,
124
+ Gauge,
125
+ Link as LinkIcon,
126
+ Archive,
127
+ Server,
128
+ MemoryStick,
129
+ } from 'lucide-react';
130
+ import { cn } from '@/lib/utils';
131
+ import { Button } from '@/components/ui/button';
132
+ import { Separator } from '@/components/ui/separator';
133
+ import {
134
+ Tooltip,
135
+ TooltipContent,
136
+ TooltipProvider,
137
+ TooltipTrigger,
138
+ } from '@/components/ui/tooltip';
139
+
140
+ interface SidebarProps {
141
+ collapsed: boolean;
142
+ onToggle: () => void;
143
+ }
144
+
145
+ interface MenuItem {
146
+ title: string;
147
+ icon: React.ComponentType<{ className?: string }>;
148
+ href: string;
149
+ /** When set, renders a separator + this label before this item. */
150
+ section?: string;
151
+ }
152
+
153
+ const menuItems: MenuItem[] = [
154
+ { title: 'Home', icon: Home, href: '/' },
155
+ { title: 'Local Files', icon: HardDrive, href: '/local' },
156
+ { title: 'Google Drive', icon: Cloud, href: '/google-drive' },
157
+ { title: 'Dropbox', icon: Inbox, href: '/dropbox' },
158
+ { title: 'Naming Config', icon: FileText, href: '/naming-config' },
159
+ { title: 'Naming Test', icon: FlaskConical, href: '/naming-test' },
160
+ { title: 'File Metadata', icon: Database, href: '/file-metadata' },
161
+ { title: 'Naming Conventions', icon: Tags, href: '/naming-conventions' },
162
+ { title: 'Upload & Extract', icon: Upload, href: '/upload-extract' },
163
+ { title: 'Content Tag', icon: Tag, href: '/content-tag' },
164
+ { title: 'LLM API', icon: Bot, href: '/llm-api' },
165
+ { title: 'Prompts', icon: MessageSquare, href: '/prompts' },
166
+ { title: 'Quota', icon: Gauge, href: '/quota' },
167
+ { title: 'URL Import', icon: LinkIcon, href: '/url_import' },
168
+ { title: 'Lifecycle', icon: Archive, href: '/lifecycle' },
169
+ {
170
+ title: 'App File Server',
171
+ icon: Server,
172
+ href: '/providers/app-file-server',
173
+ section: 'v2 Providers',
174
+ },
175
+ { title: 'In-Memory', icon: MemoryStick, href: '/providers/in-memory' },
176
+ { title: 'GDrive Provider', icon: Cloud, href: '/providers/gdrive' },
177
+ ];
178
+
179
+ const bottomItems: MenuItem[] = [
180
+ { title: 'Settings', icon: Settings, href: '/settings' },
181
+ ];
182
+
183
+ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
184
+ const pathname = usePathname();
185
+
186
+ function renderItem(item: MenuItem, index: number) {
187
+ const isActive =
188
+ pathname === item.href ||
189
+ (item.href !== '/' && pathname.startsWith(item.href));
190
+ const Icon = item.icon;
191
+
192
+ const button = collapsed ? (
193
+ <Tooltip key={item.href}>
194
+ <TooltipTrigger asChild>
195
+ <Link href={item.href}>
196
+ <Button
197
+ variant={isActive ? 'secondary' : 'ghost'}
198
+ size="icon"
199
+ className={cn(
200
+ 'w-full justify-center',
201
+ isActive && 'bg-sidebar-accent text-sidebar-accent-foreground'
202
+ )}
203
+ >
204
+ <Icon className="h-5 w-5" />
205
+ </Button>
206
+ </Link>
207
+ </TooltipTrigger>
208
+ <TooltipContent side="right">{item.title}</TooltipContent>
209
+ </Tooltip>
210
+ ) : (
211
+ <Link key={item.href} href={item.href}>
212
+ <Button
213
+ variant={isActive ? 'secondary' : 'ghost'}
214
+ className={cn(
215
+ 'w-full justify-start gap-3',
216
+ isActive && 'bg-sidebar-accent text-sidebar-accent-foreground'
217
+ )}
218
+ >
219
+ <Icon className="h-5 w-5" />
220
+ {item.title}
221
+ </Button>
222
+ </Link>
223
+ );
224
+
225
+ if (item.section) {
226
+ return (
227
+ <React.Fragment key={item.href}>
228
+ <div className="pt-2 pb-1">
229
+ <Separator />
230
+ {!collapsed && (
231
+ <p className="px-3 pt-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
232
+ {item.section}
233
+ </p>
234
+ )}
235
+ </div>
236
+ {button}
237
+ </React.Fragment>
238
+ );
239
+ }
240
+
241
+ return <React.Fragment key={`${item.href}-${index}`}>{button}</React.Fragment>;
242
+ }
243
+
244
+ return (
245
+ <TooltipProvider delayDuration={0}>
246
+ <aside
247
+ className={cn(
248
+ 'flex flex-col h-full bg-sidebar border-r border-sidebar-border transition-all duration-300',
249
+ collapsed ? 'w-16' : 'w-64'
250
+ )}
251
+ >
252
+ {/* Header */}
253
+ <div className="flex items-center h-16 px-4 border-b border-sidebar-border">
254
+ {!collapsed && (
255
+ <div className="flex items-center gap-2">
256
+ <FolderOpen className="h-6 w-6 text-sidebar-primary" />
257
+ <span className="font-semibold text-sidebar-foreground">Hazo Files</span>
258
+ </div>
259
+ )}
260
+ {collapsed && (
261
+ <FolderOpen className="h-6 w-6 text-sidebar-primary mx-auto" />
262
+ )}
263
+ </div>
264
+
265
+ {/* Navigation */}
266
+ <nav className="flex-1 p-2 space-y-1 overflow-y-auto">
267
+ {menuItems.map((item, index) => renderItem(item, index))}
268
+ </nav>
269
+
270
+ <Separator />
271
+
272
+ {/* Bottom items */}
273
+ <div className="p-2 space-y-1">
274
+ {bottomItems.map((item, index) => renderItem(item, index))}
275
+ </div>
276
+
277
+ {/* Collapse toggle */}
278
+ <div className="p-2 border-t border-sidebar-border">
279
+ <Button
280
+ variant="ghost"
281
+ size="icon"
282
+ onClick={onToggle}
283
+ className="w-full"
284
+ >
285
+ {collapsed ? (
286
+ <ChevronRight className="h-4 w-4" />
287
+ ) : (
288
+ <ChevronLeft className="h-4 w-4" />
289
+ )}
290
+ </Button>
291
+ </div>
292
+ </aside>
293
+ </TooltipProvider>
294
+ );
295
+ }
296
+
297
+ export default Sidebar;
298
+ ```
299
+
300
+ - [ ] **Step 2: Verify it compiles**
301
+
302
+ ```bash
303
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files/test-app
304
+ npx tsc --noEmit 2>&1 | head -20
305
+ ```
306
+
307
+ Expected: no errors (or only pre-existing errors unrelated to Sidebar.tsx).
308
+
309
+ - [ ] **Step 3: Commit**
310
+
311
+ ```bash
312
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files
313
+ git add test-app/app/components/Sidebar.tsx
314
+ git commit -m "feat(test-app): add v2 Providers section to sidebar"
315
+ ```
316
+
317
+ ---
318
+
319
+ ## Task 3: API route for AppFileServerProvider
320
+
321
+ **Files:**
322
+ - Create: `test-app/app/api/providers/app-file-server/route.ts`
323
+
324
+ Single route file handling GET (probe, exists, get, sign) and POST (put, delete). Instantiates `AppFileServerProvider` with root `./files/provider-test` and the `APP_FILE_SERVER_HMAC_SECRET` env var (dev fallback: `"dev-hmac-secret"`).
325
+
326
+ - [ ] **Step 1: Create the directory and route file**
327
+
328
+ ```bash
329
+ mkdir -p /Users/pubs/Local/01.code/00.lib/hazo_files/test-app/app/api/providers/app-file-server
330
+ ```
331
+
332
+ Create `test-app/app/api/providers/app-file-server/route.ts`:
333
+
334
+ ```ts
335
+ import { NextRequest, NextResponse } from 'next/server';
336
+ import path from 'node:path';
337
+ import { AppFileServerProvider } from 'hazo_files/server';
338
+
339
+ function makeProvider() {
340
+ return new AppFileServerProvider({
341
+ root: path.join(process.cwd(), 'files/provider-test'),
342
+ hmac_secret: process.env.APP_FILE_SERVER_HMAC_SECRET ?? 'dev-hmac-secret',
343
+ });
344
+ }
345
+
346
+ export async function GET(request: NextRequest) {
347
+ const { searchParams } = new URL(request.url);
348
+ const action = searchParams.get('action');
349
+ const filePath = searchParams.get('path') ?? '';
350
+ const ttl = Number(searchParams.get('ttl') ?? '60');
351
+
352
+ const provider = makeProvider();
353
+
354
+ try {
355
+ switch (action) {
356
+ case 'probe': {
357
+ const result = await provider.probe();
358
+ return NextResponse.json({ success: true, data: result });
359
+ }
360
+
361
+ case 'exists': {
362
+ if (!filePath) return NextResponse.json({ success: false, error: 'path required' }, { status: 400 });
363
+ const exists = await provider.exists(filePath);
364
+ return NextResponse.json({ success: true, data: { exists } });
365
+ }
366
+
367
+ case 'get': {
368
+ if (!filePath) return NextResponse.json({ success: false, error: 'path required' }, { status: 400 });
369
+ const buf = await provider.get(filePath) as Buffer;
370
+ return NextResponse.json({ success: true, data: { content: buf.toString() } });
371
+ }
372
+
373
+ case 'sign': {
374
+ if (!filePath) return NextResponse.json({ success: false, error: 'path required' }, { status: 400 });
375
+ const url = await provider.getSignedUrl(filePath, { ttl_seconds: ttl });
376
+ return NextResponse.json({ success: true, data: { url } });
377
+ }
378
+
379
+ default:
380
+ return NextResponse.json({ success: false, error: `Unknown action: ${action}` }, { status: 400 });
381
+ }
382
+ } catch (err) {
383
+ const e = err as Error;
384
+ return NextResponse.json(
385
+ { success: false, error: e.message, errorType: e.constructor.name },
386
+ { status: 500 }
387
+ );
388
+ }
389
+ }
390
+
391
+ export async function POST(request: NextRequest) {
392
+ const body = await request.json() as {
393
+ action: string;
394
+ path?: string;
395
+ content?: string;
396
+ ifNotExists?: boolean;
397
+ };
398
+ const { action, path: filePath, content, ifNotExists } = body;
399
+
400
+ if (!filePath) {
401
+ return NextResponse.json({ success: false, error: 'path required' }, { status: 400 });
402
+ }
403
+
404
+ const provider = makeProvider();
405
+
406
+ try {
407
+ switch (action) {
408
+ case 'put': {
409
+ const buf = Buffer.from(content ?? '');
410
+ const result = await provider.put(filePath, buf, { ifNotExists: ifNotExists ?? false });
411
+ return NextResponse.json({ success: true, data: result });
412
+ }
413
+
414
+ case 'delete': {
415
+ await provider.delete(filePath);
416
+ return NextResponse.json({ success: true, data: { deleted: true } });
417
+ }
418
+
419
+ default:
420
+ return NextResponse.json({ success: false, error: `Unknown action: ${action}` }, { status: 400 });
421
+ }
422
+ } catch (err) {
423
+ const e = err as Error;
424
+ return NextResponse.json(
425
+ { success: false, error: e.message, errorType: e.constructor.name },
426
+ { status: 500 }
427
+ );
428
+ }
429
+ }
430
+ ```
431
+
432
+ - [ ] **Step 2: Verify TypeScript**
433
+
434
+ ```bash
435
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files/test-app
436
+ npx tsc --noEmit 2>&1 | grep "app-file-server"
437
+ ```
438
+
439
+ Expected: no output (no errors in this file).
440
+
441
+ - [ ] **Step 3: Commit**
442
+
443
+ ```bash
444
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files
445
+ git add test-app/app/api/providers/app-file-server/route.ts
446
+ git commit -m "feat(test-app): AppFileServerProvider API route"
447
+ ```
448
+
449
+ ---
450
+
451
+ ## Task 4: Signed-URL serve route
452
+
453
+ **Files:**
454
+ - Create: `test-app/app/api/files/serve/[token]/[...path]/route.ts`
455
+
456
+ Verifies the HMAC token, reads the file, returns it with a sniffed Content-Type. Uses `getMimeType` already exported from `hazo_files/server` — no new dependencies needed.
457
+
458
+ - [ ] **Step 1: Create the nested route directories**
459
+
460
+ ```bash
461
+ mkdir -p "/Users/pubs/Local/01.code/00.lib/hazo_files/test-app/app/api/files/serve/[token]/[...path]"
462
+ ```
463
+
464
+ - [ ] **Step 2: Create the route file**
465
+
466
+ Create `test-app/app/api/files/serve/[token]/[...path]/route.ts`:
467
+
468
+ ```ts
469
+ import { NextRequest, NextResponse } from 'next/server';
470
+ import path from 'node:path';
471
+ import { AppFileServerProvider, getMimeType } from 'hazo_files/server';
472
+
473
+ function makeProvider() {
474
+ return new AppFileServerProvider({
475
+ root: path.join(process.cwd(), 'files/provider-test'),
476
+ hmac_secret: process.env.APP_FILE_SERVER_HMAC_SECRET ?? 'dev-hmac-secret',
477
+ });
478
+ }
479
+
480
+ export async function GET(
481
+ _request: NextRequest,
482
+ { params }: { params: Promise<{ token: string; path: string[] }> }
483
+ ) {
484
+ const { token, path: pathSegments } = await params;
485
+ const virtualPath = pathSegments.join('/');
486
+
487
+ const provider = makeProvider();
488
+
489
+ if (!provider.verifySignedUrl(token, virtualPath)) {
490
+ return NextResponse.json({ error: 'invalid_or_expired' }, { status: 401 });
491
+ }
492
+
493
+ let buf: Buffer;
494
+ try {
495
+ buf = (await provider.get(virtualPath)) as Buffer;
496
+ } catch {
497
+ return NextResponse.json({ error: 'not_found' }, { status: 404 });
498
+ }
499
+
500
+ const contentType = getMimeType(virtualPath) || 'application/octet-stream';
501
+ const filename = path.basename(virtualPath);
502
+
503
+ return new NextResponse(buf, {
504
+ headers: {
505
+ 'Content-Type': contentType,
506
+ 'Content-Disposition': `inline; filename="${filename}"`,
507
+ 'Cache-Control': 'private, no-store',
508
+ },
509
+ });
510
+ }
511
+ ```
512
+
513
+ - [ ] **Step 3: Verify TypeScript**
514
+
515
+ ```bash
516
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files/test-app
517
+ npx tsc --noEmit 2>&1 | grep "serve"
518
+ ```
519
+
520
+ Expected: no output.
521
+
522
+ - [ ] **Step 4: Commit**
523
+
524
+ ```bash
525
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files
526
+ git add "test-app/app/api/files/serve"
527
+ git commit -m "feat(test-app): HMAC-verified file serve route"
528
+ ```
529
+
530
+ ---
531
+
532
+ ## Task 5: AppFileServerProvider page
533
+
534
+ **Files:**
535
+ - Create: `test-app/app/providers/app-file-server/page.tsx`
536
+
537
+ Eight scenario cards following the exact pattern of `app/lifecycle/page.tsx`. Scenario 5 (Signed URL) renders an extra `<a>` link in its result panel so the user can click through to the served file.
538
+
539
+ - [ ] **Step 1: Create the directory and page**
540
+
541
+ ```bash
542
+ mkdir -p /Users/pubs/Local/01.code/00.lib/hazo_files/test-app/app/providers/app-file-server
543
+ ```
544
+
545
+ Create `test-app/app/providers/app-file-server/page.tsx`:
546
+
547
+ ```tsx
548
+ 'use client';
549
+
550
+ import React, { useState } from 'react';
551
+ import { Server, CheckCircle, XCircle } from 'lucide-react';
552
+ import { Button } from '@/components/ui/button';
553
+
554
+ const FILE_PATH = 'provider-test/test.txt';
555
+ const FILE_CONTENT = 'Hello AppFileServer';
556
+ const API = '/api/providers/app-file-server';
557
+
558
+ interface ScenarioResult {
559
+ label: string;
560
+ success: boolean;
561
+ data?: unknown;
562
+ error?: string;
563
+ errorType?: string;
564
+ signedUrl?: string;
565
+ }
566
+
567
+ function ResultPanel({ result }: { result: ScenarioResult | null }) {
568
+ if (!result) return null;
569
+ return (
570
+ <div
571
+ className={`mt-3 p-3 rounded-md text-sm border ${
572
+ result.success
573
+ ? 'bg-green-50 border-green-200 text-green-900'
574
+ : 'bg-red-50 border-red-200 text-red-900'
575
+ }`}
576
+ >
577
+ <div className="flex items-center gap-2 font-medium mb-1">
578
+ {result.success ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
579
+ {result.label}
580
+ </div>
581
+ {result.error && (
582
+ <div className="text-red-700">Error ({result.errorType}): {result.error}</div>
583
+ )}
584
+ {result.data !== undefined && (
585
+ <pre className="text-xs mt-1 overflow-auto whitespace-pre-wrap">
586
+ {JSON.stringify(result.data, null, 2)}
587
+ </pre>
588
+ )}
589
+ {result.signedUrl && (
590
+ <div className="mt-2">
591
+ <a
592
+ href={result.signedUrl}
593
+ target="_blank"
594
+ rel="noopener noreferrer"
595
+ className="text-blue-700 underline text-xs break-all"
596
+ >
597
+ Open signed URL →
598
+ </a>
599
+ </div>
600
+ )}
601
+ </div>
602
+ );
603
+ }
604
+
605
+ function ScenarioCard({
606
+ title,
607
+ description,
608
+ onRun,
609
+ result,
610
+ loading,
611
+ }: {
612
+ title: string;
613
+ description: string;
614
+ onRun: () => void;
615
+ result: ScenarioResult | null;
616
+ loading: boolean;
617
+ }) {
618
+ return (
619
+ <div className="border rounded-lg p-4 bg-white shadow-sm">
620
+ <div className="flex items-start justify-between gap-4">
621
+ <div className="flex-1">
622
+ <h3 className="font-semibold text-gray-900">{title}</h3>
623
+ <p className="text-sm text-gray-600 mt-0.5">{description}</p>
624
+ </div>
625
+ <Button onClick={onRun} disabled={loading} size="sm" variant="outline">
626
+ {loading ? 'Running…' : 'Run'}
627
+ </Button>
628
+ </div>
629
+ <ResultPanel result={result} />
630
+ </div>
631
+ );
632
+ }
633
+
634
+ async function call(method: 'GET' | 'POST', params: Record<string, string>, body?: object) {
635
+ if (method === 'GET') {
636
+ const qs = new URLSearchParams(params).toString();
637
+ const res = await fetch(`${API}?${qs}`);
638
+ return res.json();
639
+ }
640
+ const res = await fetch(API, {
641
+ method: 'POST',
642
+ headers: { 'Content-Type': 'application/json' },
643
+ body: JSON.stringify(body),
644
+ });
645
+ return res.json();
646
+ }
647
+
648
+ export default function AppFileServerPage() {
649
+ const [results, setResults] = useState<Record<number, ScenarioResult | null>>({});
650
+ const [loading, setLoading] = useState<Record<number, boolean>>({});
651
+
652
+ function setResult(index: number, result: ScenarioResult) {
653
+ setResults((prev) => ({ ...prev, [index]: result }));
654
+ }
655
+
656
+ async function run(index: number, fn: () => Promise<ScenarioResult>) {
657
+ setLoading((prev) => ({ ...prev, [index]: true }));
658
+ try {
659
+ const result = await fn();
660
+ setResult(index, result);
661
+ } finally {
662
+ setLoading((prev) => ({ ...prev, [index]: false }));
663
+ }
664
+ }
665
+
666
+ const scenarios: { title: string; description: string; fn: () => Promise<ScenarioResult> }[] = [
667
+ {
668
+ title: '1. Probe',
669
+ description: 'Checks the provider root directory is writable. Expects { ok: true }.',
670
+ fn: async () => {
671
+ const json = await call('GET', { action: 'probe' });
672
+ return { label: 'probe()', success: json.success, data: json.data, error: json.error, errorType: json.errorType };
673
+ },
674
+ },
675
+ {
676
+ title: '2. Put',
677
+ description: `Writes "${FILE_CONTENT}" to ${FILE_PATH}. Expects provider=app_file_server, size=19.`,
678
+ fn: async () => {
679
+ const json = await call('POST', {}, { action: 'put', path: FILE_PATH, content: FILE_CONTENT });
680
+ return { label: 'put()', success: json.success, data: json.data, error: json.error, errorType: json.errorType };
681
+ },
682
+ },
683
+ {
684
+ title: '3. Exists (true)',
685
+ description: `Checks ${FILE_PATH} exists after put. Expects { exists: true }.`,
686
+ fn: async () => {
687
+ const json = await call('GET', { action: 'exists', path: FILE_PATH });
688
+ return { label: 'exists() → true', success: json.success && json.data?.exists === true, data: json.data, error: json.error, errorType: json.errorType };
689
+ },
690
+ },
691
+ {
692
+ title: '4. Get',
693
+ description: `Reads ${FILE_PATH} back. Expects content="${FILE_CONTENT}".`,
694
+ fn: async () => {
695
+ const json = await call('GET', { action: 'get', path: FILE_PATH });
696
+ const correct = json.data?.content === FILE_CONTENT;
697
+ return { label: 'get()', success: json.success && correct, data: json.data, error: json.error, errorType: json.errorType };
698
+ },
699
+ },
700
+ {
701
+ title: '5. Signed URL',
702
+ description: `Generates a 60-second HMAC-signed URL for ${FILE_PATH}. Click the link in the result to open the raw file.`,
703
+ fn: async () => {
704
+ const json = await call('GET', { action: 'sign', path: FILE_PATH, ttl: '60' });
705
+ return {
706
+ label: 'getSignedUrl()',
707
+ success: json.success,
708
+ data: json.data,
709
+ error: json.error,
710
+ errorType: json.errorType,
711
+ signedUrl: json.data?.url,
712
+ };
713
+ },
714
+ },
715
+ {
716
+ title: '6. ifNotExists collision',
717
+ description: `Tries to put to ${FILE_PATH} again with ifNotExists=true. Expects an error matching /exists/.`,
718
+ fn: async () => {
719
+ const json = await call('POST', {}, { action: 'put', path: FILE_PATH, content: 'y', ifNotExists: true });
720
+ const isCollision = !json.success && /exists/i.test(json.error ?? '');
721
+ return {
722
+ label: 'put() ifNotExists collision',
723
+ success: isCollision,
724
+ data: isCollision ? { expected_error: json.error } : json.data,
725
+ error: isCollision ? undefined : `Expected collision error, got: ${json.error ?? 'success'}`,
726
+ errorType: json.errorType,
727
+ };
728
+ },
729
+ },
730
+ {
731
+ title: '7. Delete',
732
+ description: `Deletes ${FILE_PATH}. Expects { deleted: true }.`,
733
+ fn: async () => {
734
+ const json = await call('POST', {}, { action: 'delete', path: FILE_PATH });
735
+ return { label: 'delete()', success: json.success, data: json.data, error: json.error, errorType: json.errorType };
736
+ },
737
+ },
738
+ {
739
+ title: '8. Exists (false)',
740
+ description: `Confirms ${FILE_PATH} is gone after delete. Expects { exists: false }.`,
741
+ fn: async () => {
742
+ const json = await call('GET', { action: 'exists', path: FILE_PATH });
743
+ return { label: 'exists() → false', success: json.success && json.data?.exists === false, data: json.data, error: json.error, errorType: json.errorType };
744
+ },
745
+ },
746
+ ];
747
+
748
+ return (
749
+ <div className="p-8 max-w-3xl mx-auto">
750
+ <div className="flex items-center gap-3 mb-2">
751
+ <Server className="h-7 w-7 text-primary" />
752
+ <h1 className="text-2xl font-bold">App File Server Provider</h1>
753
+ </div>
754
+ <p className="text-muted-foreground mb-6 text-sm">
755
+ Exercises the <code className="bg-muted px-1 rounded text-xs">AppFileServerProvider</code> v2 interface end-to-end.
756
+ Run scenarios 1–8 in order — they share the test file path{' '}
757
+ <code className="bg-muted px-1 rounded text-xs">{FILE_PATH}</code>.
758
+ </p>
759
+
760
+ <div className="space-y-3">
761
+ {scenarios.map((s, i) => (
762
+ <ScenarioCard
763
+ key={i}
764
+ title={s.title}
765
+ description={s.description}
766
+ onRun={() => run(i, s.fn)}
767
+ result={results[i] ?? null}
768
+ loading={loading[i] ?? false}
769
+ />
770
+ ))}
771
+ </div>
772
+ </div>
773
+ );
774
+ }
775
+ ```
776
+
777
+ - [ ] **Step 2: Verify TypeScript**
778
+
779
+ ```bash
780
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files/test-app
781
+ npx tsc --noEmit 2>&1 | grep "app-file-server"
782
+ ```
783
+
784
+ Expected: no output.
785
+
786
+ - [ ] **Step 3: Commit**
787
+
788
+ ```bash
789
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files
790
+ git add test-app/app/providers/app-file-server/page.tsx
791
+ git commit -m "feat(test-app): AppFileServerProvider scenario page"
792
+ ```
793
+
794
+ ---
795
+
796
+ ## Task 6: Stub pages for InMemoryProvider and GoogleDriveProvider
797
+
798
+ **Files:**
799
+ - Create: `test-app/app/providers/in-memory/page.tsx`
800
+ - Create: `test-app/app/providers/gdrive/page.tsx`
801
+
802
+ Static pages — no API calls. Display the `FileStorageProvider` interface contract plus a "coming soon" badge.
803
+
804
+ - [ ] **Step 1: Create stub pages**
805
+
806
+ ```bash
807
+ mkdir -p /Users/pubs/Local/01.code/00.lib/hazo_files/test-app/app/providers/in-memory
808
+ mkdir -p /Users/pubs/Local/01.code/00.lib/hazo_files/test-app/app/providers/gdrive
809
+ ```
810
+
811
+ Create `test-app/app/providers/in-memory/page.tsx`:
812
+
813
+ ```tsx
814
+ import React from 'react';
815
+ import { MemoryStick } from 'lucide-react';
816
+
817
+ const INTERFACE = `interface FileStorageProvider {
818
+ put(path, body, opts?): Promise<PutResult>
819
+ get(path): Promise<Buffer | Readable>
820
+ delete(path): Promise<void>
821
+ exists(path): Promise<boolean>
822
+ getSignedUrl(path, opts?): Promise<string>
823
+ probe(): Promise<ProbeResult>
824
+ }`;
825
+
826
+ export default function InMemoryPage() {
827
+ return (
828
+ <div className="p-8 max-w-2xl mx-auto">
829
+ <div className="flex items-center gap-3 mb-2">
830
+ <MemoryStick className="h-7 w-7 text-primary" />
831
+ <h1 className="text-2xl font-bold">InMemoryProvider</h1>
832
+ <span className="ml-2 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-medium">
833
+ Coming soon — Task 4.4
834
+ </span>
835
+ </div>
836
+ <p className="text-muted-foreground text-sm mb-6">
837
+ An in-memory implementation of <code className="bg-muted px-1 rounded text-xs">FileStorageProvider</code> for
838
+ use in tests and CI. Registered behind the <code className="bg-muted px-1 rounded text-xs">STORAGE_TEST_MODE=1</code> environment variable.
839
+ </p>
840
+ <div className="border rounded-lg p-4 bg-muted/30">
841
+ <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
842
+ FileStorageProvider interface
843
+ </p>
844
+ <pre className="text-xs font-mono text-foreground whitespace-pre">{INTERFACE}</pre>
845
+ </div>
846
+ </div>
847
+ );
848
+ }
849
+ ```
850
+
851
+ Create `test-app/app/providers/gdrive/page.tsx`:
852
+
853
+ ```tsx
854
+ import React from 'react';
855
+ import { Cloud } from 'lucide-react';
856
+
857
+ const INTERFACE = `interface FileStorageProvider {
858
+ put(path, body, opts?): Promise<PutResult>
859
+ get(path): Promise<Buffer | Readable>
860
+ delete(path): Promise<void>
861
+ exists(path): Promise<boolean>
862
+ getSignedUrl(path, opts?): Promise<string>
863
+ probe(): Promise<ProbeResult>
864
+ }`;
865
+
866
+ export default function GDriveProviderPage() {
867
+ return (
868
+ <div className="p-8 max-w-2xl mx-auto">
869
+ <div className="flex items-center gap-3 mb-2">
870
+ <Cloud className="h-7 w-7 text-primary" />
871
+ <h1 className="text-2xl font-bold">GoogleDriveProvider</h1>
872
+ <span className="ml-2 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-medium">
873
+ Coming soon
874
+ </span>
875
+ </div>
876
+ <p className="text-muted-foreground text-sm mb-6">
877
+ Service-account based Google Drive (Workspace + Shared Drive) implementation of{' '}
878
+ <code className="bg-muted px-1 rounded text-xs">FileStorageProvider</code>. Requires{' '}
879
+ <code className="bg-muted px-1 rounded text-xs">GOOGLE_SERVICE_ACCOUNT_JSON</code> (base64 JSON keyfile).
880
+ </p>
881
+ <div className="border rounded-lg p-4 bg-muted/30">
882
+ <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
883
+ FileStorageProvider interface
884
+ </p>
885
+ <pre className="text-xs font-mono text-foreground whitespace-pre">{INTERFACE}</pre>
886
+ </div>
887
+ </div>
888
+ );
889
+ }
890
+ ```
891
+
892
+ - [ ] **Step 2: Verify TypeScript**
893
+
894
+ ```bash
895
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files/test-app
896
+ npx tsc --noEmit 2>&1 | grep "providers"
897
+ ```
898
+
899
+ Expected: no output.
900
+
901
+ - [ ] **Step 3: Commit**
902
+
903
+ ```bash
904
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files
905
+ git add test-app/app/providers/
906
+ git commit -m "feat(test-app): stub pages for InMemoryProvider and GoogleDriveProvider"
907
+ ```
908
+
909
+ ---
910
+
911
+ ## Task 7: Smoke test in the browser
912
+
913
+ No automated tests in the test-app. Verify manually.
914
+
915
+ - [ ] **Step 1: Start the dev server**
916
+
917
+ ```bash
918
+ cd /Users/pubs/Local/01.code/00.lib/hazo_files/test-app
919
+ npm run dev
920
+ ```
921
+
922
+ Expected: server starts on `http://localhost:3000`.
923
+
924
+ - [ ] **Step 2: Check sidebar**
925
+
926
+ Open `http://localhost:3000`. Confirm:
927
+ - Sidebar shows `Lifecycle` then a separator with label "v2 Providers"
928
+ - Three new entries below: `App File Server`, `In-Memory`, `GDrive Provider`
929
+ - Collapsed sidebar shows icons with tooltips for all three
930
+
931
+ - [ ] **Step 3: Run all 8 scenarios on the AppFileServerProvider page**
932
+
933
+ Navigate to `http://localhost:3000/providers/app-file-server`. Click Run on each scenario in order 1→8.
934
+
935
+ | # | Expected |
936
+ |---|---|
937
+ | 1 Probe | Green — `{ ok: true }` |
938
+ | 2 Put | Green — `{ provider: "app_file_server", native_id: "provider-test/test.txt", size: 19 }` |
939
+ | 3 Exists (true) | Green — `{ exists: true }` |
940
+ | 4 Get | Green — `{ content: "Hello AppFileServer" }` |
941
+ | 5 Signed URL | Green — URL shown + clickable link opens raw text in new tab |
942
+ | 6 ifNotExists | Green — shows `expected_error: "File exists: ..."` |
943
+ | 7 Delete | Green — `{ deleted: true }` |
944
+ | 8 Exists (false) | Green — `{ exists: false }` |
945
+
946
+ - [ ] **Step 4: Verify stub pages render**
947
+
948
+ - `http://localhost:3000/providers/in-memory` — shows MemoryStick icon, "Coming soon — Task 4.4" badge, interface block.
949
+ - `http://localhost:3000/providers/gdrive` — shows Cloud icon, "Coming soon" badge, interface block.
950
+
951
+ ---
952
+
953
+ ## Self-review
954
+
955
+ **Spec coverage:**
956
+ - ✅ 3 sidebar entries with v2 Providers section label
957
+ - ✅ AppFileServerProvider page with 8 scenario cards (probe, put, exists×2, get, sign, ifNotExists, delete)
958
+ - ✅ Signed URL renders as clickable anchor
959
+ - ✅ `/api/files/serve/[token]/[...path]` route: token verify → 401, file read → 404, serve with Content-Type
960
+ - ✅ Stub pages with interface contract + coming-soon badge
961
+ - ✅ `AppFileServerProvider` exported from `hazo_files/server` (Task 1 prerequisite)
962
+
963
+ **Placeholder scan:** None found.
964
+
965
+ **Type consistency:**
966
+ - `ScenarioResult.signedUrl?: string` — added in Task 5 `ResultPanel`, used only in scenario 5.
967
+ - `makeProvider()` helper defined identically in Task 3 route and Task 4 serve route — consistent `root` and `hmac_secret` config.
968
+ - `getMimeType(virtualPath)` from `hazo_files/server` — verified exported in Task 1.