handoff-app 1.2.2-7 → 2.0.0-1
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 +37 -15
- package/README.md +372 -118
- package/dist/app-builder/build.d.ts +41 -2
- package/dist/app-builder/build.js +455 -18
- package/dist/app-builder/client-config.d.ts +15 -2
- package/dist/app-builder/client-config.js +43 -4
- package/dist/app-builder/config-diff/snapshot.d.ts +1 -6
- package/dist/app-builder/config-diff/snapshot.js +2 -9
- package/dist/app-builder/index.d.ts +1 -1
- package/dist/app-builder/index.js +3 -1
- package/dist/app-builder/nav-shell.d.ts +23 -0
- package/dist/app-builder/nav-shell.js +80 -0
- package/dist/app-builder/paths.js +25 -1
- package/dist/app-builder/static-export.d.ts +17 -0
- package/dist/app-builder/static-export.js +206 -0
- package/dist/app-builder/vercel-output.d.ts +39 -0
- package/dist/app-builder/vercel-output.js +259 -0
- package/dist/app-builder/watchers/utils.d.ts +2 -0
- package/dist/app-builder/watchers.d.ts +14 -4
- package/dist/app-builder/watchers.js +123 -28
- package/dist/app-builder/websocket.js +1 -1
- package/dist/artifacts/index.d.ts +2 -0
- package/dist/artifacts/index.js +16 -0
- package/dist/artifacts/types.d.ts +136 -0
- package/dist/artifacts/types.js +24 -0
- package/dist/artifacts/url.d.ts +67 -0
- package/dist/artifacts/url.js +116 -0
- package/dist/cache/build-cache.js +48 -4
- package/dist/cli.js +0 -0
- package/dist/commands/build/app.js +1 -2
- package/dist/commands/build/index.d.ts +17 -0
- package/dist/commands/build/index.js +66 -0
- package/dist/commands/checkout/index.d.ts +24 -0
- package/dist/commands/checkout/index.js +71 -0
- package/dist/commands/db/migrate.d.ts +11 -0
- package/dist/commands/db/migrate.js +33 -0
- package/dist/commands/index.js +8 -0
- package/dist/commands/publish/index.d.ts +21 -0
- package/dist/commands/publish/index.js +68 -0
- package/dist/config/defaults.js +15 -1
- package/dist/config/loader.js +6 -1
- package/dist/config/normalizers/declaration.js +4 -1
- package/dist/config/normalizers/page.d.ts +18 -0
- package/dist/config/normalizers/page.js +34 -0
- package/dist/config/normalizers/pattern.js +7 -1
- package/dist/config/runtime.d.ts +4 -0
- package/dist/config/runtime.js +53 -5
- package/dist/index.d.ts +65 -1
- package/dist/index.js +123 -3
- package/dist/nav/index.d.ts +83 -0
- package/dist/nav/index.js +9 -0
- package/dist/nav/registry.d.ts +15 -0
- package/dist/nav/registry.js +42 -0
- package/dist/nav/resolver.d.ts +8 -0
- package/dist/nav/resolver.js +192 -0
- package/dist/nav/workspace.d.ts +18 -0
- package/dist/nav/workspace.js +103 -0
- package/dist/pipeline/styles.js +7 -35
- package/dist/pipeline/token-transformers.d.ts +45 -0
- package/dist/pipeline/token-transformers.js +71 -0
- package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +17 -0
- package/dist/registry/asset-storage/adapters/vercel-blob.js +87 -0
- package/dist/registry/asset-storage/define.d.ts +10 -0
- package/dist/registry/asset-storage/define.js +13 -0
- package/dist/registry/asset-storage/resolve.d.ts +45 -0
- package/dist/registry/asset-storage/resolve.js +36 -0
- package/dist/registry/asset-storage/types.d.ts +51 -0
- package/dist/registry/asset-storage/types.js +9 -0
- package/dist/registry/assets/layout.d.ts +22 -0
- package/dist/registry/assets/layout.js +30 -0
- package/dist/registry/assets/sets.d.ts +18 -0
- package/dist/registry/assets/sets.js +44 -0
- package/dist/registry/assets/transfer.d.ts +45 -0
- package/dist/registry/assets/transfer.js +11 -0
- package/dist/registry/checkout/assets.d.ts +16 -0
- package/dist/registry/checkout/assets.js +150 -0
- package/dist/registry/checkout/index.d.ts +33 -0
- package/dist/registry/checkout/index.js +610 -0
- package/dist/registry/checkout/tokens.d.ts +15 -0
- package/dist/registry/checkout/tokens.js +171 -0
- package/dist/registry/client.d.ts +61 -0
- package/dist/registry/client.js +211 -0
- package/dist/registry/connection.d.ts +30 -0
- package/dist/registry/connection.js +36 -0
- package/dist/registry/db/adapter.d.ts +43 -0
- package/dist/registry/db/adapter.js +59 -0
- package/dist/registry/db/client.d.ts +35 -0
- package/dist/registry/db/client.js +94 -0
- package/dist/registry/db/index.d.ts +5 -0
- package/dist/registry/db/index.js +24 -0
- package/dist/registry/db/migrate.d.ts +23 -0
- package/dist/registry/db/migrate.js +64 -0
- package/dist/registry/db/schema.d.ts +4869 -0
- package/dist/registry/db/schema.js +347 -0
- package/dist/registry/path.d.ts +4 -0
- package/dist/registry/path.js +31 -0
- package/dist/registry/publish/artifacts.d.ts +16 -0
- package/dist/registry/publish/artifacts.js +132 -0
- package/dist/registry/publish/assets.d.ts +16 -0
- package/dist/registry/publish/assets.js +129 -0
- package/dist/registry/publish/errors.d.ts +5 -0
- package/dist/registry/publish/errors.js +26 -0
- package/dist/registry/publish/index.d.ts +27 -0
- package/dist/registry/publish/index.js +126 -0
- package/dist/registry/publish/package.d.ts +27 -0
- package/dist/registry/publish/package.js +257 -0
- package/dist/registry/publish/publish-build.d.ts +12 -0
- package/dist/registry/publish/publish-build.js +43 -0
- package/dist/registry/publish/tokens.d.ts +18 -0
- package/dist/registry/publish/tokens.js +112 -0
- package/dist/registry/tokens/sets.d.ts +59 -0
- package/dist/registry/tokens/sets.js +109 -0
- package/dist/registry/tokens/transfer.d.ts +48 -0
- package/dist/registry/tokens/transfer.js +10 -0
- package/dist/registry/transfer.d.ts +75 -0
- package/dist/registry/transfer.js +11 -0
- package/dist/store/filesystem-assets.d.ts +17 -0
- package/dist/store/filesystem-assets.js +147 -0
- package/dist/store/filesystem.d.ts +76 -0
- package/dist/store/filesystem.js +208 -0
- package/dist/store/index.d.ts +20 -0
- package/dist/store/index.js +38 -0
- package/dist/store/registry.d.ts +77 -0
- package/dist/store/registry.js +350 -0
- package/dist/store/source-files.d.ts +24 -0
- package/dist/store/source-files.js +147 -0
- package/dist/store/types.d.ts +185 -0
- package/dist/store/types.js +12 -0
- package/dist/transformers/plugins/csf-render.js +17 -8
- package/dist/transformers/plugins/handlebars-previews.js +10 -3
- package/dist/transformers/plugins/ssr-render.js +116 -49
- package/dist/transformers/preview/component/api.js +3 -1
- package/dist/transformers/preview/component/artifacts.js +8 -1
- package/dist/transformers/preview/component/builder.js +13 -5
- package/dist/transformers/preview/component/css.js +9 -1
- package/dist/transformers/preview/component/javascript.d.ts +9 -3
- package/dist/transformers/preview/component/javascript.js +45 -17
- package/dist/transformers/preview/component/shared-artifacts.d.ts +83 -0
- package/dist/transformers/preview/component/shared-artifacts.js +92 -0
- package/dist/transformers/preview/component.js +5 -2
- package/dist/transformers/preview/pattern/builder.js +28 -9
- package/dist/transformers/preview/pattern/html.d.ts +13 -10
- package/dist/transformers/preview/pattern/html.js +62 -27
- package/dist/transformers/preview/types.d.ts +38 -0
- package/dist/transformers/utils/handlebars.d.ts +3 -0
- package/dist/transformers/utils/handlebars.js +19 -5
- package/dist/types/config.d.ts +124 -31
- package/dist/utils/menu-shell.d.ts +60 -0
- package/dist/utils/menu-shell.js +221 -0
- package/dist/utils/stable-stringify.d.ts +2 -0
- package/dist/utils/stable-stringify.js +8 -0
- package/docs/cli.md +4 -2
- package/drizzle/0000_init_registry_storage.sql +95 -0
- package/drizzle/0001_add_review_metadata.sql +2 -0
- package/drizzle/0002_add_pages.sql +28 -0
- package/drizzle/0003_add_tokens.sql +29 -0
- package/drizzle/0004_add_assets.sql +37 -0
- package/drizzle/meta/0000_snapshot.json +642 -0
- package/drizzle/meta/0001_snapshot.json +654 -0
- package/drizzle/meta/0002_snapshot.json +835 -0
- package/drizzle/meta/0003_snapshot.json +1023 -0
- package/drizzle/meta/0004_snapshot.json +1252 -0
- package/drizzle/meta/_journal.json +41 -0
- package/drizzle.config.ts +16 -0
- package/package.json +10 -1
- package/src/app/components/Component/Preview.tsx +3 -2
- package/src/app/components/Component/RegistryActions.tsx +150 -0
- package/src/app/components/Foundations/DisplayLogo.tsx +11 -3
- package/src/app/components/Layout/Header.tsx +21 -3
- package/src/app/components/Layout/Main.tsx +11 -3
- package/src/app/components/Navigation/MainNav.tsx +4 -2
- package/src/app/components/Navigation/MobileNav.tsx +4 -2
- package/src/app/components/Navigation/SideNav.tsx +46 -11
- package/src/app/components/NotFound.tsx +24 -0
- package/src/app/components/context/NavProvider.tsx +71 -0
- package/src/app/components/old/Header.tsx +3 -3
- package/src/app/components/util/index.ts +203 -380
- package/src/app/components/util/useCollectionAssets.ts +125 -0
- package/src/app/components/util/useFoundationTokens.ts +67 -0
- package/src/app/generated/default-pages.json +1 -0
- package/src/app/generated/nav-shell.json +1 -0
- package/src/app/lib/api/query.ts +6 -0
- package/src/app/lib/api/redact.ts +46 -0
- package/src/app/lib/asset-storage.ts +81 -0
- package/src/app/lib/docs-api/artifacts.ts +116 -0
- package/src/app/lib/docs-api/backend.ts +151 -0
- package/src/app/lib/docs-api/errors.ts +52 -0
- package/src/app/lib/docs-api/handler.ts +34 -0
- package/src/app/lib/docs-api/index.ts +7 -0
- package/src/app/lib/docs-api/records.ts +189 -0
- package/src/app/lib/docs-api/registry.ts +130 -0
- package/src/app/lib/docs-api/runtime-config.ts +173 -0
- package/src/app/lib/docs-api/serve.ts +37 -0
- package/src/app/lib/docs-api/token-detail.ts +19 -0
- package/src/app/lib/registry-api/allowlist.ts +163 -0
- package/src/app/lib/registry-api/asset-transfer.ts +363 -0
- package/src/app/lib/registry-api/auth.ts +68 -0
- package/src/app/lib/registry-api/entity-routes.ts +197 -0
- package/src/app/lib/registry-api/errors.ts +51 -0
- package/src/app/lib/registry-api/files.ts +135 -0
- package/src/app/lib/registry-api/handler.ts +96 -0
- package/src/app/lib/registry-api/index.ts +4 -0
- package/src/app/lib/registry-api/meta.ts +87 -0
- package/src/app/lib/registry-api/revalidate.ts +36 -0
- package/src/app/lib/registry-api/store.ts +348 -0
- package/src/app/lib/registry-api/token-transfer.ts +231 -0
- package/src/app/lib/registry-api/transfer.ts +513 -0
- package/src/app/lib/registry-api/validation.ts +74 -0
- package/src/app/lib/registry-connection.ts +58 -0
- package/src/app/lib/utils.ts +14 -0
- package/src/app/next.config.mjs +72 -13
- package/src/app/pages/[...slug]/index.tsx +74 -20
- package/src/app/pages/_app.tsx +5 -2
- package/src/app/pages/api/docs/artifacts/[...artifactPath].ts +16 -0
- package/src/app/pages/api/docs/assets/[collection]/[...path].ts +50 -0
- package/src/app/pages/api/docs/assets/[collection]/index.ts +22 -0
- package/src/app/pages/api/docs/components/[id]/assets/[asset].ts +33 -0
- package/src/app/pages/api/docs/components/[id]/inspect/[previewId].ts +20 -0
- package/src/app/pages/api/docs/components/[id]/previews/[previewId].ts +21 -0
- package/src/app/pages/api/docs/components/[id].ts +24 -0
- package/src/app/pages/api/docs/components.json.ts +12 -0
- package/src/app/pages/api/docs/nav.json.ts +48 -0
- package/src/app/pages/api/docs/patterns/[id]/preview.ts +19 -0
- package/src/app/pages/api/docs/patterns/[id].ts +24 -0
- package/src/app/pages/api/docs/patterns.json.ts +12 -0
- package/src/app/pages/api/docs/tokens/components/[id].ts +30 -0
- package/src/app/pages/api/docs/tokens/foundations/[type].ts +32 -0
- package/src/app/pages/api/registry/components/[id]/files/[...filePath].ts +19 -0
- package/src/app/pages/api/registry/components/[id]/files/index.ts +19 -0
- package/src/app/pages/api/registry/components/[id].ts +20 -0
- package/src/app/pages/api/registry/components/index.ts +19 -0
- package/src/app/pages/api/registry/health.ts +20 -0
- package/src/app/pages/api/registry/patterns/[id]/files/[...filePath].ts +19 -0
- package/src/app/pages/api/registry/patterns/[id]/files/index.ts +19 -0
- package/src/app/pages/api/registry/patterns/[id].ts +20 -0
- package/src/app/pages/api/registry/patterns/index.ts +19 -0
- package/src/app/pages/api/registry/transfer/[kind]/[id].ts +39 -0
- package/src/app/pages/api/registry/transfer/assets/[collection].ts +24 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +23 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/have.ts +11 -0
- package/src/app/pages/api/registry/transfer/assets/index.ts +10 -0
- package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +26 -0
- package/src/app/pages/api/registry/transfer/tokens/index.ts +11 -0
- package/src/app/pages/assets/fonts.tsx +41 -17
- package/src/app/pages/assets/index.tsx +2 -2
- package/src/app/pages/assets/logos.tsx +6 -3
- package/src/app/pages/foundations/colors.tsx +18 -8
- package/src/app/pages/foundations/effects.tsx +18 -8
- package/src/app/pages/foundations/icons/[name]/index.tsx +28 -16
- package/src/app/pages/foundations/icons/index.tsx +18 -12
- package/src/app/pages/foundations/index.tsx +1 -1
- package/src/app/pages/foundations/logo.tsx +12 -8
- package/src/app/pages/foundations/typography.tsx +19 -9
- package/src/app/pages/index.tsx +1 -1
- package/src/app/pages/system/component/[component]/index.tsx +87 -17
- package/src/app/pages/system/index.tsx +13 -4
- package/src/app/pages/system/pattern/[pattern]/index.tsx +70 -14
- package/src/app/pages/system/tokens/components/[component]/index.tsx +42 -14
- package/src/app/pages/system/tokens/foundations/colors.tsx +7 -5
- package/src/app/pages/system/tokens/foundations/effects.tsx +7 -5
- package/src/app/pages/system/tokens/foundations/typography.tsx +8 -4
- package/src/app/tsconfig.json +2 -3
- package/src/app-builder/build.ts +555 -11
- package/src/app-builder/client-config.ts +61 -4
- package/src/app-builder/config-diff/snapshot.ts +1 -11
- package/src/app-builder/index.ts +1 -1
- package/src/app-builder/nav-shell.ts +74 -0
- package/src/app-builder/paths.ts +27 -1
- package/src/app-builder/static-export.ts +242 -0
- package/src/app-builder/vercel-output.ts +297 -0
- package/src/app-builder/watchers/utils.ts +2 -0
- package/src/app-builder/watchers.ts +149 -25
- package/src/app-builder/websocket.ts +2 -2
- package/src/artifacts/README.md +14 -0
- package/src/artifacts/index.ts +24 -0
- package/src/artifacts/types.ts +146 -0
- package/src/artifacts/url.ts +122 -0
- package/src/cache/build-cache.ts +47 -2
- package/src/commands/build/app.ts +1 -2
- package/src/commands/build/index.ts +61 -0
- package/src/commands/checkout/index.ts +66 -0
- package/src/commands/db/migrate.ts +25 -0
- package/src/commands/index.ts +8 -0
- package/src/commands/publish/index.ts +63 -0
- package/src/config/defaults.ts +15 -0
- package/src/config/loader.ts +10 -1
- package/src/config/normalizers/declaration.ts +3 -0
- package/src/config/normalizers/page.ts +45 -0
- package/src/config/normalizers/pattern.ts +4 -1
- package/src/config/runtime.ts +48 -2
- package/src/index.ts +126 -3
- package/src/nav/index.ts +94 -0
- package/src/nav/registry.ts +54 -0
- package/src/nav/resolver.ts +217 -0
- package/src/nav/workspace.ts +87 -0
- package/src/pipeline/styles.ts +4 -35
- package/src/pipeline/token-transformers.ts +97 -0
- package/src/registry/asset-storage/adapters/vercel-blob.ts +51 -0
- package/src/registry/asset-storage/define.ts +15 -0
- package/src/registry/asset-storage/resolve.ts +72 -0
- package/src/registry/asset-storage/types.ts +46 -0
- package/src/registry/assets/layout.ts +38 -0
- package/src/registry/assets/sets.ts +48 -0
- package/src/registry/assets/transfer.ts +50 -0
- package/src/registry/checkout/assets.ts +126 -0
- package/src/registry/checkout/index.ts +596 -0
- package/src/registry/checkout/tokens.ts +152 -0
- package/src/registry/client.ts +234 -0
- package/src/registry/connection.ts +50 -0
- package/src/registry/db/adapter.ts +74 -0
- package/src/registry/db/client.ts +88 -0
- package/src/registry/db/index.ts +35 -0
- package/src/registry/db/migrate.ts +56 -0
- package/src/registry/db/schema.ts +444 -0
- package/src/registry/path.ts +26 -0
- package/src/registry/publish/artifacts.ts +147 -0
- package/src/registry/publish/assets.ts +129 -0
- package/src/registry/publish/errors.ts +34 -0
- package/src/registry/publish/index.ts +128 -0
- package/src/registry/publish/package.ts +272 -0
- package/src/registry/publish/publish-build.ts +51 -0
- package/src/registry/publish/tokens.ts +119 -0
- package/src/registry/tokens/sets.ts +132 -0
- package/src/registry/tokens/transfer.ts +53 -0
- package/src/registry/transfer.ts +82 -0
- package/src/store/filesystem-assets.ts +152 -0
- package/src/store/filesystem.ts +237 -0
- package/src/store/index.ts +62 -0
- package/src/store/registry.ts +334 -0
- package/src/store/source-files.ts +155 -0
- package/src/store/types.ts +210 -0
- package/src/transformers/plugins/csf-render.ts +31 -7
- package/src/transformers/plugins/handlebars-previews.ts +22 -3
- package/src/transformers/plugins/ssr-render.ts +145 -57
- package/src/transformers/preview/component/api.ts +2 -1
- package/src/transformers/preview/component/artifacts.ts +10 -1
- package/src/transformers/preview/component/builder.ts +9 -2
- package/src/transformers/preview/component/css.ts +10 -1
- package/src/transformers/preview/component/javascript.ts +55 -20
- package/src/transformers/preview/component/shared-artifacts.ts +146 -0
- package/src/transformers/preview/component.ts +5 -2
- package/src/transformers/preview/pattern/builder.ts +33 -7
- package/src/transformers/preview/pattern/html.ts +71 -27
- package/src/transformers/preview/types.ts +42 -0
- package/src/transformers/utils/handlebars.ts +31 -5
- package/src/types/config.ts +129 -31
- package/src/utils/menu-shell.ts +241 -0
- package/src/utils/stable-stringify.ts +7 -0
- package/templates/default/package.json.tpl +1 -1
- package/handoff.config.js +0 -90
- package/src/app/components/ComponentSearch.tsx +0 -93
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { and, eq, inArray } from 'drizzle-orm';
|
|
3
|
+
import type { ArtifactKind, ArtifactOwnerKind, ArtifactReference, ArtifactReferenceKind } from '@handoff/artifacts/types';
|
|
4
|
+
import type { RegistryDatabase } from '@handoff/registry/db/client';
|
|
5
|
+
import { isSafePathSegment, isSafeRelativePath } from '@handoff/registry/path';
|
|
6
|
+
import {
|
|
7
|
+
buildMetadata,
|
|
8
|
+
componentFiles,
|
|
9
|
+
components,
|
|
10
|
+
docsArtifacts,
|
|
11
|
+
pageFiles,
|
|
12
|
+
pages,
|
|
13
|
+
patternFiles,
|
|
14
|
+
patterns,
|
|
15
|
+
} from '@handoff/registry/db/schema';
|
|
16
|
+
import type { TransferArtifact, TransferBuild, TransferEntityKind, TransferFile, TransferPackage } from '@handoff/registry/transfer';
|
|
17
|
+
import { singleQueryValue } from '../api/query';
|
|
18
|
+
import { sendRegistryError } from './errors';
|
|
19
|
+
import { validateFileBody } from './files';
|
|
20
|
+
import { handleRegistryRoute, sendRegistryData } from './handler';
|
|
21
|
+
import { buildMeta, resolveBuildMeta } from './meta';
|
|
22
|
+
import { revalidateEntityPages } from './revalidate';
|
|
23
|
+
import { getEntity, listEntityFiles } from './store';
|
|
24
|
+
import {
|
|
25
|
+
asString,
|
|
26
|
+
invalidPackage as invalid,
|
|
27
|
+
isPlainObject,
|
|
28
|
+
normalizeSafeRelativePath,
|
|
29
|
+
type PackageValidation,
|
|
30
|
+
validateTransferBuild,
|
|
31
|
+
} from './validation';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Publish ingestion for the registry transfer endpoint.
|
|
35
|
+
*
|
|
36
|
+
* `PUT /api/registry/transfer/{component|pattern}/:id` is the **only** path allowed to set
|
|
37
|
+
* render/build-defining fields, source files, rendered artifacts, and build metadata. It validates
|
|
38
|
+
* the uploaded package against the transfer contract, then ingests it under the artifact ownership &
|
|
39
|
+
* lifecycle rules:
|
|
40
|
+
*
|
|
41
|
+
* - The published entity's own record/files/artifacts are **replaced**.
|
|
42
|
+
* - Shared/global artifacts (`component/main.{css,js}`, `component/shared.css`) are **upserted when
|
|
43
|
+
* present, never deleted when omitted**, so a publish that omits them preserves them for entities
|
|
44
|
+
* that still depend on them.
|
|
45
|
+
* - A pattern may carry required component artifacts; those are upserted, not replaced, so unrelated
|
|
46
|
+
* entities' artifacts are never touched.
|
|
47
|
+
*
|
|
48
|
+
* Runs behind {@link handleRegistryRoute}, so the runtime-mode, method, bearer-token, and database
|
|
49
|
+
* guards apply before any of this executes.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const VALID_ARTIFACT_KINDS = new Set<ArtifactKind>(['json', 'html', 'css', 'javascript', 'other']);
|
|
53
|
+
const VALID_OWNER_KINDS = new Set<ArtifactOwnerKind>(['component', 'pattern', 'asset']);
|
|
54
|
+
const VALID_REFERENCE_KINDS = new Set<ArtifactReferenceKind>(['client', 'style', 'script', 'shared', 'other']);
|
|
55
|
+
|
|
56
|
+
const asStringArray = (value: unknown): string[] | undefined =>
|
|
57
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
58
|
+
|
|
59
|
+
/** Binds an entity kind to its tables and file foreign-key column. */
|
|
60
|
+
const ENTITY = {
|
|
61
|
+
component: { table: components, filesTable: componentFiles, fileFk: componentFiles.componentId, fileFkName: 'componentId' as const },
|
|
62
|
+
pattern: { table: patterns, filesTable: patternFiles, fileFk: patternFiles.patternId, fileFkName: 'patternId' as const },
|
|
63
|
+
page: { table: pages, filesTable: pageFiles, fileFk: pageFiles.pageId, fileFkName: 'pageId' as const },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate a publish package body against the transfer contract. Checks the normalized record,
|
|
68
|
+
* source-file kinds/paths (declarations rejected), artifact path-prefix/kind/owner rules, and the
|
|
69
|
+
* required build hash for `build.status: 'current'`. Required reference presence is checked later
|
|
70
|
+
* against the package + already-stored artifacts.
|
|
71
|
+
*/
|
|
72
|
+
const validatePackage = (body: unknown, kind: TransferEntityKind, id: string): PackageValidation<TransferPackage> => {
|
|
73
|
+
const validId = kind === 'page' ? isSafeRelativePath(id) : isSafePathSegment(id);
|
|
74
|
+
if (!validId) {
|
|
75
|
+
return invalid('Publish target id must be a registry-safe relative path.', { rejectedFields: ['id'] });
|
|
76
|
+
}
|
|
77
|
+
if (!isPlainObject(body)) {
|
|
78
|
+
return invalid('Request body must be a JSON object.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const item = body.item;
|
|
82
|
+
if (!isPlainObject(item)) {
|
|
83
|
+
return invalid('A normalized `item` record is required to publish.', { rejectedFields: ['item'] });
|
|
84
|
+
}
|
|
85
|
+
const itemId = asString(item.id);
|
|
86
|
+
if (itemId !== undefined && itemId !== id) {
|
|
87
|
+
return invalid(`Package item id "${itemId}" does not match the publish target "${id}".`, { rejectedFields: ['item.id'] });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rawFiles = body.files ?? [];
|
|
91
|
+
if (!Array.isArray(rawFiles)) {
|
|
92
|
+
return invalid('`files` must be an array.', { rejectedFields: ['files'] });
|
|
93
|
+
}
|
|
94
|
+
const files: TransferFile[] = [];
|
|
95
|
+
const seenFilePaths = new Set<string>();
|
|
96
|
+
for (let i = 0; i < rawFiles.length; i += 1) {
|
|
97
|
+
const validation = validateFileBody(rawFiles[i]);
|
|
98
|
+
if (!validation.ok) {
|
|
99
|
+
return invalid(validation.message ?? 'Invalid source file in package.', {
|
|
100
|
+
rejectedFields: (validation.rejectedFields ?? []).map((field) => `files[${i}].${field}`),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (seenFilePaths.has(validation.value.path)) {
|
|
104
|
+
return invalid(`Duplicate source file path "${validation.value.path}" in package.`, {
|
|
105
|
+
rejectedFields: [`files[${i}].path`],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
seenFilePaths.add(validation.value.path);
|
|
109
|
+
files.push(validation.value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rawArtifacts = body.artifacts ?? [];
|
|
113
|
+
if (!Array.isArray(rawArtifacts)) {
|
|
114
|
+
return invalid('`artifacts` must be an array.', { rejectedFields: ['artifacts'] });
|
|
115
|
+
}
|
|
116
|
+
if (kind === 'page' && rawArtifacts.length > 0) {
|
|
117
|
+
return invalid('Page publishes cannot contain rendered artifacts.', { rejectedFields: ['artifacts'] });
|
|
118
|
+
}
|
|
119
|
+
const artifacts: TransferArtifact[] = [];
|
|
120
|
+
const seenPaths = new Set<string>();
|
|
121
|
+
for (let i = 0; i < rawArtifacts.length; i += 1) {
|
|
122
|
+
const raw = rawArtifacts[i];
|
|
123
|
+
const field = (name: string) => `artifacts[${i}].${name}`;
|
|
124
|
+
if (!isPlainObject(raw)) {
|
|
125
|
+
return invalid('Each artifact must be an object.', { rejectedFields: [`artifacts[${i}]`] });
|
|
126
|
+
}
|
|
127
|
+
const artifactPath = normalizeSafeRelativePath(raw.path);
|
|
128
|
+
if (!artifactPath) {
|
|
129
|
+
return invalid('Artifact path must be a registry-safe relative path.', { rejectedFields: [field('path')] });
|
|
130
|
+
}
|
|
131
|
+
const allowedPrefixes = kind === 'component' ? ['component/'] : ['pattern/', 'component/'];
|
|
132
|
+
if (!allowedPrefixes.some((prefix) => artifactPath.startsWith(prefix))) {
|
|
133
|
+
return invalid(
|
|
134
|
+
`Artifact "${artifactPath}" is not allowed for a ${kind} publish (expected a path under ${allowedPrefixes
|
|
135
|
+
.map((p) => `"${p}"`)
|
|
136
|
+
.join(' or ')}).`,
|
|
137
|
+
{ rejectedFields: [field('path')] }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (seenPaths.has(artifactPath)) {
|
|
141
|
+
return invalid(`Duplicate artifact path "${artifactPath}" in package.`, { rejectedFields: [field('path')] });
|
|
142
|
+
}
|
|
143
|
+
seenPaths.add(artifactPath);
|
|
144
|
+
|
|
145
|
+
const artifactKind = asString(raw.artifactKind) as ArtifactKind | undefined;
|
|
146
|
+
if (!artifactKind || !VALID_ARTIFACT_KINDS.has(artifactKind)) {
|
|
147
|
+
return invalid(`Artifact "${artifactPath}" has an invalid artifactKind.`, { rejectedFields: [field('artifactKind')] });
|
|
148
|
+
}
|
|
149
|
+
if (typeof raw.content !== 'string') {
|
|
150
|
+
return invalid(`Artifact "${artifactPath}" is missing string content.`, { rejectedFields: [field('content')] });
|
|
151
|
+
}
|
|
152
|
+
const ownerKind = (asString(raw.ownerKind) as ArtifactOwnerKind | undefined) ?? undefined;
|
|
153
|
+
if (!ownerKind || !VALID_OWNER_KINDS.has(ownerKind)) {
|
|
154
|
+
return invalid(`Artifact "${artifactPath}" has an invalid ownerKind.`, { rejectedFields: [field('ownerKind')] });
|
|
155
|
+
}
|
|
156
|
+
const ownerId = ownerKind === 'asset' ? null : (asString(raw.ownerId) ?? null);
|
|
157
|
+
if (ownerKind !== 'asset' && !ownerId) {
|
|
158
|
+
return invalid(`Artifact "${artifactPath}" must name an ownerId for owner kind "${ownerKind}".`, {
|
|
159
|
+
rejectedFields: [field('ownerId')],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (ownerKind === 'component' && kind === 'component' && ownerId !== id) {
|
|
163
|
+
return invalid(`Artifact "${artifactPath}" cannot be owned by another component.`, {
|
|
164
|
+
rejectedFields: [field('ownerId')],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (ownerKind === 'pattern' && (kind !== 'pattern' || ownerId !== id)) {
|
|
168
|
+
return invalid(`Artifact "${artifactPath}" must be owned by the target pattern.`, {
|
|
169
|
+
rejectedFields: [field('ownerId')],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (ownerKind === 'component' && !artifactPath.startsWith('component/')) {
|
|
173
|
+
return invalid(`Component artifact "${artifactPath}" must use the component path prefix.`, {
|
|
174
|
+
rejectedFields: [field('path')],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (ownerKind === 'pattern' && !artifactPath.startsWith('pattern/')) {
|
|
178
|
+
return invalid(`Pattern artifact "${artifactPath}" must use the pattern path prefix.`, {
|
|
179
|
+
rejectedFields: [field('path')],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let references: ArtifactReference[] | undefined;
|
|
184
|
+
if (raw.references !== undefined) {
|
|
185
|
+
if (!Array.isArray(raw.references)) {
|
|
186
|
+
return invalid(`Artifact "${artifactPath}" references must be an array.`, {
|
|
187
|
+
rejectedFields: [field('references')],
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
references = [];
|
|
191
|
+
for (let referenceIndex = 0; referenceIndex < raw.references.length; referenceIndex += 1) {
|
|
192
|
+
const reference = raw.references[referenceIndex];
|
|
193
|
+
const referenceField = (name: string) => `${field('references')}[${referenceIndex}].${name}`;
|
|
194
|
+
if (!isPlainObject(reference)) {
|
|
195
|
+
return invalid(`Artifact "${artifactPath}" contains an invalid reference.`, {
|
|
196
|
+
rejectedFields: [`${field('references')}[${referenceIndex}]`],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const referencePath = normalizeSafeRelativePath(reference.path);
|
|
200
|
+
const referenceKind = asString(reference.kind) as ArtifactReferenceKind | undefined;
|
|
201
|
+
if (!referencePath) {
|
|
202
|
+
return invalid(`Artifact "${artifactPath}" contains an unsafe reference path.`, {
|
|
203
|
+
rejectedFields: [referenceField('path')],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (!referenceKind || !VALID_REFERENCE_KINDS.has(referenceKind)) {
|
|
207
|
+
return invalid(`Artifact "${artifactPath}" contains an invalid reference kind.`, {
|
|
208
|
+
rejectedFields: [referenceField('kind')],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (typeof reference.required !== 'boolean') {
|
|
212
|
+
return invalid(`Artifact "${artifactPath}" reference must declare whether it is required.`, {
|
|
213
|
+
rejectedFields: [referenceField('required')],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const referenceOwnerKind = asString(reference.ownerKind) as ArtifactOwnerKind | undefined;
|
|
217
|
+
if (referenceOwnerKind && !VALID_OWNER_KINDS.has(referenceOwnerKind)) {
|
|
218
|
+
return invalid(`Artifact "${artifactPath}" contains an invalid reference owner.`, {
|
|
219
|
+
rejectedFields: [referenceField('ownerKind')],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
references.push({
|
|
223
|
+
path: referencePath,
|
|
224
|
+
kind: referenceKind,
|
|
225
|
+
required: reference.required,
|
|
226
|
+
...(referenceOwnerKind ? { ownerKind: referenceOwnerKind } : {}),
|
|
227
|
+
...(referenceOwnerKind ? { ownerId: referenceOwnerKind === 'asset' ? null : (asString(reference.ownerId) ?? null) } : {}),
|
|
228
|
+
...(asString(reference.contentType) ? { contentType: asString(reference.contentType) } : {}),
|
|
229
|
+
...(asString(reference.formatVersion) ? { formatVersion: asString(reference.formatVersion) } : {}),
|
|
230
|
+
...(asString(reference.buildId) ? { buildId: asString(reference.buildId) } : {}),
|
|
231
|
+
...(asString(reference.hash) ? { hash: asString(reference.hash) } : {}),
|
|
232
|
+
...(typeof reference.size === 'number' ? { size: reference.size } : {}),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
artifacts.push({
|
|
238
|
+
path: artifactPath,
|
|
239
|
+
artifactKind,
|
|
240
|
+
content: raw.content as string,
|
|
241
|
+
contentType: asString(raw.contentType) || 'text/plain; charset=utf-8',
|
|
242
|
+
ownerKind,
|
|
243
|
+
ownerId,
|
|
244
|
+
references,
|
|
245
|
+
formatVersion: asString(raw.formatVersion),
|
|
246
|
+
hash: asString(raw.hash),
|
|
247
|
+
size: typeof raw.size === 'number' ? raw.size : undefined,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const buildValidation = validateTransferBuild(body.build, {
|
|
252
|
+
requiredHashField: kind === 'page' ? 'sourceHash' : 'artifactHash',
|
|
253
|
+
currentMessage:
|
|
254
|
+
kind === 'page'
|
|
255
|
+
? 'A "current" page build requires builtAt, builderVersion, and sourceHash.'
|
|
256
|
+
: 'A "current" build requires builtAt, builderVersion, and artifactHash.',
|
|
257
|
+
});
|
|
258
|
+
if (!buildValidation.ok) {
|
|
259
|
+
return invalid(buildValidation.message, buildValidation.details);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { ok: true, value: { item, files, artifacts, build: buildValidation.value } };
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Ensure every **required** structured reference is satisfied — present in the package or already
|
|
267
|
+
* stored — so a published HTML artifact never depends on a missing required artifact.
|
|
268
|
+
*/
|
|
269
|
+
const findMissingRequiredReference = async (
|
|
270
|
+
db: RegistryDatabase,
|
|
271
|
+
pkg: TransferPackage
|
|
272
|
+
): Promise<{ artifact: string; reference: string } | null> => {
|
|
273
|
+
const packagePaths = new Set(pkg.artifacts.map((artifact) => artifact.path));
|
|
274
|
+
const requiredReferences = pkg.artifacts.flatMap((artifact) =>
|
|
275
|
+
(artifact.references ?? [])
|
|
276
|
+
.filter((reference) => reference.required)
|
|
277
|
+
.map((reference) => ({ artifact: artifact.path, reference: reference.path }))
|
|
278
|
+
);
|
|
279
|
+
const storedCandidates = Array.from(
|
|
280
|
+
new Set(requiredReferences.map(({ reference }) => reference).filter((path) => !packagePaths.has(path)))
|
|
281
|
+
);
|
|
282
|
+
const storedPaths = new Set<string>();
|
|
283
|
+
if (storedCandidates.length > 0) {
|
|
284
|
+
const rows = await db
|
|
285
|
+
.select({ path: docsArtifacts.path, content: docsArtifacts.content })
|
|
286
|
+
.from(docsArtifacts)
|
|
287
|
+
.where(inArray(docsArtifacts.path, storedCandidates));
|
|
288
|
+
rows.forEach(({ path, content }) => {
|
|
289
|
+
if (content != null) {
|
|
290
|
+
storedPaths.add(path);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const required of requiredReferences) {
|
|
296
|
+
if (!packagePaths.has(required.reference) && !storedPaths.has(required.reference)) {
|
|
297
|
+
return required;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** Upsert the entity's normalized record + promoted columns, preserving existing review metadata. */
|
|
304
|
+
const upsertEntityRecord = async (
|
|
305
|
+
db: RegistryDatabase,
|
|
306
|
+
kind: TransferEntityKind,
|
|
307
|
+
id: string,
|
|
308
|
+
item: Record<string, unknown>
|
|
309
|
+
): Promise<void> => {
|
|
310
|
+
const spec = ENTITY[kind];
|
|
311
|
+
const record = { ...item, id };
|
|
312
|
+
const existing = await db.select({ id: spec.table.id }).from(spec.table).where(eq(spec.table.id, id)).limit(1);
|
|
313
|
+
|
|
314
|
+
// `tags` is promoted on components/patterns only; the pages table has no such column.
|
|
315
|
+
const base = {
|
|
316
|
+
path: asString(item.path) || `${kind}/${id}`,
|
|
317
|
+
title: asString(item.title),
|
|
318
|
+
description: asString(item.description),
|
|
319
|
+
group: asString(item.group),
|
|
320
|
+
record,
|
|
321
|
+
updatedAt: new Date(),
|
|
322
|
+
};
|
|
323
|
+
const values =
|
|
324
|
+
kind === 'component'
|
|
325
|
+
? {
|
|
326
|
+
...base,
|
|
327
|
+
tags: asStringArray(item.tags),
|
|
328
|
+
type: asString(item.type) ?? '',
|
|
329
|
+
renderer: asString(item.renderer),
|
|
330
|
+
categories: asStringArray(item.categories),
|
|
331
|
+
}
|
|
332
|
+
: kind === 'pattern'
|
|
333
|
+
? { ...base, tags: asStringArray(item.tags), components: Array.isArray(item.components) ? item.components : [] }
|
|
334
|
+
: { ...base, weight: typeof item.weight === 'number' ? item.weight : null };
|
|
335
|
+
|
|
336
|
+
if (existing[0]) {
|
|
337
|
+
await db
|
|
338
|
+
.update(spec.table)
|
|
339
|
+
.set(values as any)
|
|
340
|
+
.where(eq(spec.table.id, id));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await db.insert(spec.table).values({ id, metadata: null, ...values } as any);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** Replace the entity's source files: drop all existing, then insert the package's files. */
|
|
347
|
+
const replaceEntityFiles = async (db: RegistryDatabase, kind: TransferEntityKind, id: string, files: TransferFile[]): Promise<void> => {
|
|
348
|
+
const spec = ENTITY[kind];
|
|
349
|
+
await db.delete(spec.filesTable).where(eq(spec.fileFk, id));
|
|
350
|
+
for (const file of files) {
|
|
351
|
+
await db.insert(spec.filesTable).values({
|
|
352
|
+
[spec.fileFkName]: id,
|
|
353
|
+
path: file.path,
|
|
354
|
+
kind: file.kind,
|
|
355
|
+
content: file.content,
|
|
356
|
+
contentType: file.contentType,
|
|
357
|
+
} as any);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Ingest the package's artifacts under the lifecycle rules: replace the publishing entity's own
|
|
363
|
+
* artifacts (delete its prior set, then upsert), while shared and cross-entity (pattern-carried
|
|
364
|
+
* component) artifacts are upserted by path without disturbing anything they do not key.
|
|
365
|
+
*/
|
|
366
|
+
const ingestArtifacts = async (
|
|
367
|
+
db: RegistryDatabase,
|
|
368
|
+
kind: TransferEntityKind,
|
|
369
|
+
id: string,
|
|
370
|
+
artifacts: TransferArtifact[]
|
|
371
|
+
): Promise<void> => {
|
|
372
|
+
// Replace only the publishing entity's own artifacts; shared (`asset`) and other entities' rows are
|
|
373
|
+
// keyed by a different (entityKind, entityId) and are never deleted here.
|
|
374
|
+
await db.delete(docsArtifacts).where(and(eq(docsArtifacts.entityKind, kind), eq(docsArtifacts.entityId, id)));
|
|
375
|
+
|
|
376
|
+
for (const artifact of artifacts) {
|
|
377
|
+
const entityKind = artifact.ownerKind;
|
|
378
|
+
const entityId = artifact.ownerKind === 'asset' ? null : artifact.ownerId;
|
|
379
|
+
await db
|
|
380
|
+
.insert(docsArtifacts)
|
|
381
|
+
.values({
|
|
382
|
+
path: artifact.path,
|
|
383
|
+
entityKind,
|
|
384
|
+
entityId,
|
|
385
|
+
artifactKind: artifact.artifactKind,
|
|
386
|
+
content: artifact.content,
|
|
387
|
+
contentType: artifact.contentType,
|
|
388
|
+
ownerKind: artifact.ownerKind,
|
|
389
|
+
ownerId: artifact.ownerId,
|
|
390
|
+
references: artifact.references ?? null,
|
|
391
|
+
formatVersion: artifact.formatVersion ?? null,
|
|
392
|
+
hash: artifact.hash ?? null,
|
|
393
|
+
size: artifact.size ?? null,
|
|
394
|
+
} as any)
|
|
395
|
+
.onConflictDoUpdate({
|
|
396
|
+
target: docsArtifacts.path,
|
|
397
|
+
set: {
|
|
398
|
+
entityKind,
|
|
399
|
+
entityId,
|
|
400
|
+
artifactKind: artifact.artifactKind,
|
|
401
|
+
content: artifact.content,
|
|
402
|
+
contentType: artifact.contentType,
|
|
403
|
+
ownerKind: artifact.ownerKind,
|
|
404
|
+
ownerId: artifact.ownerId,
|
|
405
|
+
references: artifact.references ?? null,
|
|
406
|
+
formatVersion: artifact.formatVersion ?? null,
|
|
407
|
+
hash: artifact.hash ?? null,
|
|
408
|
+
size: artifact.size ?? null,
|
|
409
|
+
updatedAt: new Date(),
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/** Upsert the entity's build metadata. */
|
|
416
|
+
const upsertBuildMetadata = async (db: RegistryDatabase, kind: TransferEntityKind, id: string, build: TransferBuild): Promise<void> => {
|
|
417
|
+
const values = {
|
|
418
|
+
entityKind: kind,
|
|
419
|
+
entityId: id,
|
|
420
|
+
status: build.status,
|
|
421
|
+
builtAt: build.builtAt ? new Date(build.builtAt) : null,
|
|
422
|
+
builderVersion: build.builderVersion ?? null,
|
|
423
|
+
artifactHash: build.artifactHash ?? null,
|
|
424
|
+
sourceHash: build.sourceHash ?? null,
|
|
425
|
+
warnings: build.warnings ?? null,
|
|
426
|
+
error: build.error ?? null,
|
|
427
|
+
updatedAt: new Date(),
|
|
428
|
+
};
|
|
429
|
+
await db
|
|
430
|
+
.insert(buildMetadata)
|
|
431
|
+
.values(values as any)
|
|
432
|
+
.onConflictDoUpdate({ target: [buildMetadata.entityKind, buildMetadata.entityId], set: values as any });
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Handle `GET /api/registry/transfer/{component|pattern}/:id` — checkout read. Returns the
|
|
437
|
+
* normalized record plus its registry-safe source files so a connected workspace can reconstruct
|
|
438
|
+
* the entity locally. Declaration files are workspace-only: registry stores never hold them, but
|
|
439
|
+
* they are filtered defensively so checkout never receives one. The read is unauthenticated,
|
|
440
|
+
* running behind the registry-runtime + method guards only.
|
|
441
|
+
*/
|
|
442
|
+
export const handleCheckoutRoute = (req: NextApiRequest, res: NextApiResponse, kind: TransferEntityKind): Promise<void> =>
|
|
443
|
+
handleRegistryRoute(req, res, ['GET'], async ({ db }) => {
|
|
444
|
+
const id = singleQueryValue(req.query.id);
|
|
445
|
+
if (!id) {
|
|
446
|
+
sendRegistryError(res, 'not_found', `Missing ${kind} id.`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const validId = kind === 'page' ? isSafeRelativePath(id) : isSafePathSegment(id);
|
|
450
|
+
if (!validId) {
|
|
451
|
+
sendRegistryError(res, 'bad_request', 'Entity id must be a registry-safe relative path.', {
|
|
452
|
+
rejectedFields: ['id'],
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const entity = await getEntity(db, kind, id);
|
|
458
|
+
if (!entity) {
|
|
459
|
+
sendRegistryError(res, 'not_found', `No ${kind} "${id}" exists in the registry.`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Declarations are workspace-only and are never persisted as registry file records (their kind
|
|
464
|
+
// is excluded from `RegistryTextFileKind`), so the stored files are already declaration-free.
|
|
465
|
+
const files = await listEntityFiles(db, kind, id);
|
|
466
|
+
|
|
467
|
+
sendRegistryData(res, 200, { kind, item: entity.data, files }, buildMeta(entity.build));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Handle `PUT /api/registry/transfer/{component|pattern}/:id` — validate and ingest a publish
|
|
472
|
+
* package. Registry-runtime only; the bearer token is required (enforced by the guard stack).
|
|
473
|
+
*/
|
|
474
|
+
export const handleTransferRoute = (req: NextApiRequest, res: NextApiResponse, kind: TransferEntityKind): Promise<void> =>
|
|
475
|
+
handleRegistryRoute(req, res, ['PUT'], async ({ db }) => {
|
|
476
|
+
const id = singleQueryValue(req.query.id);
|
|
477
|
+
if (!id) {
|
|
478
|
+
sendRegistryError(res, 'not_found', `Missing ${kind} id.`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const validation = validatePackage(req.body, kind, id);
|
|
483
|
+
if (!validation.ok) {
|
|
484
|
+
sendRegistryError(res, 'bad_request', validation.message ?? 'Invalid publish package.', validation.details);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const pkg = validation.value;
|
|
488
|
+
|
|
489
|
+
const missing = await findMissingRequiredReference(db, pkg);
|
|
490
|
+
if (missing) {
|
|
491
|
+
sendRegistryError(
|
|
492
|
+
res,
|
|
493
|
+
'bad_request',
|
|
494
|
+
`Required artifact "${missing.reference}" referenced by "${missing.artifact}" is neither in the package nor already published.`,
|
|
495
|
+
{ rejectedFields: ['artifacts'], missingReference: missing.reference }
|
|
496
|
+
);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await db.transaction(async (tx) => {
|
|
501
|
+
const transactionalDb = tx as unknown as RegistryDatabase;
|
|
502
|
+
await upsertEntityRecord(transactionalDb, kind, id, pkg.item);
|
|
503
|
+
await replaceEntityFiles(transactionalDb, kind, id, pkg.files);
|
|
504
|
+
await ingestArtifacts(transactionalDb, kind, id, pkg.artifacts);
|
|
505
|
+
await upsertBuildMetadata(transactionalDb, kind, id, pkg.build);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// The publish persisted; regenerate the affected docs pages on demand so the served pages reflect
|
|
509
|
+
// the new content immediately (correct server-rendered `<head>` title/metadata).
|
|
510
|
+
await revalidateEntityPages(res, kind, id);
|
|
511
|
+
|
|
512
|
+
sendRegistryData(res, 200, { id, kind, published: true }, buildMeta(await resolveBuildMeta(db, kind, id)));
|
|
513
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ArtifactBuildStatus } from '@handoff/artifacts/types';
|
|
2
|
+
import { isSafeRelativePath, normalizeRelativePath } from '@handoff/registry/path';
|
|
3
|
+
import type { TransferBuild } from '@handoff/registry/transfer';
|
|
4
|
+
import type { RegistryErrorDetails } from './errors';
|
|
5
|
+
|
|
6
|
+
const REGISTRY_BUILD_STATUSES: readonly ArtifactBuildStatus[] = ['current', 'stale', 'missing', 'error'];
|
|
7
|
+
|
|
8
|
+
export const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
9
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
|
|
11
|
+
export const asString = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
|
|
12
|
+
|
|
13
|
+
const isRegistryBuildStatus = (value: unknown): value is ArtifactBuildStatus =>
|
|
14
|
+
typeof value === 'string' && REGISTRY_BUILD_STATUSES.includes(value as ArtifactBuildStatus);
|
|
15
|
+
|
|
16
|
+
export const normalizeSafeRelativePath = (value: unknown): string | undefined => {
|
|
17
|
+
const candidate = asString(value);
|
|
18
|
+
return candidate && isSafeRelativePath(candidate) ? normalizeRelativePath(candidate) : undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const isSha256Hash = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
|
|
22
|
+
|
|
23
|
+
export interface PackageValidation<T> {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
message?: string;
|
|
26
|
+
details?: RegistryErrorDetails;
|
|
27
|
+
value?: T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const invalidPackage = <T = never>(message: string, details?: RegistryErrorDetails): PackageValidation<T> => ({
|
|
31
|
+
ok: false,
|
|
32
|
+
message,
|
|
33
|
+
details,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
interface TransferBuildValidationOptions {
|
|
37
|
+
currentMessage: string;
|
|
38
|
+
requiredHashField: 'artifactHash' | 'sourceHash';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Validate and normalize the build block shared by entity, token, and asset transfer packages. */
|
|
42
|
+
export const validateTransferBuild = (value: unknown, options: TransferBuildValidationOptions): PackageValidation<TransferBuild> => {
|
|
43
|
+
if (!isPlainObject(value)) {
|
|
44
|
+
return invalidPackage('`build` metadata is required to publish.', { rejectedFields: ['build'] });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const status = value.status;
|
|
48
|
+
if (!isRegistryBuildStatus(status)) {
|
|
49
|
+
return invalidPackage('`build.status` must be one of current|stale|missing|error.', {
|
|
50
|
+
rejectedFields: ['build.status'],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const build: TransferBuild = {
|
|
55
|
+
status,
|
|
56
|
+
builtAt: asString(value.builtAt),
|
|
57
|
+
builderVersion: asString(value.builderVersion),
|
|
58
|
+
artifactHash: asString(value.artifactHash),
|
|
59
|
+
sourceHash: asString(value.sourceHash),
|
|
60
|
+
warnings:
|
|
61
|
+
Array.isArray(value.warnings) && value.warnings.every((warning) => typeof warning === 'string')
|
|
62
|
+
? (value.warnings as string[])
|
|
63
|
+
: undefined,
|
|
64
|
+
error: asString(value.error),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (status === 'current' && (!build.builtAt || !build.builderVersion || !build[options.requiredHashField])) {
|
|
68
|
+
return invalidPackage(options.currentMessage, {
|
|
69
|
+
rejectedFields: ['build.builtAt', 'build.builderVersion', `build.${options.requiredHashField}`],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { ok: true, value: build };
|
|
74
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createRegistryDbConnection, type RegistryDbConnection } from '@handoff/registry/db/client';
|
|
2
|
+
import { getServerRuntimeConfig } from './docs-api/runtime-config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared, process-lifetime registry database connection.
|
|
6
|
+
*
|
|
7
|
+
* Both the registry-mode docs read API and the registry management API resolve their database
|
|
8
|
+
* through this single helper so one connection pool backs the whole registry runtime rather than
|
|
9
|
+
* one pool per consumer. The connection string is read from the configured env var *name* at
|
|
10
|
+
* request time — never persisted in config — and the selected adapter comes from the server runtime
|
|
11
|
+
* config baked at build time.
|
|
12
|
+
*
|
|
13
|
+
* Server-only: imported exclusively by registry-mode API route helpers, which are themselves only
|
|
14
|
+
* reached after the runtime-mode guard, so the Drizzle/Postgres driver code never loads in
|
|
15
|
+
* workspace dev/build.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Thrown when the registry database cannot be reached — a missing connection-string env var or a
|
|
20
|
+
* failed connect. Distinct from generic failures so callers can map it to the management API's
|
|
21
|
+
* `database_unavailable` (503) contract rather than a `500`.
|
|
22
|
+
*/
|
|
23
|
+
export class RegistryConnectionError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'RegistryConnectionError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let connectionPromise: Promise<RegistryDbConnection> | null = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open (or reuse) the registry database connection. A missing env var surfaces as an actionable
|
|
34
|
+
* {@link RegistryConnectionError} rather than a deep driver failure. A failed connect is not cached,
|
|
35
|
+
* so a later request can retry once the database/env is available.
|
|
36
|
+
*/
|
|
37
|
+
export const getRegistryConnection = (): Promise<RegistryDbConnection> => {
|
|
38
|
+
if (connectionPromise) {
|
|
39
|
+
return connectionPromise;
|
|
40
|
+
}
|
|
41
|
+
const { registry } = getServerRuntimeConfig();
|
|
42
|
+
const connectionString = process.env[registry.databaseUrlEnv]?.trim();
|
|
43
|
+
if (!connectionString) {
|
|
44
|
+
return Promise.reject(
|
|
45
|
+
new RegistryConnectionError(
|
|
46
|
+
`Registry database URL is not configured. Set the "${registry.databaseUrlEnv}" environment ` +
|
|
47
|
+
`variable to a PostgreSQL connection string to serve the registry API.`
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
connectionPromise = createRegistryDbConnection({ adapter: registry.adapter, connectionString }).catch((error) => {
|
|
52
|
+
connectionPromise = null;
|
|
53
|
+
// TODO: Route server errors through the shared logger.
|
|
54
|
+
console.error('Registry database connection failed.', error);
|
|
55
|
+
throw new RegistryConnectionError('Registry database is unavailable.');
|
|
56
|
+
});
|
|
57
|
+
return connectionPromise;
|
|
58
|
+
};
|
package/src/app/lib/utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { clsx, type ClassValue } from 'clsx';
|
|
2
2
|
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
import type { SectionLink } from '../components/util';
|
|
3
4
|
|
|
4
5
|
export function cn(...inputs: ClassValue[]) {
|
|
5
6
|
return twMerge(clsx(inputs));
|
|
@@ -24,3 +25,16 @@ export const normalizePathForMatch = (input: string): string => {
|
|
|
24
25
|
* @returns
|
|
25
26
|
*/
|
|
26
27
|
export const filterOutNull = <T>(value: T): value is NonNullable<T> => value !== null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a section has any side-nav content worth reserving the left column for. Mirrors what
|
|
31
|
+
* `SideNav` actually renders: a subsection contributes only if it has a non-empty `menu` or is a
|
|
32
|
+
* `dynamic` registry slot (filled client-side from the live entity lists). A section whose
|
|
33
|
+
* subsections are all empty (e.g. a standalone page with no children) renders nothing, so the
|
|
34
|
+
* caller drops the sidebar gutter and lets the content go full width.
|
|
35
|
+
*
|
|
36
|
+
* Lives here (not in `components/util`) so it is importable as a runtime value by client modules —
|
|
37
|
+
* `components/util` pulls in server-only deps (`fs`, the docs backend) and must stay type-only there.
|
|
38
|
+
*/
|
|
39
|
+
export const hasRenderableNav = (section?: SectionLink | null): boolean =>
|
|
40
|
+
!!section?.subSections?.some((sub) => (sub.menu && sub.menu.length > 0) || !!sub.dynamic);
|