hazo_files 1.6.0 → 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.
|