handoff-app 2.0.0-8 → 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.
- package/{Changelog.md → CHANGELOG.md} +85 -1
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +816 -393
- package/RECIPES.md +975 -0
- package/UPGRADE.md +57 -9
- 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/connections.d.ts +66 -0
- package/dist/ai/connections.js +149 -0
- package/dist/ai/define.d.ts +10 -0
- package/dist/ai/define.js +13 -0
- package/dist/ai/types.d.ts +26 -0
- package/dist/ai/types.js +10 -0
- package/dist/app-builder/build.d.ts +14 -7
- package/dist/app-builder/build.js +195 -66
- package/dist/app-builder/client-config.js +3 -2
- package/dist/app-builder/paths.d.ts +0 -12
- package/dist/app-builder/paths.js +3 -20
- package/dist/app-builder/static-export.js +1 -1
- package/dist/app-builder/vercel-output.d.ts +1 -1
- package/dist/app-builder/vercel-output.js +5 -4
- package/dist/cache/build-cache.d.ts +27 -4
- package/dist/cache/build-cache.js +114 -47
- package/dist/cache/build-inputs.d.ts +13 -0
- package/dist/cache/build-inputs.js +19 -0
- package/dist/cache/index.d.ts +2 -1
- package/dist/cache/index.js +5 -1
- package/dist/catalog/normalize.js +3 -0
- package/dist/catalog/previews.js +5 -4
- package/dist/catalog/types.d.ts +8 -0
- package/dist/cli/create.js +1 -2
- package/dist/commands/build/index.d.ts +1 -1
- package/dist/commands/build/index.js +1 -1
- package/dist/commands/utils.d.ts +3 -3
- package/dist/config/defaults.js +7 -4
- package/dist/config/from-env.d.ts +4 -0
- package/dist/config/from-env.js +38 -1
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +0 -1
- package/dist/config/index.js +1 -4
- package/dist/config/loader.js +28 -2
- package/dist/config/resolve-env.d.ts +2 -1
- package/dist/config/resolve-env.js +28 -12
- package/dist/config/runtime.js +9 -11
- package/dist/handlebars.d.ts +10 -2
- package/dist/index.d.ts +12 -7
- package/dist/index.js +18 -22
- 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/pipeline/figma.d.ts +13 -2
- package/dist/pipeline/figma.js +86 -46
- package/dist/pipeline/styles.js +1 -1
- package/dist/pipeline/validation.d.ts +1 -1
- package/dist/pipeline/validation.js +3 -3
- package/dist/providers.d.ts +10 -0
- package/dist/providers.js +13 -0
- 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 +9 -19
- package/dist/registry/asset-storage/resolve.js +33 -21
- package/dist/registry/asset-storage/types.d.ts +37 -17
- package/dist/registry/asset-storage/types.js +5 -5
- package/dist/registry/auth/ai-keys.d.ts +32 -0
- package/dist/registry/auth/ai-keys.js +69 -0
- package/dist/registry/auth/crypto.d.ts +17 -0
- package/dist/registry/auth/crypto.js +61 -1
- package/dist/registry/auth/index.d.ts +2 -1
- package/dist/registry/auth/index.js +11 -2
- package/dist/registry/checkout/index.d.ts +2 -1
- package/dist/registry/checkout/index.js +57 -127
- package/dist/registry/client.js +53 -17
- package/dist/registry/db/driver.js +3 -3
- package/dist/registry/db/schema.d.ts +261 -3
- package/dist/registry/db/schema.js +29 -4
- package/dist/registry/email/define.d.ts +10 -0
- package/dist/registry/email/define.js +13 -0
- package/dist/registry/email/providers/smtp.d.ts +10 -0
- package/dist/registry/email/providers/smtp.js +73 -0
- package/dist/registry/email/resolve.d.ts +28 -0
- package/dist/registry/email/resolve.js +74 -0
- package/dist/registry/email/types.d.ts +24 -0
- package/dist/registry/email/types.js +6 -0
- package/dist/registry/path.d.ts +2 -0
- package/dist/registry/path.js +11 -1
- package/dist/registry/publish/package.js +8 -0
- package/dist/registry/transfer.d.ts +4 -0
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/transformers/plugins/ssr-render.js +40 -15
- package/dist/transformers/preview/component/builder.d.ts +1 -9
- package/dist/transformers/preview/component/builder.js +17 -17
- package/dist/transformers/preview/component/css.d.ts +3 -1
- package/dist/transformers/preview/component/css.js +92 -51
- package/dist/transformers/preview/component/html.d.ts +3 -1
- package/dist/transformers/preview/component/html.js +6 -2
- package/dist/transformers/preview/component/javascript.d.ts +2 -1
- package/dist/transformers/preview/component/javascript.js +10 -4
- package/dist/transformers/preview/pattern/html.js +3 -1
- package/dist/transformers/preview/types.d.ts +13 -0
- package/dist/transformers/utils/build.d.ts +24 -0
- package/dist/transformers/utils/build.js +100 -1
- package/dist/transformers/utils/module.js +2 -1
- package/dist/types/config.d.ts +135 -18
- package/dist/utils/path.d.ts +1 -5
- package/dist/utils/path.js +6 -23
- package/docs/api.md +6 -4
- package/docs/cli.md +275 -54
- package/drizzle/0007_add_ai_keys.sql +11 -0
- package/drizzle/0008_add_source_dir.sql +2 -0
- package/drizzle/meta/0007_snapshot.json +2013 -0
- package/drizzle/meta/0008_snapshot.json +2025 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +19 -8
- package/src/ai/README.md +42 -0
- package/src/ai/connections.ts +189 -0
- package/src/ai/define.ts +15 -0
- package/src/ai/types.ts +30 -0
- package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
- package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
- package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
- package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
- package/src/app/components/AiAssistant/dockState.ts +86 -0
- package/src/app/components/AiAssistant/toolLabel.ts +24 -0
- package/src/app/components/AiAssistant/useAiChat.ts +147 -0
- package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
- package/src/app/components/AiAssistant/usePageContext.ts +63 -0
- package/src/app/components/Auth/AccountLayout.tsx +58 -19
- package/src/app/components/Auth/AuthShell.tsx +27 -33
- package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
- package/src/app/components/Auth/installStatus.ts +29 -0
- package/src/app/components/Component/PageSliceResolver.tsx +1 -0
- package/src/app/components/Component/Preview.tsx +383 -189
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Header.tsx +21 -15
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +2 -2
- package/src/app/components/Navigation/SideNav.tsx +1 -15
- package/src/app/components/context/NavProvider.tsx +7 -3
- package/src/app/components/ui/attachment.tsx +50 -0
- package/src/app/components/ui/bubble.tsx +57 -0
- package/src/app/components/ui/button.tsx +3 -1
- package/src/app/components/ui/confirm-dialog.tsx +87 -0
- package/src/app/components/ui/dialog.tsx +7 -2
- package/src/app/components/ui/marker.tsx +56 -0
- package/src/app/components/ui/sidebar.tsx +3 -1
- package/src/app/components/util/index.ts +3 -12
- package/src/app/css/index.css +64 -0
- package/src/app/lib/ai/auth.ts +33 -0
- package/src/app/lib/ai/context.ts +99 -0
- package/src/app/lib/ai/model.ts +79 -0
- package/src/app/lib/ai/prompt.ts +36 -0
- package/src/app/lib/ai/resolve.ts +97 -0
- package/src/app/lib/ai/tools.ts +120 -0
- package/src/app/lib/api/methods.ts +13 -0
- package/src/app/lib/asset-storage.ts +35 -36
- package/src/app/lib/auth/api.ts +4 -12
- package/src/app/lib/auth/email.ts +96 -23
- 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 +47 -21
- package/src/app/lib/mcp/components.ts +523 -0
- package/src/app/lib/mcp/page-url.ts +24 -0
- package/src/app/lib/mcp/pages.ts +84 -0
- package/src/app/lib/mcp/result.ts +33 -0
- package/src/app/lib/mcp/server.ts +39 -318
- package/src/app/lib/mcp/tokens.ts +334 -0
- package/src/app/lib/mcp/tool.ts +19 -0
- package/src/app/lib/registry-api/asset-transfer.ts +119 -11
- package/src/app/lib/registry-api/errors.ts +2 -0
- package/src/app/lib/registry-api/handler.ts +7 -1
- package/src/app/lib/registry-api/transfer.ts +28 -6
- package/src/app/lib/server-module.ts +34 -0
- package/src/app/lib/utils.ts +20 -0
- package/src/app/next.config.mjs +26 -24
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/_app.tsx +6 -2
- package/src/app/pages/_document.tsx +6 -0
- package/src/app/pages/account/ai.tsx +183 -0
- package/src/app/pages/account/index.tsx +68 -68
- package/src/app/pages/account/tokens.tsx +37 -7
- package/src/app/pages/account/users.tsx +75 -15
- package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
- package/src/app/pages/api/account/ai/keys/index.ts +20 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
- package/src/app/pages/api/admin/users/index.ts +2 -2
- package/src/app/pages/api/ai/chat.ts +115 -0
- package/src/app/pages/api/ai/connections.ts +23 -0
- package/src/app/pages/api/auth/request-reset.ts +14 -16
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/install.ts +3 -3
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +10 -5
- 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/install.tsx +7 -2
- package/src/app/pages/login.tsx +32 -27
- package/src/app/pages/reset-password.tsx +21 -20
- 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/README.md +2 -1
- package/src/app-builder/build.ts +218 -73
- package/src/app-builder/client-config.ts +3 -2
- package/src/app-builder/paths.ts +3 -22
- package/src/app-builder/static-export.ts +1 -1
- package/src/app-builder/vercel-output.ts +5 -4
- package/src/cache/README.md +4 -1
- package/src/cache/build-cache.ts +128 -43
- package/src/cache/build-inputs.ts +28 -0
- package/src/cache/index.ts +4 -0
- package/src/catalog/README.md +3 -0
- package/src/catalog/normalize.ts +2 -0
- package/src/catalog/previews.ts +6 -3
- package/src/catalog/types.ts +8 -0
- package/src/cli/create.ts +1 -2
- package/src/commands/build/index.ts +1 -1
- package/src/config/README.md +10 -9
- package/src/config/defaults.ts +7 -4
- package/src/config/from-env.ts +33 -0
- package/src/config/helpers.ts +1 -3
- package/src/config/index.ts +0 -3
- package/src/config/loader.ts +27 -3
- package/src/config/resolve-env.ts +28 -15
- package/src/config/runtime.ts +10 -5
- package/src/handlebars.ts +8 -2
- package/src/index.ts +33 -29
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/pipeline/README.md +1 -1
- package/src/pipeline/figma.ts +98 -49
- package/src/pipeline/styles.ts +1 -1
- package/src/pipeline/validation.ts +3 -3
- package/src/providers.ts +18 -0
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +38 -37
- package/src/registry/asset-storage/types.ts +39 -17
- package/src/registry/auth/ai-keys.ts +62 -0
- package/src/registry/auth/crypto.ts +63 -1
- package/src/registry/auth/index.ts +11 -1
- package/src/registry/checkout/index.ts +60 -138
- package/src/registry/client.ts +60 -20
- package/src/registry/db/driver.ts +2 -2
- package/src/registry/db/schema.ts +49 -22
- package/src/registry/email/define.ts +15 -0
- package/src/registry/email/providers/smtp.ts +39 -0
- package/src/registry/email/resolve.ts +94 -0
- package/src/registry/email/types.ts +28 -0
- package/src/registry/path.ts +11 -0
- package/src/registry/publish/package.ts +9 -0
- package/src/registry/transfer.ts +4 -0
- package/src/store/registry.ts +7 -7
- package/src/transformers/plugins/ssr-render.ts +53 -16
- package/src/transformers/preview/component/builder.ts +19 -25
- package/src/transformers/preview/component/css.ts +101 -53
- package/src/transformers/preview/component/html.ts +6 -2
- package/src/transformers/preview/component/javascript.ts +14 -4
- package/src/transformers/preview/pattern/html.ts +3 -1
- package/src/transformers/preview/types.ts +14 -0
- package/src/transformers/utils/build.ts +93 -0
- package/src/transformers/utils/module.ts +4 -2
- package/src/types/config.ts +148 -20
- package/src/utils/README.md +1 -1
- package/src/utils/path.ts +6 -29
- package/templates/default/handoff.config.js.tpl +10 -2
- package/templates/default/handoff.config.ts.tpl +10 -2
- package/templates/default/handoff.config.with-examples.js.tpl +10 -2
- package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
- package/templates/default/package.json.tpl +2 -2
- 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/config/validator.d.ts +0 -9
- package/dist/config/validator.js +0 -24
- package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
- package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/lib/mcp/shape.ts +0 -596
- 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/config/validator.ts +0 -22
- package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
|
@@ -2,74 +2,75 @@
|
|
|
2
2
|
* Asset storage settings resolution.
|
|
3
3
|
*
|
|
4
4
|
* `runtime.registry.assetStorage` selects the active provider for **new** uploads: the built-in
|
|
5
|
-
* `database` inline default
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `database` inline default or a `custom` module. The build bakes the provider, the module location,
|
|
6
|
+
* and the options. An option literal is baked as it is. An environment reference stays `{ $env }`
|
|
7
|
+
* and is resolved at request time, before the custom provider factory runs.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { HandoffConfigError } from '../../config/errors';
|
|
10
11
|
import type { ResolvedConfig } from '../../types/config';
|
|
11
12
|
import type { AssetStorageProvider } from '../db/schema';
|
|
12
13
|
|
|
13
|
-
/** Which adapter implementation is active. */
|
|
14
|
-
export type AssetStorageAdapterKind = 'database' | 'vercel-blob' | 'custom';
|
|
15
|
-
|
|
16
14
|
/** The raw, authored settings block (a structural copy of the config type, resolvable from bake). */
|
|
17
15
|
export interface AssetStorageSettings {
|
|
18
|
-
|
|
16
|
+
provider?: AssetStorageProvider;
|
|
19
17
|
module?: string;
|
|
20
|
-
tokenEnv?: string;
|
|
21
18
|
maxInlineBytes?: number;
|
|
22
19
|
options?: Record<string, unknown>;
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
export const DEFAULT_ASSET_STORAGE_ADAPTER: AssetStorageAdapterKind = 'database';
|
|
22
|
+
const PROVIDERS: readonly AssetStorageProvider[] = ['database', 'custom'];
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
export const DEFAULT_BLOB_TOKEN_ENV = 'BLOB_READ_WRITE_TOKEN';
|
|
24
|
+
export const DEFAULT_ASSET_STORAGE_PROVIDER: AssetStorageProvider = 'database';
|
|
30
25
|
|
|
31
26
|
/** Default max bytes kept inline in Postgres `bytea` (4 MB, under Vercel's ~4.5 MB function limit). */
|
|
32
27
|
export const DEFAULT_MAX_INLINE_BYTES = 4 * 1024 * 1024;
|
|
33
28
|
|
|
34
29
|
/** Fully resolved asset storage settings. */
|
|
35
30
|
export interface ResolvedAssetStorage {
|
|
36
|
-
/**
|
|
37
|
-
adapterKind: AssetStorageAdapterKind;
|
|
38
|
-
/** Provider id recorded on blob rows written by the active adapter (drives read resolution). */
|
|
31
|
+
/** Recorded on each new blob row as its storage provider. */
|
|
39
32
|
provider: AssetStorageProvider;
|
|
40
|
-
/** For `custom`: the server-only
|
|
33
|
+
/** For `custom`: the server-only provider module path. */
|
|
41
34
|
module?: string;
|
|
42
|
-
/** For `vercel-blob`: the env-var name holding the token. */
|
|
43
|
-
tokenEnv: string;
|
|
44
35
|
/** For `database`: the inline-content size ceiling. */
|
|
45
36
|
maxInlineBytes: number;
|
|
46
|
-
/**
|
|
37
|
+
/** Provider options: literals and `{ $env }` references, never secret values. */
|
|
47
38
|
options: Record<string, unknown>;
|
|
48
39
|
}
|
|
49
40
|
|
|
50
|
-
/** The stable provider id recorded on blobs for a custom adapter (single custom provider at a time). */
|
|
51
|
-
const customProviderId = (options: Record<string, unknown>): AssetStorageProvider => {
|
|
52
|
-
const id = typeof options.providerId === 'string' && options.providerId.trim() ? options.providerId.trim() : 'custom';
|
|
53
|
-
return id;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
41
|
/** Resolve raw asset storage settings into a fully-defaulted shape. */
|
|
57
42
|
export const resolveAssetStorageSettings = (settings: AssetStorageSettings | null | undefined): ResolvedAssetStorage => {
|
|
58
|
-
const
|
|
43
|
+
const provider = settings?.provider ?? DEFAULT_ASSET_STORAGE_PROVIDER;
|
|
59
44
|
const options = settings?.options ?? {};
|
|
60
|
-
const tokenEnv = settings?.tokenEnv?.trim() || DEFAULT_BLOB_TOKEN_ENV;
|
|
61
45
|
const maxInlineBytes =
|
|
62
46
|
typeof settings?.maxInlineBytes === 'number' && settings.maxInlineBytes > 0 ? settings.maxInlineBytes : DEFAULT_MAX_INLINE_BYTES;
|
|
63
47
|
|
|
64
|
-
|
|
65
|
-
adapterKind === 'vercel-blob' ? 'vercel-blob' : adapterKind === 'custom' ? customProviderId(options) : 'database';
|
|
66
|
-
|
|
67
|
-
return { adapterKind, provider, module: settings?.module?.trim() || undefined, tokenEnv, maxInlineBytes, options };
|
|
48
|
+
return { provider, module: settings?.module?.trim() || undefined, maxInlineBytes, options };
|
|
68
49
|
};
|
|
69
50
|
|
|
70
|
-
/** Resolve the
|
|
71
|
-
export const resolveAssetStorageFromConfig = (config: ResolvedConfig | null | undefined): ResolvedAssetStorage =>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
51
|
+
/** Resolve and validate the asset storage block from a loaded config (CLI/build side). An unusable block fails the build. */
|
|
52
|
+
export const resolveAssetStorageFromConfig = (config: ResolvedConfig | null | undefined): ResolvedAssetStorage => {
|
|
53
|
+
const settings = config?.runtime?.registry?.assetStorage as Record<string, unknown> | undefined;
|
|
54
|
+
const fail = (problem: string): never => {
|
|
55
|
+
throw new HandoffConfigError(`Config "runtime.registry.assetStorage": ${problem}`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const provider = settings?.provider ?? DEFAULT_ASSET_STORAGE_PROVIDER;
|
|
59
|
+
if (!PROVIDERS.includes(provider as AssetStorageProvider)) {
|
|
60
|
+
fail(`unknown provider ${JSON.stringify(provider)}. Use "database" or "custom".`);
|
|
61
|
+
}
|
|
62
|
+
const options = settings?.options;
|
|
63
|
+
if (options !== undefined && (options === null || typeof options !== 'object' || Array.isArray(options))) {
|
|
64
|
+
fail('"options" must be an object.');
|
|
65
|
+
}
|
|
66
|
+
const module = settings?.module;
|
|
67
|
+
if (provider === 'custom' && (typeof module !== 'string' || !module.trim())) {
|
|
68
|
+
fail('provider "custom" needs a "module" that default-exports a defineStorageProvider() result.');
|
|
69
|
+
}
|
|
70
|
+
const maxInlineBytes = settings?.maxInlineBytes;
|
|
71
|
+
if (maxInlineBytes !== undefined && (typeof maxInlineBytes !== 'number' || !Number.isInteger(maxInlineBytes) || maxInlineBytes <= 0)) {
|
|
72
|
+
fail('"maxInlineBytes" must be a positive whole number.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return resolveAssetStorageSettings(settings as AssetStorageSettings | undefined);
|
|
76
|
+
};
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-only asset storage contract for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Server-only asset storage contract for custom {@link defineStorageProvider} object storage modules.
|
|
3
|
+
* The built-in `database` provider is the inline default: bytes live in the `asset_blobs.content`
|
|
4
|
+
* bytea column, read/written directly by the registry, so it needs no module. Object providers
|
|
5
|
+
* record a `storageRef` + provider id so reads resolve back through whichever provider stored the
|
|
6
|
+
* blob.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/** Input for storing one content-addressed blob. */
|
|
10
|
-
export interface
|
|
10
|
+
export interface StorageInput {
|
|
11
11
|
/** SHA-256 of the bytes (hex), the content identity, usable as a stable object key. */
|
|
12
12
|
hash: string;
|
|
13
13
|
bytes: Buffer;
|
|
@@ -19,28 +19,50 @@ export interface AssetStorageInput {
|
|
|
19
19
|
* How a provider returns stored content: raw bytes, a readable stream, or a signed/redirect URL the
|
|
20
20
|
* client can fetch directly (used to avoid routing large payloads through the serverless function).
|
|
21
21
|
*/
|
|
22
|
-
export type
|
|
22
|
+
export type StorageReadResult =
|
|
23
23
|
| { kind: 'bytes'; bytes: Buffer; contentType?: string }
|
|
24
24
|
| { kind: 'stream'; stream: NodeJS.ReadableStream; contentType?: string }
|
|
25
25
|
| { kind: 'redirect'; url: string };
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
export interface
|
|
27
|
+
/** Input for a direct upload of one blob from the CLI to storage. */
|
|
28
|
+
export interface AssetUploadInput {
|
|
29
|
+
/** SHA-256 of the bytes (hex). */
|
|
30
|
+
hash: string;
|
|
31
|
+
size: number;
|
|
32
|
+
contentType: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A signed URL the CLI sends the blob to with `PUT` and the given headers. */
|
|
36
|
+
export interface AssetUpload {
|
|
37
|
+
url: string;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
/** The reference to persist on the blob's `asset_blobs` row after the upload. */
|
|
40
|
+
storageRef: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The pluggable storage contract for object-backed asset providers. */
|
|
44
|
+
export interface StorageProvider {
|
|
29
45
|
/** Store one blob and return the reference to persist on its `asset_blobs` row. */
|
|
30
|
-
put(input:
|
|
46
|
+
put(input: StorageInput): Promise<{ storageRef: string }>;
|
|
47
|
+
/**
|
|
48
|
+
* Return a signed URL so the CLI uploads the blob directly to storage instead of through the
|
|
49
|
+
* registry, which avoids serverless request size limits. The URL must make storage reject bytes
|
|
50
|
+
* that do not match `hash`, for example with a signed SHA-256 checksum header.
|
|
51
|
+
*/
|
|
52
|
+
createUpload?(input: AssetUploadInput): Promise<AssetUpload>;
|
|
31
53
|
/** Resolve stored content by its `storageRef`. */
|
|
32
|
-
get(storageRef: string): Promise<
|
|
54
|
+
get(storageRef: string): Promise<StorageReadResult>;
|
|
33
55
|
/** Delete stored content by its `storageRef`. Should be retryable/idempotent. */
|
|
34
56
|
delete(storageRef: string): Promise<void>;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
/** Context passed to a custom
|
|
38
|
-
export interface
|
|
39
|
-
/**
|
|
59
|
+
/** Context passed to a custom provider factory: its config options and the process env. */
|
|
60
|
+
export interface StorageProviderContext {
|
|
61
|
+
/** `runtime.registry.assetStorage.options`, with each `fromEnv()` reference replaced by its value. */
|
|
40
62
|
options: Record<string, unknown>;
|
|
41
|
-
/** The process environment.
|
|
63
|
+
/** The process environment. */
|
|
42
64
|
env: NodeJS.ProcessEnv;
|
|
43
65
|
}
|
|
44
66
|
|
|
45
|
-
/** A custom
|
|
46
|
-
export type
|
|
67
|
+
/** A custom provider module may export the provider directly or a factory that builds it from its context. */
|
|
68
|
+
export type StorageProviderFactory = (context: StorageProviderContext) => StorageProvider | Promise<StorageProvider>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm';
|
|
2
|
+
import type { RegistryDatabase } from '../db/client';
|
|
3
|
+
import { registryAiKeys } from '../db/schema';
|
|
4
|
+
import { decryptSecret, encryptSecret } from './crypto';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reader-supplied keys for connections the config declares with `credential: 'user'`.
|
|
8
|
+
*
|
|
9
|
+
* The key is write-only across the API boundary: once saved, a read reports only that a key exists.
|
|
10
|
+
* Only the chat route ever decrypts one, and only to send it to the connection's own endpoint.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ids of the connections this reader has a *usable* key for. A row that no longer decrypts — written
|
|
15
|
+
* under a rotated `HANDOFF_AI_KEY_SECRET` — is left out, so the account page and the assistant agree
|
|
16
|
+
* that no key is in place instead of offering a key the provider would never receive.
|
|
17
|
+
*/
|
|
18
|
+
export const listRegistryAiKeyConnections = async (db: RegistryDatabase, userId: string): Promise<string[]> => {
|
|
19
|
+
const rows = await db
|
|
20
|
+
.select({ connectionId: registryAiKeys.connectionId, encryptedKey: registryAiKeys.encryptedKey })
|
|
21
|
+
.from(registryAiKeys)
|
|
22
|
+
.where(eq(registryAiKeys.userId, userId))
|
|
23
|
+
.orderBy(registryAiKeys.connectionId);
|
|
24
|
+
return rows.filter((row) => decryptSecret(row.encryptedKey) !== null).map((row) => row.connectionId);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Save or replace this reader's key for one connection. */
|
|
28
|
+
export const saveRegistryAiKey = async (
|
|
29
|
+
db: RegistryDatabase,
|
|
30
|
+
input: { userId: string; connectionId: string; key: string }
|
|
31
|
+
): Promise<void> => {
|
|
32
|
+
const encryptedKey = encryptSecret(input.key);
|
|
33
|
+
await db
|
|
34
|
+
.insert(registryAiKeys)
|
|
35
|
+
.values({ userId: input.userId, connectionId: input.connectionId, encryptedKey })
|
|
36
|
+
.onConflictDoUpdate({
|
|
37
|
+
target: [registryAiKeys.userId, registryAiKeys.connectionId],
|
|
38
|
+
set: { encryptedKey, updatedAt: new Date() },
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Remove this reader's key for one connection. Returns whether a row was there to remove. */
|
|
43
|
+
export const deleteRegistryAiKey = async (db: RegistryDatabase, input: { userId: string; connectionId: string }): Promise<boolean> => {
|
|
44
|
+
const rows = await db
|
|
45
|
+
.delete(registryAiKeys)
|
|
46
|
+
.where(and(eq(registryAiKeys.userId, input.userId), eq(registryAiKeys.connectionId, input.connectionId)))
|
|
47
|
+
.returning({ connectionId: registryAiKeys.connectionId });
|
|
48
|
+
return rows.length > 0;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read back one reader's key. A row that no longer decrypts reads as absent, so the reader is told
|
|
53
|
+
* to add a key rather than shown a provider error.
|
|
54
|
+
*/
|
|
55
|
+
export const readRegistryAiKey = async (db: RegistryDatabase, input: { userId: string; connectionId: string }): Promise<string | null> => {
|
|
56
|
+
const [row] = await db
|
|
57
|
+
.select({ encryptedKey: registryAiKeys.encryptedKey })
|
|
58
|
+
.from(registryAiKeys)
|
|
59
|
+
.where(and(eq(registryAiKeys.userId, input.userId), eq(registryAiKeys.connectionId, input.connectionId)))
|
|
60
|
+
.limit(1);
|
|
61
|
+
return row ? decryptSecret(row.encryptedKey) : null;
|
|
62
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
export const hashSecret = (secret: string): string => createHash('sha256').update(secret, 'utf8').digest('hex');
|
|
4
4
|
|
|
@@ -11,3 +11,65 @@ export const secretHashMatches = (secret: string, expectedHash: string): boolean
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
export const normalizeEmail = (email: string): string => email.trim().toLowerCase();
|
|
14
|
+
|
|
15
|
+
/** Env var holding the deployment secret that reader-supplied AI keys are encrypted with. */
|
|
16
|
+
export const AI_KEY_SECRET_ENV = 'HANDOFF_AI_KEY_SECRET';
|
|
17
|
+
|
|
18
|
+
/** Shortest secret worth accepting. Matches the guidance the installer gives for `AUTH_SECRET`. */
|
|
19
|
+
const MIN_KEY_SECRET_LENGTH = 32;
|
|
20
|
+
|
|
21
|
+
/** Format marker, so a later algorithm is distinguishable from this one rather than guessed at. */
|
|
22
|
+
const ENCRYPTED_SECRET_VERSION = 'v1';
|
|
23
|
+
|
|
24
|
+
/** Thrown when a key must be encrypted but the deployment secret is missing or too short. */
|
|
25
|
+
export class MissingEncryptionSecretError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(
|
|
28
|
+
`Reader-supplied AI keys cannot be stored because ${AI_KEY_SECRET_ENV} is not set. Set it to a ` +
|
|
29
|
+
`random value of at least ${MIN_KEY_SECRET_LENGTH} characters, or declare no connection with ` +
|
|
30
|
+
`"credential: 'user'".`
|
|
31
|
+
);
|
|
32
|
+
this.name = 'MissingEncryptionSecretError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive the record key. A per-record salt means two rows holding the same provider key produce
|
|
38
|
+
* different ciphertexts, and the fixed `info` label keeps this key distinct from anything else the
|
|
39
|
+
* same secret is ever used for.
|
|
40
|
+
*/
|
|
41
|
+
const deriveKey = (salt: Buffer): Buffer => {
|
|
42
|
+
const secret = process.env[AI_KEY_SECRET_ENV]?.trim();
|
|
43
|
+
if (!secret || secret.length < MIN_KEY_SECRET_LENGTH) throw new MissingEncryptionSecretError();
|
|
44
|
+
return Buffer.from(hkdfSync('sha256', Buffer.from(secret, 'utf8'), salt, 'handoff-ai-key', 32));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Encrypt a value that has to be read back, such as a reader's provider key. AES-256-GCM, with the
|
|
49
|
+
* salt, nonce and authentication tag carried in the payload so a row is self-describing.
|
|
50
|
+
*/
|
|
51
|
+
export const encryptSecret = (plaintext: string): string => {
|
|
52
|
+
const salt = randomBytes(16);
|
|
53
|
+
const iv = randomBytes(12);
|
|
54
|
+
const cipher = createCipheriv('aes-256-gcm', deriveKey(salt), iv);
|
|
55
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
56
|
+
const parts = [salt, iv, cipher.getAuthTag(), encrypted].map((part) => part.toString('base64url'));
|
|
57
|
+
return [ENCRYPTED_SECRET_VERSION, ...parts].join('.');
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decrypt a value written by {@link encryptSecret}. Returns `null` for anything that does not
|
|
62
|
+
* authenticate — a payload in another format, or one written under a secret that has since been
|
|
63
|
+
* rotated — so a stale row reads as "no key saved" instead of failing the request.
|
|
64
|
+
*/
|
|
65
|
+
export const decryptSecret = (payload: string): string | null => {
|
|
66
|
+
const [version, salt, iv, tag, encrypted] = payload.split('.');
|
|
67
|
+
if (version !== ENCRYPTED_SECRET_VERSION || !salt || !iv || !tag || !encrypted) return null;
|
|
68
|
+
try {
|
|
69
|
+
const decipher = createDecipheriv('aes-256-gcm', deriveKey(Buffer.from(salt, 'base64url')), Buffer.from(iv, 'base64url'));
|
|
70
|
+
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
|
|
71
|
+
return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8');
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
@@ -25,7 +25,17 @@ export {
|
|
|
25
25
|
type ResendUserInvitationResult,
|
|
26
26
|
type ConsumeAuthActionResult,
|
|
27
27
|
} from './action-tokens';
|
|
28
|
-
export {
|
|
28
|
+
export {
|
|
29
|
+
AI_KEY_SECRET_ENV,
|
|
30
|
+
MissingEncryptionSecretError,
|
|
31
|
+
createOpaqueSecret,
|
|
32
|
+
decryptSecret,
|
|
33
|
+
encryptSecret,
|
|
34
|
+
hashSecret,
|
|
35
|
+
normalizeEmail,
|
|
36
|
+
secretHashMatches,
|
|
37
|
+
} from './crypto';
|
|
38
|
+
export { deleteRegistryAiKey, listRegistryAiKeyConnections, readRegistryAiKey, saveRegistryAiKey } from './ai-keys';
|
|
29
39
|
export { buildGravatarUrl } from './gravatar';
|
|
30
40
|
export { authenticateRegistrySyncSecret, resolveRegistrySyncSecret, SYNC_SECRET_ENV } from './sync-secret';
|
|
31
41
|
export {
|