handoff-app 2.0.0-12 → 2.0.0-13
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/{Changelog.md → CHANGELOG.md} +84 -0
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +760 -801
- package/RECIPES.md +975 -0
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/define.d.ts +1 -1
- package/dist/ai/define.js +1 -1
- package/dist/ai/types.d.ts +1 -1
- package/dist/ai/types.js +1 -1
- package/dist/app-builder/build.js +14 -14
- package/dist/app-builder/client-config.js +1 -1
- package/dist/config/loader.js +1 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/providers.d.ts +10 -0
- package/dist/{define.js → providers.js} +4 -4
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +8 -11
- package/dist/registry/asset-storage/resolve.js +12 -13
- package/dist/registry/asset-storage/types.d.ts +12 -12
- package/dist/registry/asset-storage/types.js +2 -2
- package/dist/registry/db/schema.d.ts +2 -2
- package/dist/registry/db/schema.js +2 -2
- package/dist/registry/email/define.d.ts +1 -1
- package/dist/registry/email/define.js +1 -1
- package/dist/registry/email/resolve.d.ts +7 -8
- package/dist/registry/email/resolve.js +21 -23
- package/dist/registry/email/types.d.ts +1 -1
- package/dist/registry/email/types.js +1 -1
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/types/config.d.ts +4 -13
- package/docs/cli.md +275 -54
- package/package.json +7 -7
- package/src/ai/define.ts +1 -1
- package/src/ai/types.ts +1 -1
- package/src/app/components/Auth/AccountLayout.tsx +1 -1
- package/src/app/components/Auth/AuthShell.tsx +10 -4
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +0 -2
- package/src/app/components/util/index.ts +0 -11
- package/src/app/lib/asset-storage.ts +25 -25
- package/src/app/lib/auth/email.ts +4 -5
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +8 -8
- package/src/app/lib/registry-api/asset-transfer.ts +25 -25
- package/src/app/lib/server-module.ts +1 -1
- package/src/app/lib/utils.ts +7 -0
- package/src/app/next.config.mjs +2 -2
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/account/ai.tsx +1 -1
- package/src/app/pages/account/index.tsx +1 -1
- package/src/app/pages/account/tokens.tsx +1 -1
- package/src/app/pages/account/users.tsx +1 -1
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +1 -1
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/login.tsx +10 -5
- package/src/app/pages/reset-password.tsx +7 -2
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/build.ts +10 -10
- package/src/app-builder/client-config.ts +1 -1
- package/src/config/loader.ts +1 -2
- package/src/index.ts +6 -6
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/{define.ts → providers.ts} +8 -8
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +16 -20
- package/src/registry/asset-storage/types.ts +12 -12
- package/src/registry/db/schema.ts +3 -3
- package/src/registry/email/define.ts +1 -1
- package/src/registry/email/resolve.ts +23 -23
- package/src/registry/email/types.ts +1 -1
- package/src/store/registry.ts +7 -7
- package/src/types/config.ts +4 -14
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/define.d.ts +0 -10
- package/dist/registry/email/adapters/resend.d.ts +0 -7
- package/dist/registry/email/adapters/resend.js +0 -35
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/registry/email/adapters/resend.ts +0 -22
- /package/dist/registry/email/{adapters → providers}/smtp.d.ts +0 -0
- /package/dist/registry/email/{adapters → providers}/smtp.js +0 -0
- /package/src/registry/email/{adapters → providers}/smtp.ts +0 -0
|
@@ -134,18 +134,13 @@ type EndpointStatus = 'checking' | 'live' | 'unreachable';
|
|
|
134
134
|
let cachedStatus: EndpointStatus | null = null;
|
|
135
135
|
let inFlight: Promise<EndpointStatus> | null = null;
|
|
136
136
|
|
|
137
|
-
/**
|
|
138
|
-
* Statuses that prove the route is being served. A GET is the cheapest question to ask: the handler
|
|
139
|
-
* answers 405 without ever building an MCP server, and a registry answers 401 ahead of that because
|
|
140
|
-
* the credential check runs first. Anything else, 404 and 5xx included, means it is not there.
|
|
141
|
-
*/
|
|
142
|
-
const LIVE_STATUSES = new Set([200, 401, 403, 405]);
|
|
143
|
-
|
|
144
137
|
const probeEndpoint = (url: string): Promise<EndpointStatus> => {
|
|
145
138
|
if (cachedStatus) return Promise.resolve(cachedStatus);
|
|
146
139
|
if (inFlight) return inFlight;
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
// The endpoint answers HEAD with 204 before the credential check. Thus a served route never logs an
|
|
141
|
+
// error status to the console.
|
|
142
|
+
inFlight = fetch(url, { method: 'HEAD' })
|
|
143
|
+
.then((res): EndpointStatus => (res.ok ? 'live' : 'unreachable'))
|
|
149
144
|
.catch((): EndpointStatus => 'unreachable')
|
|
150
145
|
.then((status) => {
|
|
151
146
|
cachedStatus = status;
|
|
@@ -184,29 +179,19 @@ const StatusDot: React.FC<{ status: EndpointStatus; className?: string }> = ({ s
|
|
|
184
179
|
<span role="status" aria-label={DOT[status].label} title={DOT[status].label} className={cn('size-1.5 rounded-full', DOT[status].className, className)} />
|
|
185
180
|
);
|
|
186
181
|
|
|
187
|
-
const Snippet: React.FC<{ label: string; value: string
|
|
182
|
+
const Snippet: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
|
188
183
|
<div className="min-w-0 space-y-1.5">
|
|
189
184
|
<div className="flex items-center justify-between gap-2">
|
|
190
185
|
<code className="truncate rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{label}</code>
|
|
191
186
|
<CopyToClipboard value={value} tooltip={`Copy ${label}`} showTooltip={false} className="relative size-7 shrink-0" />
|
|
192
187
|
</div>
|
|
193
|
-
<pre
|
|
194
|
-
className={cn(
|
|
195
|
-
'overflow-auto rounded-md border bg-muted/50 p-3 leading-relaxed',
|
|
196
|
-
compact ? 'max-h-48 text-[10px]' : 'max-h-64 text-xs'
|
|
197
|
-
)}
|
|
198
|
-
>
|
|
188
|
+
<pre className="max-h-64 overflow-auto rounded-md border bg-muted/50 p-3 text-xs leading-relaxed">
|
|
199
189
|
<code>{value}</code>
|
|
200
190
|
</pre>
|
|
201
191
|
</div>
|
|
202
192
|
);
|
|
203
193
|
|
|
204
|
-
const ClientConfig: React.FC<{ client: McpClient; name: string; target: McpTarget
|
|
205
|
-
client,
|
|
206
|
-
name,
|
|
207
|
-
target,
|
|
208
|
-
compact,
|
|
209
|
-
}) => {
|
|
194
|
+
const ClientConfig: React.FC<{ client: McpClient; name: string; target: McpTarget }> = ({ client, name, target }) => {
|
|
210
195
|
// A one-click install would write the `<your-token>` placeholder verbatim and register a server
|
|
211
196
|
// that cannot connect, so a registry gets the snippet and the token pointer instead.
|
|
212
197
|
const deeplink = isRegistryRuntime ? undefined : client.deeplink?.(name, target);
|
|
@@ -222,8 +207,8 @@ const ClientConfig: React.FC<{ client: McpClient; name: string; target: McpTarge
|
|
|
222
207
|
</a>
|
|
223
208
|
</Button>
|
|
224
209
|
)}
|
|
225
|
-
{command && <Snippet label="command line" value={command}
|
|
226
|
-
<Snippet label={client.configPath} value={JSON.stringify(client.file(name, target), null, 2)}
|
|
210
|
+
{command && <Snippet label="command line" value={command} />}
|
|
211
|
+
<Snippet label={client.configPath} value={JSON.stringify(client.file(name, target), null, 2)} />
|
|
227
212
|
<p className="text-xs text-muted-foreground">{client.note}</p>
|
|
228
213
|
</div>
|
|
229
214
|
);
|
|
@@ -261,8 +246,8 @@ const Credential: React.FC = () => (
|
|
|
261
246
|
</p>
|
|
262
247
|
);
|
|
263
248
|
|
|
264
|
-
/** Endpoint, per-client config and credential guidance.
|
|
265
|
-
const ConnectionDetails: React.FC<{ url: string; status: EndpointStatus
|
|
249
|
+
/** Endpoint, per-client config and credential guidance. */
|
|
250
|
+
const ConnectionDetails: React.FC<{ url: string; status: EndpointStatus }> = ({ url, status }) => {
|
|
266
251
|
const name = useServerName();
|
|
267
252
|
const target: McpTarget = { url, headers: isRegistryRuntime ? CREDENTIAL_HEADERS : undefined };
|
|
268
253
|
|
|
@@ -279,7 +264,7 @@ const ConnectionDetails: React.FC<{ url: string; status: EndpointStatus; compact
|
|
|
279
264
|
</TabsList>
|
|
280
265
|
{CLIENTS.map((client) => (
|
|
281
266
|
<TabsContent key={client.id} value={client.id} className="min-w-0">
|
|
282
|
-
<ClientConfig client={client} name={name} target={target}
|
|
267
|
+
<ClientConfig client={client} name={name} target={target} />
|
|
283
268
|
</TabsContent>
|
|
284
269
|
))}
|
|
285
270
|
</Tabs>
|
|
@@ -323,27 +308,3 @@ const McpDialog: React.FC = () => {
|
|
|
323
308
|
</Dialog>
|
|
324
309
|
);
|
|
325
310
|
};
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Inline variant for the mobile nav, which is itself a sheet, so a nested dialog would sit on top of
|
|
329
|
-
* it. It owns its divider, so a build without the endpoint renders nothing and leaves no stray rule.
|
|
330
|
-
*/
|
|
331
|
-
export function McpConfigMobile() {
|
|
332
|
-
return isAvailable ? <McpMobile /> : null;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const McpMobile: React.FC = () => {
|
|
336
|
-
const url = useMcpEndpoint();
|
|
337
|
-
const status = useEndpointStatus(url);
|
|
338
|
-
|
|
339
|
-
return (
|
|
340
|
-
<div className="mt-4 space-y-3 border-t pt-4">
|
|
341
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
342
|
-
<McpIcon className="h-4 w-4" />
|
|
343
|
-
<span className="text-sm font-medium">Connect a coding agent</span>
|
|
344
|
-
<RuntimeModeBadge mode={mode} />
|
|
345
|
-
</div>
|
|
346
|
-
<ConnectionDetails url={url} status={status} compact />
|
|
347
|
-
</div>
|
|
348
|
-
);
|
|
349
|
-
};
|
|
@@ -4,7 +4,6 @@ import { useTheme } from 'next-themes';
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { useRouter } from 'next/router';
|
|
6
6
|
import { AiAssistantMobileTrigger } from '../../components/AiAssistant/AiAssistantProvider';
|
|
7
|
-
import { McpConfigMobile } from '../../components/McpIntegration/McpConfigDialog';
|
|
8
7
|
import { Button } from '../../components/ui/button';
|
|
9
8
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../components/ui/sheet';
|
|
10
9
|
import { cn } from '../../lib/utils';
|
|
@@ -72,7 +71,6 @@ export function MobileNav() {
|
|
|
72
71
|
</Link>
|
|
73
72
|
);
|
|
74
73
|
})}
|
|
75
|
-
<McpConfigMobile />
|
|
76
74
|
<div className="mt-4 border-t pt-4">
|
|
77
75
|
<Button variant="ghost" className="w-full justify-start font-normal" onClick={toggleTheme}>
|
|
78
76
|
{theme === 'light' && (
|
|
@@ -557,17 +557,6 @@ export const fetchDocPageMetadataAndContent = (localPath: string, slug: string |
|
|
|
557
557
|
*/
|
|
558
558
|
export const filterOutUndefined = <T>(value: T): value is NonNullable<T> => value !== undefined;
|
|
559
559
|
|
|
560
|
-
/**
|
|
561
|
-
* Create a title string from a prefix
|
|
562
|
-
* @param prefix
|
|
563
|
-
* @returns
|
|
564
|
-
*/
|
|
565
|
-
export const titleString = (prefix: string | null): string => {
|
|
566
|
-
const config = getClientRuntimeConfig();
|
|
567
|
-
const prepend = prefix ? `${prefix} | ` : '';
|
|
568
|
-
return `${prepend}${config?.app?.client} Design System`;
|
|
569
|
-
};
|
|
570
|
-
|
|
571
560
|
/**
|
|
572
561
|
* Get the tokens for a component
|
|
573
562
|
* @param component
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-only asset storage runtime. Resolves the active
|
|
3
|
-
* config and lazily builds and memoizes it. Writes use the active
|
|
4
|
-
* blob's recorded provider, so `database` blobs (inline bytea) stay readable when a `custom`
|
|
5
|
-
* is active. `database` has no
|
|
2
|
+
* Server-only asset storage runtime. Resolves the active provider from the baked server runtime
|
|
3
|
+
* config and lazily builds and memoizes it. Writes use the active provider. Reads resolve by each
|
|
4
|
+
* blob's recorded provider, so `database` blobs (inline bytea) stay readable when a `custom` provider
|
|
5
|
+
* is active. `database` has no module: its bytes live directly in `asset_blobs.content`.
|
|
6
6
|
*
|
|
7
7
|
* Imported exclusively by registry API route handlers and the registry store.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { resolveAssetStorageSettings, type ResolvedAssetStorage } from '@handoff/registry/asset-storage/resolve';
|
|
11
|
-
import type {
|
|
11
|
+
import type { StorageProvider } from '@handoff/registry/asset-storage/types';
|
|
12
12
|
import type { AssetStorageProvider } from '@handoff/registry/db/schema';
|
|
13
13
|
import { getServerRuntimeConfig } from './docs-api/runtime-config';
|
|
14
14
|
import { loadServerModule } from './server-module';
|
|
15
15
|
|
|
16
16
|
let activeCache: ResolvedAssetStorage | null = null;
|
|
17
|
-
const
|
|
17
|
+
const providerCache = new Map<string, StorageProvider | null>();
|
|
18
18
|
|
|
19
19
|
/** The active, fully-resolved asset storage settings for new uploads. */
|
|
20
20
|
export const getActiveAssetStorage = (): ResolvedAssetStorage => {
|
|
@@ -32,49 +32,49 @@ export class AssetStorageError extends Error {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Load a custom
|
|
36
|
-
const
|
|
35
|
+
/** Load a custom provider module and coerce its default export (provider object or factory) to a provider. */
|
|
36
|
+
const loadCustomProvider = async (active: ResolvedAssetStorage): Promise<StorageProvider> => {
|
|
37
37
|
if (!active.module) {
|
|
38
|
-
throw new AssetStorageError('A custom asset storage
|
|
38
|
+
throw new AssetStorageError('A custom asset storage provider is selected but no module path is configured.');
|
|
39
39
|
}
|
|
40
|
-
const candidate = (await loadServerModule(active.module, { options: active.options })) as Partial<
|
|
40
|
+
const candidate = (await loadServerModule(active.module, { options: active.options })) as Partial<StorageProvider> | null;
|
|
41
41
|
if (!candidate || typeof candidate.put !== 'function' || typeof candidate.get !== 'function' || typeof candidate.delete !== 'function') {
|
|
42
|
-
throw new AssetStorageError(`Custom asset storage module "${active.module}" must default-export a
|
|
42
|
+
throw new AssetStorageError(`Custom asset storage module "${active.module}" must default-export a defineStorageProvider() result.`);
|
|
43
43
|
}
|
|
44
|
-
return candidate as
|
|
44
|
+
return candidate as StorageProvider;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Resolve the {@link
|
|
49
|
-
* inline `database` default (there is no
|
|
48
|
+
* Resolve the {@link StorageProvider} for a recorded provider, or `null` when the provider is the
|
|
49
|
+
* inline `database` default (there is no module; content lives in the DB row). Reading a blob whose
|
|
50
50
|
* provider is no longer configured throws an actionable error rather than silently failing.
|
|
51
51
|
*/
|
|
52
|
-
export const
|
|
52
|
+
export const getStorageProvider = async (provider: AssetStorageProvider): Promise<StorageProvider | null> => {
|
|
53
53
|
if (provider === 'database') {
|
|
54
54
|
return null;
|
|
55
55
|
}
|
|
56
|
-
if (
|
|
57
|
-
return
|
|
56
|
+
if (providerCache.has(provider)) {
|
|
57
|
+
return providerCache.get(provider) ?? null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const active = getActiveAssetStorage();
|
|
61
|
-
if (active.
|
|
61
|
+
if (active.provider !== provider) {
|
|
62
62
|
throw new AssetStorageError(
|
|
63
|
-
`No asset storage
|
|
63
|
+
`No asset storage module is configured for provider "${provider}". A blob was stored by that ` +
|
|
64
64
|
'provider but it is no longer selected. Restore its configuration to read it.'
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
|
-
const
|
|
67
|
+
const storage = await loadCustomProvider(active);
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
return
|
|
69
|
+
providerCache.set(provider, storage);
|
|
70
|
+
return storage;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
-
/** The
|
|
74
|
-
export const
|
|
73
|
+
/** The active provider (used for new uploads), or `null` for the inline database default. */
|
|
74
|
+
export const getActiveStorageProvider = (): Promise<StorageProvider | null> => getStorageProvider(getActiveAssetStorage().provider);
|
|
75
75
|
|
|
76
76
|
/** Reset memoized state (test seam only). */
|
|
77
77
|
export const __resetAssetStorageCache = (): void => {
|
|
78
78
|
activeCache = null;
|
|
79
|
-
|
|
79
|
+
providerCache.clear();
|
|
80
80
|
};
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { isEnvReference, resolveEnvReferences } from '@handoff/config/from-env';
|
|
2
|
-
import {
|
|
3
|
-
import { createSmtpProvider } from '@handoff/registry/email/adapters/smtp';
|
|
2
|
+
import { createSmtpProvider } from '@handoff/registry/email/providers/smtp';
|
|
4
3
|
import type { EmailProviderKind, EmailSettings } from '@handoff/registry/email/resolve';
|
|
5
4
|
import type { EmailProvider } from '@handoff/registry/email/types';
|
|
6
5
|
import { getServerRuntimeConfig } from '../docs-api/runtime-config';
|
|
7
6
|
import { loadServerModule } from '../server-module';
|
|
8
7
|
|
|
9
8
|
/** Options a provider needs before email counts as configured. */
|
|
10
|
-
const REQUIRED_OPTIONS: Record<EmailProviderKind, readonly string[]> = {
|
|
9
|
+
const REQUIRED_OPTIONS: Record<EmailProviderKind, readonly string[]> = { smtp: ['host'], custom: [] };
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* The settings that still need a value, named as the administrator sets them: the config key, or the
|
|
@@ -17,6 +16,7 @@ export const missingRegistryEmailSettings = (): string[] => {
|
|
|
17
16
|
const settings = getServerRuntimeConfig().email;
|
|
18
17
|
const options = resolveEnvReferences(settings.options, process.env);
|
|
19
18
|
const missing = settings.from ? [] : ['runtime.registry.email.from'];
|
|
19
|
+
if (!settings.provider) return [...missing, 'runtime.registry.email.provider'];
|
|
20
20
|
if (settings.provider === 'custom' && !settings.module) missing.push('runtime.registry.email.module');
|
|
21
21
|
for (const key of REQUIRED_OPTIONS[settings.provider]) {
|
|
22
22
|
if (options[key] !== undefined) continue;
|
|
@@ -31,7 +31,7 @@ export const registryEmailIsConfigured = (): boolean => missingRegistryEmailSett
|
|
|
31
31
|
/** The provider name for messages shown to an administrator. */
|
|
32
32
|
export const registryEmailProviderName = (): string => {
|
|
33
33
|
const { provider } = getServerRuntimeConfig().email;
|
|
34
|
-
return provider === '
|
|
34
|
+
return provider === 'smtp' ? 'SMTP' : 'the custom email provider';
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
let customProvider: Promise<EmailProvider> | null = null;
|
|
@@ -46,7 +46,6 @@ const loadCustomProvider = async (settings: EmailSettings, options: Record<strin
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
const getEmailProvider = (settings: EmailSettings, options: Record<string, unknown>): Promise<EmailProvider> => {
|
|
49
|
-
if (settings.provider === 'resend') return Promise.resolve(createResendProvider(options));
|
|
50
49
|
if (settings.provider === 'smtp') return Promise.resolve(createSmtpProvider(options));
|
|
51
50
|
customProvider ??= loadCustomProvider(settings, options).catch((error) => {
|
|
52
51
|
customProvider = null;
|
|
@@ -31,7 +31,7 @@ export const documentationMetadata = (source: Record<string, unknown>) => {
|
|
|
31
31
|
return {
|
|
32
32
|
title,
|
|
33
33
|
description,
|
|
34
|
-
metaTitle: value('metaTitle')
|
|
34
|
+
metaTitle: value('metaTitle'),
|
|
35
35
|
metaDescription: value('metaDescription') || description,
|
|
36
36
|
};
|
|
37
37
|
};
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { defaultPageDetail, type BakedDefaultPage } from './page-rendering';
|
|
22
22
|
import { MAX_SEARCH_BODY_LENGTH, MAX_SEARCH_CANDIDATES, truncateSearchBody, type SearchResponse } from './search';
|
|
23
23
|
import { getRegistryConnection } from '../registry-connection';
|
|
24
|
-
import {
|
|
24
|
+
import { getStorageProvider } from '../asset-storage';
|
|
25
25
|
// A static import includes package defaults in each registry serverless bundle. A deployed function
|
|
26
26
|
// cannot read the build machine's `config/docs` directory.
|
|
27
27
|
import defaultPages from '../../generated/default-pages.json';
|
|
@@ -148,8 +148,7 @@ const searchRegistryPages = async (db: RegistryDatabase, request: PageSearchRequ
|
|
|
148
148
|
export const createRegistryDocsBackend = async (): Promise<DocsBackend> => {
|
|
149
149
|
const connection = await getRegistryConnection();
|
|
150
150
|
const { db } = connection;
|
|
151
|
-
|
|
152
|
-
const store = createRegistryStore({ db, resolveAssetAdapter: getAssetStorageAdapter });
|
|
151
|
+
const store = createRegistryStore({ db, resolveStorageProvider: getStorageProvider });
|
|
153
152
|
|
|
154
153
|
return {
|
|
155
154
|
async listComponents() {
|
|
@@ -2,9 +2,9 @@ import fs from 'fs-extra';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import type { RuntimeMode } from '@handoff/types/config';
|
|
4
4
|
import { DEFAULT_DATABASE_URL_ENV, DEFAULT_REGISTRY_DRIVER, type RegistryDatabaseDriver } from '@handoff/registry/db/driver';
|
|
5
|
-
import {
|
|
5
|
+
import { DEFAULT_ASSET_STORAGE_PROVIDER, type AssetStorageSettings } from '@handoff/registry/asset-storage/resolve';
|
|
6
6
|
import { mergeAiConnections, parseAiConnections, type AiSettings } from '@handoff/ai/connections';
|
|
7
|
-
import {
|
|
7
|
+
import { parseEmailSettings, type EmailSettings } from '@handoff/registry/email/resolve';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Server-side runtime resolution for the docs read API.
|
|
@@ -41,7 +41,7 @@ export interface ServerRuntimeConfig {
|
|
|
41
41
|
ai: AiSettings;
|
|
42
42
|
registry: ServerRegistryRuntimeConfig;
|
|
43
43
|
/**
|
|
44
|
-
* Asset storage
|
|
44
|
+
* Asset storage provider, module, and options for registry mode. Options hold `{ $env }` references,
|
|
45
45
|
* never secret values.
|
|
46
46
|
*/
|
|
47
47
|
assetStorage: AssetStorageSettings;
|
|
@@ -60,13 +60,13 @@ const defaults = (): ServerRuntimeConfig => ({
|
|
|
60
60
|
driver: DEFAULT_REGISTRY_DRIVER,
|
|
61
61
|
databaseUrlEnv: DEFAULT_DATABASE_URL_ENV,
|
|
62
62
|
},
|
|
63
|
-
assetStorage: {
|
|
64
|
-
email: {
|
|
63
|
+
assetStorage: { provider: DEFAULT_ASSET_STORAGE_PROVIDER },
|
|
64
|
+
email: { options: {} },
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
/** Parse the baked asset-storage selection from env (names/selectors only; JSON options tolerated). */
|
|
68
68
|
const assetStorageFromEnv = (): AssetStorageSettings => {
|
|
69
|
-
const
|
|
69
|
+
const provider = process.env.HANDOFF_ASSET_STORAGE_PROVIDER?.trim();
|
|
70
70
|
let options: Record<string, unknown> | undefined;
|
|
71
71
|
const rawOptions = process.env.HANDOFF_ASSET_STORAGE_OPTIONS?.trim();
|
|
72
72
|
if (rawOptions) {
|
|
@@ -79,7 +79,7 @@ const assetStorageFromEnv = (): AssetStorageSettings => {
|
|
|
79
79
|
}
|
|
80
80
|
const maxInline = Number(process.env.HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES);
|
|
81
81
|
return {
|
|
82
|
-
|
|
82
|
+
provider: provider === 'custom' ? provider : DEFAULT_ASSET_STORAGE_PROVIDER,
|
|
83
83
|
module: process.env.HANDOFF_ASSET_STORAGE_MODULE?.trim() || undefined,
|
|
84
84
|
maxInlineBytes: Number.isFinite(maxInline) && maxInline > 0 ? maxInline : undefined,
|
|
85
85
|
options,
|
|
@@ -167,7 +167,7 @@ export const getServerRuntimeConfig = (): ServerRuntimeConfig => {
|
|
|
167
167
|
? parsed.registry.databaseUrlEnv.trim()
|
|
168
168
|
: DEFAULT_DATABASE_URL_ENV;
|
|
169
169
|
const assetStorage: AssetStorageSettings =
|
|
170
|
-
parsed?.assetStorage && typeof parsed.assetStorage === 'object' ? parsed.assetStorage : {
|
|
170
|
+
parsed?.assetStorage && typeof parsed.assetStorage === 'object' ? parsed.assetStorage : { provider: DEFAULT_ASSET_STORAGE_PROVIDER };
|
|
171
171
|
cached = {
|
|
172
172
|
mode: parsed?.mode === 'registry' ? 'registry' : 'workspace',
|
|
173
173
|
mcp: parsed?.mcp !== false,
|
|
@@ -6,9 +6,9 @@ import type { RegistryDatabase } from '@handoff/registry/db/client';
|
|
|
6
6
|
import { assetBlobs, assetCollections, assets } from '@handoff/registry/db/schema';
|
|
7
7
|
import { isAssetCollection, type AssetCollection } from '@handoff/registry/assets/sets';
|
|
8
8
|
import type { AssetCollectionTransferPackage, AssetManifestEntry } from '@handoff/registry/assets/transfer';
|
|
9
|
-
import type {
|
|
9
|
+
import type { StorageProvider } from '@handoff/registry/asset-storage/types';
|
|
10
10
|
import { singleQueryValue } from '../api/query';
|
|
11
|
-
import { getActiveAssetStorage,
|
|
11
|
+
import { getActiveAssetStorage, getActiveStorageProvider, getStorageProvider } from '../asset-storage';
|
|
12
12
|
import { sendRegistryError } from './errors';
|
|
13
13
|
import { buildMeta } from './meta';
|
|
14
14
|
import { handleRegistryRoute, sendRegistryData } from './handler';
|
|
@@ -318,10 +318,10 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
|
|
|
318
318
|
'application/octet-stream';
|
|
319
319
|
const active = getActiveAssetStorage();
|
|
320
320
|
|
|
321
|
-
const storageProvider = active.
|
|
321
|
+
const storageProvider = active.provider;
|
|
322
322
|
let content: Buffer | null = null;
|
|
323
323
|
let storageRef: string | null = null;
|
|
324
|
-
if (active.
|
|
324
|
+
if (active.provider === 'database') {
|
|
325
325
|
if (bytes.length > active.maxInlineBytes) {
|
|
326
326
|
sendRegistryError(
|
|
327
327
|
res,
|
|
@@ -332,12 +332,12 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
|
|
|
332
332
|
}
|
|
333
333
|
content = bytes;
|
|
334
334
|
} else {
|
|
335
|
-
const
|
|
336
|
-
if (!
|
|
337
|
-
sendRegistryError(res, 'unexpected_error', `No storage
|
|
335
|
+
const storage = await getActiveStorageProvider();
|
|
336
|
+
if (!storage) {
|
|
337
|
+
sendRegistryError(res, 'unexpected_error', `No asset storage is available for provider "${active.provider}".`);
|
|
338
338
|
return;
|
|
339
339
|
}
|
|
340
|
-
({ storageRef } = await
|
|
340
|
+
({ storageRef } = await storage.put({ hash, bytes, contentType, size: bytes.length }));
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
await db
|
|
@@ -376,12 +376,12 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
|
|
|
376
376
|
sendRegistryError(res, 'not_found', `Blob "${hash}" has no resolvable content.`);
|
|
377
377
|
return;
|
|
378
378
|
}
|
|
379
|
-
const
|
|
380
|
-
if (!
|
|
381
|
-
sendRegistryError(res, 'unexpected_error', `No storage
|
|
379
|
+
const storage = await getStorageProvider(blob.storageProvider);
|
|
380
|
+
if (!storage) {
|
|
381
|
+
sendRegistryError(res, 'unexpected_error', `No asset storage is available for provider "${blob.storageProvider}".`);
|
|
382
382
|
return;
|
|
383
383
|
}
|
|
384
|
-
const result = await
|
|
384
|
+
const result = await storage.get(blob.storageRef);
|
|
385
385
|
if (result.kind === 'redirect') {
|
|
386
386
|
res.redirect(302, result.url);
|
|
387
387
|
return;
|
|
@@ -394,13 +394,13 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
|
|
|
394
394
|
result.stream.pipe(res);
|
|
395
395
|
});
|
|
396
396
|
|
|
397
|
-
/** The active
|
|
398
|
-
const
|
|
399
|
-
if (getActiveAssetStorage().
|
|
397
|
+
/** The active provider when it supports direct uploads, otherwise `null` (the client then uses `PUT`). */
|
|
398
|
+
const getDirectUploadProvider = async (): Promise<StorageProvider | null> => {
|
|
399
|
+
if (getActiveAssetStorage().provider === 'database') {
|
|
400
400
|
return null;
|
|
401
401
|
}
|
|
402
|
-
const
|
|
403
|
-
return
|
|
402
|
+
const storage = await getActiveStorageProvider();
|
|
403
|
+
return storage?.createUpload ? storage : null;
|
|
404
404
|
};
|
|
405
405
|
|
|
406
406
|
const readDirectUploadRequest = (req: NextApiRequest, res: NextApiResponse): { hash: string; size: number; contentType: string } | null => {
|
|
@@ -422,8 +422,8 @@ const readDirectUploadRequest = (req: NextApiRequest, res: NextApiResponse): { h
|
|
|
422
422
|
* Throw unless `storageRef` resolves to a stored object. A redirect is checked with a one-byte
|
|
423
423
|
* ranged `GET`, because a signed read URL usually rejects `HEAD`.
|
|
424
424
|
*/
|
|
425
|
-
const assertStored = async (
|
|
426
|
-
const result = await
|
|
425
|
+
const assertStored = async (storage: StorageProvider, storageRef: string, size: number): Promise<void> => {
|
|
426
|
+
const result = await storage.get(storageRef);
|
|
427
427
|
if (result.kind === 'bytes') {
|
|
428
428
|
if (result.bytes.length !== size) {
|
|
429
429
|
throw new Error(`stored size ${result.bytes.length} does not match ${size}`);
|
|
@@ -452,8 +452,8 @@ export const handleAssetBlobUploadRoute = (req: NextApiRequest, res: NextApiResp
|
|
|
452
452
|
sendRegistryData(res, 200, { upload: null, stored: true }, buildMeta());
|
|
453
453
|
return;
|
|
454
454
|
}
|
|
455
|
-
const
|
|
456
|
-
const upload =
|
|
455
|
+
const storage = await getDirectUploadProvider();
|
|
456
|
+
const upload = storage?.createUpload ? await storage.createUpload(input) : null;
|
|
457
457
|
sendRegistryData(res, 200, { upload }, buildMeta());
|
|
458
458
|
});
|
|
459
459
|
|
|
@@ -468,13 +468,13 @@ export const handleAssetBlobCompleteRoute = (req: NextApiRequest, res: NextApiRe
|
|
|
468
468
|
return;
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
const
|
|
472
|
-
if (!
|
|
471
|
+
const storage = await getDirectUploadProvider();
|
|
472
|
+
if (!storage) {
|
|
473
473
|
sendRegistryError(res, 'bad_request', 'The active asset storage provider does not accept direct uploads.');
|
|
474
474
|
return;
|
|
475
475
|
}
|
|
476
476
|
try {
|
|
477
|
-
await assertStored(
|
|
477
|
+
await assertStored(storage, storageRef, input.size);
|
|
478
478
|
} catch (error) {
|
|
479
479
|
const reason = error instanceof Error ? error.message : String(error);
|
|
480
480
|
sendRegistryError(res, 'bad_request', `Blob "${input.hash}" was not found in storage (${reason}).`, {
|
|
@@ -487,7 +487,7 @@ export const handleAssetBlobCompleteRoute = (req: NextApiRequest, res: NextApiRe
|
|
|
487
487
|
.insert(assetBlobs)
|
|
488
488
|
.values({
|
|
489
489
|
hash: input.hash,
|
|
490
|
-
storageProvider: getActiveAssetStorage().
|
|
490
|
+
storageProvider: getActiveAssetStorage().provider,
|
|
491
491
|
content: null,
|
|
492
492
|
storageRef,
|
|
493
493
|
contentType: input.contentType,
|
|
@@ -4,7 +4,7 @@ import { pathToFileURL } from 'url';
|
|
|
4
4
|
import { resolveEnvReferences } from '@handoff/config/from-env';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Import a consumer's server-only module (a custom asset storage
|
|
7
|
+
* Import a consumer's server-only module (a custom asset storage, AI or email
|
|
8
8
|
* provider) by the path the config gives, relative to the project.
|
|
9
9
|
*
|
|
10
10
|
* A registry bundle mirrors the tracing root and runs with its root as the working directory, so the
|
package/src/app/lib/utils.ts
CHANGED
|
@@ -51,3 +51,10 @@ export const filterOutNull = <T>(value: T): value is NonNullable<T> => value !==
|
|
|
51
51
|
*/
|
|
52
52
|
export const hasRenderableNav = (section?: SectionLink | null): boolean =>
|
|
53
53
|
!!section?.subSections?.some((sub) => (sub.menu && sub.menu.length > 0) || !!sub.dynamic);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The browser title: a page's own `metaTitle` as written, otherwise its title followed by the
|
|
57
|
+
* site title from `app.title`.
|
|
58
|
+
*/
|
|
59
|
+
export const pageTitle = (metadata: { title?: string; metaTitle?: string }, siteTitle?: string): string =>
|
|
60
|
+
metadata.metaTitle || [metadata.title, siteTitle].filter(Boolean).join(' | ');
|
package/src/app/next.config.mjs
CHANGED
|
@@ -113,9 +113,9 @@ const nextConfig = {
|
|
|
113
113
|
HANDOFF_AI_DEFAULT_MODEL: '%HANDOFF_AI_DEFAULT_MODEL%',
|
|
114
114
|
HANDOFF_REGISTRY_DRIVER: '%HANDOFF_REGISTRY_DRIVER%',
|
|
115
115
|
HANDOFF_REGISTRY_DATABASE_URL_ENV: '%HANDOFF_REGISTRY_DATABASE_URL_ENV%',
|
|
116
|
-
// Asset storage
|
|
116
|
+
// Asset storage provider, module, and options JSON. Option references keep only their variable
|
|
117
117
|
// name and are resolved at request time, so secret values are never baked.
|
|
118
|
-
|
|
118
|
+
HANDOFF_ASSET_STORAGE_PROVIDER: '%HANDOFF_ASSET_STORAGE_PROVIDER%',
|
|
119
119
|
HANDOFF_ASSET_STORAGE_MODULE: '%HANDOFF_ASSET_STORAGE_MODULE%',
|
|
120
120
|
HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES: '%HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES%',
|
|
121
121
|
HANDOFF_ASSET_STORAGE_OPTIONS: '%HANDOFF_ASSET_STORAGE_OPTIONS%',
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '../../components/util';
|
|
21
21
|
import { resolveDocsBackend } from '../../lib/docs-api/backend';
|
|
22
22
|
import { documentationMetadata, REGISTRY_PAGE_REVALIDATE_SECONDS } from '../../lib/docs-api/page-rendering';
|
|
23
|
+
import { pageTitle } from '../../lib/utils';
|
|
23
24
|
import { Home, getStaticProps as getHomeStaticProps } from '../index';
|
|
24
25
|
|
|
25
26
|
type CatchAllDocumentationProps = DocumentationProps & { homeAlias?: boolean };
|
|
@@ -114,7 +115,7 @@ export default function DocCatchAllPage({
|
|
|
114
115
|
return (
|
|
115
116
|
<div className="flex min-h-screen flex-col items-center justify-center bg-white dark:bg-gray-900">
|
|
116
117
|
<Head>
|
|
117
|
-
<title>
|
|
118
|
+
<title>{pageTitle({ title: 'Page Not Found' }, config?.app?.title)}</title>
|
|
118
119
|
<meta name="description" content="Page Not Found" />
|
|
119
120
|
</Head>
|
|
120
121
|
<NotFound />
|
|
@@ -34,7 +34,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
34
34
|
|
|
35
35
|
export default function AiKeysPage({ config }: { config: ClientConfig }) {
|
|
36
36
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
37
|
-
return <AccountLayout title="AI providers">{null}</AccountLayout>;
|
|
37
|
+
return <AccountLayout config={config} title="AI providers">{null}</AccountLayout>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
return <RegistryAiKeysPage config={config} />;
|
|
@@ -25,7 +25,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
25
25
|
|
|
26
26
|
export default function AccountPage({ config }: { config: ClientConfig }) {
|
|
27
27
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
28
|
-
return <AccountLayout title="Profile">{null}</AccountLayout>;
|
|
28
|
+
return <AccountLayout config={config} title="Profile">{null}</AccountLayout>;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
return <RegistryAccountPage config={config} />;
|
|
@@ -37,7 +37,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
37
37
|
|
|
38
38
|
export default function TokensPage({ config }: { config: ClientConfig }) {
|
|
39
39
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
40
|
-
return <AccountLayout title="Access tokens">{null}</AccountLayout>;
|
|
40
|
+
return <AccountLayout config={config} title="Access tokens">{null}</AccountLayout>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
return <RegistryTokensPage config={config} />;
|
|
@@ -37,7 +37,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
37
37
|
|
|
38
38
|
export default function UsersPage({ config }: { config: ClientConfig }) {
|
|
39
39
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
40
|
-
return <AccountLayout title="Users">{null}</AccountLayout>;
|
|
40
|
+
return <AccountLayout config={config} title="Users">{null}</AccountLayout>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
return <RegistryUsersPage config={config} />;
|
|
@@ -5,7 +5,7 @@ import { ensureGet, resolveDocsBackend, sendDocsData, sendDocsError } from '@/li
|
|
|
5
5
|
// Kept static so the baked structure is traced into every registry serverless bundle.
|
|
6
6
|
import navShell from '@/generated/nav-shell.json';
|
|
7
7
|
|
|
8
|
-
/** Thin
|
|
8
|
+
/** Thin route: `getRegistryNavData` builds and fills the whole navigation. */
|
|
9
9
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
|
10
10
|
if (!ensureGet(req, res)) return;
|
|
11
11
|
|
package/src/app/pages/api/mcp.ts
CHANGED
|
@@ -22,6 +22,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// The status probe of the docs app. A 204 shows only that the route is served, so the probe needs no credential.
|
|
26
|
+
if (req.method === 'HEAD') {
|
|
27
|
+
res.status(204).end();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
if (!(await authorizeMcpRequest(req, res))) {
|
|
26
32
|
return;
|
|
27
33
|
}
|
|
@@ -5,7 +5,7 @@ import { handleAssetBlobRoute } from '@/lib/registry-api/asset-transfer';
|
|
|
5
5
|
* `/api/registry/transfer/assets/blobs/:hash`: one content-addressed blob.
|
|
6
6
|
*
|
|
7
7
|
* - `PUT` stores the raw binary body through the active storage provider (inline `bytea` by default,
|
|
8
|
-
* or
|
|
8
|
+
* or a custom storage provider). Idempotent by hash; requires a `registry:write` token.
|
|
9
9
|
* - `GET` returns the blob's bytes, or redirects to a provider URL for object-backed content.
|
|
10
10
|
* Requires a `registry:read` token.
|
|
11
11
|
*
|