@happyvertical/smrt-content 0.51.0 → 0.51.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.
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { A as normalizeContentTransparency, nt as resolveEffectiveContentGovernance } from "./content-query-BsGgJ4XY.js";
2
+ import { A as normalizeContentTransparency, nt as resolveEffectiveContentGovernance } from "./content-query-BfnrHwlW.js";
3
3
  import { SmrtCollection, SmrtObject, foreignKey, smrt } from "@happyvertical/smrt-core";
4
4
  import { TenantScoped, tenantId } from "@happyvertical/smrt-tenancy";
5
5
  //#region src/content-version.ts
@@ -284,4 +284,4 @@ var ContentVersionCollection = class extends SmrtCollection {
284
284
  //#endregion
285
285
  export { content_versions_exports as n, ContentVersion as r, ContentVersionCollection as t };
286
286
 
287
- //# sourceMappingURL=content-versions-C6K6FtcF.js.map
287
+ //# sourceMappingURL=content-versions-DCwXHfgo.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"content-versions-C6K6FtcF.js","names":[],"sources":["../../src/content-version.ts","../../src/content-versions.ts"],"sourcesContent":["import type { SmrtObjectOptions } from '@happyvertical/smrt-core';\nimport { foreignKey, SmrtObject, smrt } from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\nimport type { ContentVersionKind } from './content-governance';\nimport { normalizeContentTransparency } from './content-transparency';\n\n/**\n * Shape of the loosely-structured `metadata` JSON bag stored on a content\n * version. Known fields are typed; unrecognized keys remain accessible via the\n * index signature.\n */\nexport interface ContentVersionMetadata {\n /**\n * Fingerprint of the snapshot captured for the latest publication version,\n * used to detect post-publication drift.\n */\n publicationSnapshotFingerprint?: string;\n [key: string]: unknown;\n}\n\nexport interface ContentVersionOptions extends SmrtObjectOptions {\n contentId?: string;\n version?: number;\n kind?: ContentVersionKind;\n title?: string;\n description?: string;\n body?: string;\n status?: string;\n summary?: string;\n snapshot?: string | Record<string, unknown>;\n metadata?: string | Record<string, unknown>;\n tenantId?: string | null;\n createdAt?: Date;\n updatedAt?: Date;\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'content_versions',\n conflictColumns: ['content_id', 'version'],\n api: {\n include: ['list', 'get', 'create', 'getTransparencyAction'],\n routes: {\n getTransparencyAction: { method: 'GET', path: 'transparency' },\n },\n },\n mcp: { include: ['list', 'get', 'create'] },\n cli: true,\n})\nexport class ContentVersion extends SmrtObject {\n @foreignKey('Content', { required: true, onDelete: 'CASCADE' })\n contentId = '';\n\n version = 1;\n kind: ContentVersionKind = 'manual';\n title = '';\n description = '';\n body = '';\n status = 'draft';\n summary = '';\n snapshot = '{}';\n metadata = '';\n\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n createdAt = new Date();\n updatedAt = new Date();\n\n constructor(options: ContentVersionOptions = {}) {\n super(options);\n if (options.contentId) this.contentId = options.contentId;\n if (options.version !== undefined) this.version = options.version;\n if (options.kind !== undefined) this.kind = options.kind;\n if (options.title !== undefined) this.title = options.title;\n if (options.description !== undefined)\n this.description = options.description;\n if (options.body !== undefined) this.body = options.body;\n if (options.status !== undefined) this.status = options.status;\n if (options.summary !== undefined) this.summary = options.summary;\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.createdAt) this.createdAt = options.createdAt;\n if (options.updatedAt) this.updatedAt = options.updatedAt;\n\n if (options.snapshot !== undefined) {\n this.snapshot =\n typeof options.snapshot === 'string'\n ? options.snapshot\n : JSON.stringify(options.snapshot);\n }\n\n if (options.metadata !== undefined) {\n this.metadata =\n typeof options.metadata === 'string'\n ? options.metadata\n : JSON.stringify(options.metadata);\n }\n }\n\n getSnapshot(): Record<string, unknown> {\n try {\n return this.snapshot ? JSON.parse(this.snapshot) : {};\n } catch {\n return {};\n }\n }\n\n getMetadata(): ContentVersionMetadata {\n try {\n return this.metadata ? JSON.parse(this.metadata) : {};\n } catch {\n return {};\n }\n }\n\n getTransparency() {\n const metadata = this.getMetadata();\n const snapshot = this.getSnapshot();\n\n return normalizeContentTransparency(metadata.transparency, {\n snapshotKind: this.kind === 'publication' ? 'published' : 'preview',\n contentId: this.contentId || (snapshot.contentId as string) || null,\n currentContentStatus:\n this.status || (snapshot.status as string) || 'draft',\n publicationVersion: {\n id: (this.id as string) || null,\n version: this.version ?? null,\n kind: this.kind || null,\n summary: this.summary || '',\n createdAt:\n this.createdAt instanceof Date ? this.createdAt.toISOString() : null,\n },\n });\n }\n\n async getTransparencyAction() {\n return this.getTransparency();\n }\n}\n","import { SmrtCollection } from '@happyvertical/smrt-core';\nimport type { FactContentRelationship } from '@happyvertical/smrt-facts';\nimport type { Content } from './content';\nimport type { CreateContentVersionOptions } from './content-governance';\nimport { resolveEffectiveContentGovernance } from './content-governance';\nimport { ContentVersion } from './content-version';\n\nexport class ContentVersionCollection extends SmrtCollection<ContentVersion> {\n static readonly _itemClass = ContentVersion;\n\n private buildSnapshotFactRelationships(\n snapshot: Record<string, unknown>,\n defaultRelationship: FactContentRelationship,\n ): Map<FactContentRelationship, string[]> {\n const byRelationship = new Map<FactContentRelationship, string[]>();\n const rawLinks: unknown[] = Array.isArray(snapshot.factLinks)\n ? snapshot.factLinks\n : [];\n\n for (const rawLink of rawLinks) {\n const link = rawLink as {\n factId?: unknown;\n relationship?: unknown;\n } | null;\n const factId =\n typeof link?.factId === 'string' && link.factId.length > 0\n ? link.factId\n : null;\n const relationship =\n typeof link?.relationship === 'string' && link.relationship.length > 0\n ? (link.relationship as FactContentRelationship)\n : defaultRelationship;\n\n if (!factId) {\n continue;\n }\n\n byRelationship.set(relationship, [\n ...(byRelationship.get(relationship) || []),\n factId,\n ]);\n }\n\n if (\n byRelationship.size === 0 &&\n Array.isArray(snapshot.factIds) &&\n snapshot.factIds.length > 0\n ) {\n byRelationship.set(\n defaultRelationship,\n snapshot.factIds.filter(\n (factId: unknown): factId is string =>\n typeof factId === 'string' && factId.length > 0,\n ),\n );\n }\n\n return byRelationship;\n }\n\n async listForContent(contentId: string): Promise<ContentVersion[]> {\n return this.list({\n where: { contentId },\n orderBy: 'version ASC',\n });\n }\n\n async getLatestForContent(contentId: string): Promise<ContentVersion | null> {\n const versions = await this.listForContent(contentId);\n return versions.length > 0 ? versions[versions.length - 1] : null;\n }\n\n async getLatestPublishedForContent(\n contentId: string,\n ): Promise<ContentVersion | null> {\n const versions = await this.list({\n where: {\n contentId,\n kind: 'publication',\n },\n orderBy: 'version DESC',\n });\n\n return versions[0] || null;\n }\n\n async getVersion(\n contentId: string,\n versionNumber: number,\n ): Promise<ContentVersion | null> {\n return this.get({\n contentId,\n version: versionNumber,\n });\n }\n\n async getNextVersionNumber(contentId: string): Promise<number> {\n const latest = await this.getLatestForContent(contentId);\n return latest ? latest.version + 1 : 1;\n }\n\n async createSnapshot(\n content: Content,\n options: CreateContentVersionOptions = {},\n ): Promise<ContentVersion> {\n if (!content.id) {\n throw new Error('Cannot create a version for unsaved content');\n }\n\n const version = await this.getNextVersionNumber(content.id as string);\n const governance = await resolveEffectiveContentGovernance({\n contentType: content.type,\n contentVariant: content.variant,\n db: this.db,\n tenantId: content.tenantId ?? null,\n });\n const [references, referenceEdges, assets, factsState] = await Promise.all([\n typeof content.getReferences === 'function'\n ? content.getReferences()\n : [],\n // Capture per-edge citation pins so restore can reconstruct them.\n // `getReferences()` resolves to Content objects and loses targetVersion.\n typeof content.getReferenceEdges === 'function'\n ? content.getReferenceEdges()\n : Promise.resolve([]),\n typeof content.getAssets === 'function' ? content.getAssets() : [],\n typeof content.getFactsState === 'function' &&\n governance.factLinkingEnabled\n ? content.getFactsState()\n : {\n factIds: [],\n facts: [],\n factLinks: [],\n },\n ]);\n\n const baseSnapshot = {\n id: content.id,\n slug: content.slug,\n context: content.context,\n name: content.name,\n type: content.type,\n variant: content.variant,\n fileKey: content.fileKey,\n author: content.author,\n title: content.title,\n description: content.description,\n body: content.body,\n bodyFormat: content.bodyFormat,\n publish_date: content.publish_date,\n url: content.url,\n source: content.source,\n original_url: content.original_url,\n language: content.language,\n tags: [...content.tags],\n category: content.category,\n status: content.status,\n state: content.state,\n metadata: content.metadata,\n thumbnailAssetId: content.thumbnailAssetId,\n referenceIds: references.map((reference) => reference.id).filter(Boolean),\n // Full edges with citation pins; `referenceIds` retained for back-compat\n // with snapshots written before pin-aware restore (#1387 #3).\n referenceEdges: referenceEdges.filter((edge) => Boolean(edge.targetId)),\n assetIds: assets.map((asset) => asset.id).filter(Boolean),\n factIds: factsState.factIds,\n factLinks: factsState.factLinks,\n tenantId: content.tenantId,\n _meta_type: content.toJSON()._meta_type,\n };\n const snapshot = {\n ...baseSnapshot,\n ...(options.snapshot || {}),\n };\n const versionSlugBase =\n snapshot.slug ||\n content.slug ||\n content.name ||\n content.title ||\n content.id;\n const versionSlug = `${versionSlugBase}-v${version}`;\n\n return this.create({\n slug: versionSlug,\n context: content.context || '',\n contentId: content.id as string,\n version,\n kind: options.kind || 'manual',\n title: snapshot.title || '',\n description: snapshot.description || '',\n body: snapshot.body || '',\n status: snapshot.status || 'draft',\n summary: options.summary || '',\n snapshot: JSON.stringify(snapshot),\n metadata: JSON.stringify(options.metadata || {}),\n tenantId: content.tenantId,\n });\n }\n\n async restoreIntoContent(\n content: Content,\n versionNumber: number,\n ): Promise<Content> {\n if (!content.id) {\n throw new Error('Cannot restore an unsaved content item');\n }\n\n const version = await this.getVersion(content.id as string, versionNumber);\n if (!version) {\n throw new Error(\n `Content version ${versionNumber} not found for content ${content.id}`,\n );\n }\n\n const snapshot = version.getSnapshot();\n const keysToRestore = [\n 'name',\n 'type',\n 'variant',\n 'fileKey',\n 'author',\n 'title',\n 'description',\n 'body',\n 'bodyFormat',\n 'publish_date',\n 'url',\n 'source',\n 'original_url',\n 'language',\n 'tags',\n 'category',\n 'status',\n 'state',\n 'metadata',\n 'thumbnailAssetId',\n ];\n\n // Restore snapshot values onto the live Content instance by field name.\n // Indexing a class instance by an arbitrary string key requires a record\n // view; the keys are a fixed, known set of Content fields.\n const writableContent = content as unknown as Record<string, unknown>;\n for (const key of keysToRestore) {\n if (snapshot[key] !== undefined) {\n writableContent[key] = snapshot[key];\n }\n }\n\n // Reference edges with citation pins (#1387 #3). Newer snapshots carry\n // `referenceEdges` ({ targetId, targetVersion }); older ones only have\n // `referenceIds`. Either way, seed the pending `referenceIds` from the\n // target ids so `save()` reconciles the set (adds missing, removes extra),\n // then re-apply the saved pins below so restoring \"to vN\" reconstructs the\n // citation pins that existed at vN instead of dropping them to unpinned.\n const snapshotEdges: Array<{\n targetId: string;\n targetVersion: number | null;\n }> = Array.isArray(snapshot.referenceEdges)\n ? (snapshot.referenceEdges as unknown[])\n .filter(\n (edge): edge is { targetId: string; targetVersion?: unknown } =>\n !!edge &&\n typeof edge === 'object' &&\n typeof (edge as { targetId?: unknown }).targetId === 'string' &&\n (edge as { targetId: string }).targetId.length > 0,\n )\n .map((edge) => ({\n targetId: edge.targetId,\n targetVersion:\n typeof edge.targetVersion === 'number'\n ? edge.targetVersion\n : null,\n }))\n : Array.isArray(snapshot.referenceIds)\n ? snapshot.referenceIds\n .filter(\n (id: unknown): id is string =>\n typeof id === 'string' && id.length > 0,\n )\n .map((targetId: string) => ({ targetId, targetVersion: null }))\n : [];\n\n if (\n Array.isArray(snapshot.referenceEdges) ||\n Array.isArray(snapshot.referenceIds)\n ) {\n writableContent.referenceIds = snapshotEdges.map((edge) => edge.targetId);\n }\n\n if (Array.isArray(snapshot.assetIds)) {\n writableContent.assetIds = [...snapshot.assetIds];\n }\n\n await content.save();\n\n // Re-apply the citation pin of EVERY snapshot edge — including UNPINNED\n // ones (`targetVersion: null`). `save()` only reconciles the target-id set\n // (adds missing / removes extra edges) and leaves the pin of an edge that\n // already existed untouched. So restoring an *unpinned* snapshot over an\n // edge that is currently *pinned* must explicitly clear that pin, otherwise\n // the live pin survives the restore and drift never resets. Passing\n // `addReference(target, { targetVersion: null })` clears the pin in place\n // (the junction's `attach` updates the row when `null !== existing`), while\n // a non-null value (re)sets it — so \"restore to vN\" reconstructs exactly\n // the pins that existed at vN.\n //\n // We pass the resolved Content object (not the raw id) because\n // `addReference(string)` treats the string as a URL, not a content id.\n // `addReference` is idempotent on (source, target) and only adjusts\n // targetVersion.\n if (\n snapshotEdges.length > 0 &&\n typeof content.getReferences === 'function' &&\n typeof content.addReference === 'function'\n ) {\n const resolvedReferences = await content.getReferences();\n const resolvedById = new Map(\n resolvedReferences\n .filter((reference) => reference.id)\n .map((reference) => [reference.id as string, reference]),\n );\n for (const edge of snapshotEdges) {\n const target = resolvedById.get(edge.targetId);\n if (target) {\n await content.addReference(target, {\n targetVersion: edge.targetVersion,\n });\n }\n }\n }\n\n const governance = await resolveEffectiveContentGovernance({\n contentType: content.type,\n contentVariant: content.variant,\n db: this.db,\n tenantId: content.tenantId ?? null,\n });\n\n if (\n governance.isGoverned &&\n governance.factLinkingEnabled &&\n typeof content.getFactLinks === 'function' &&\n typeof content.syncFacts === 'function'\n ) {\n const desiredByRelationship = this.buildSnapshotFactRelationships(\n snapshot,\n governance.defaultFactRelationship,\n );\n const currentLinks = await content.getFactLinks();\n const currentRelationships = new Set(\n currentLinks.map(\n (link) =>\n (link.relationship as FactContentRelationship) ||\n governance.defaultFactRelationship,\n ),\n );\n const relationshipsToSync = new Set<FactContentRelationship>([\n ...currentRelationships,\n ...desiredByRelationship.keys(),\n ]);\n\n for (const relationship of relationshipsToSync) {\n await content.syncFacts(\n desiredByRelationship.get(relationship) || [],\n relationship,\n );\n }\n }\n\n return content;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"content-versions-DCwXHfgo.js","names":[],"sources":["../../src/content-version.ts","../../src/content-versions.ts"],"sourcesContent":["import type { SmrtObjectOptions } from '@happyvertical/smrt-core';\nimport { foreignKey, SmrtObject, smrt } from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\nimport type { ContentVersionKind } from './content-governance';\nimport { normalizeContentTransparency } from './content-transparency';\n\n/**\n * Shape of the loosely-structured `metadata` JSON bag stored on a content\n * version. Known fields are typed; unrecognized keys remain accessible via the\n * index signature.\n */\nexport interface ContentVersionMetadata {\n /**\n * Fingerprint of the snapshot captured for the latest publication version,\n * used to detect post-publication drift.\n */\n publicationSnapshotFingerprint?: string;\n [key: string]: unknown;\n}\n\nexport interface ContentVersionOptions extends SmrtObjectOptions {\n contentId?: string;\n version?: number;\n kind?: ContentVersionKind;\n title?: string;\n description?: string;\n body?: string;\n status?: string;\n summary?: string;\n snapshot?: string | Record<string, unknown>;\n metadata?: string | Record<string, unknown>;\n tenantId?: string | null;\n createdAt?: Date;\n updatedAt?: Date;\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'content_versions',\n conflictColumns: ['content_id', 'version'],\n api: {\n include: ['list', 'get', 'create', 'getTransparencyAction'],\n routes: {\n getTransparencyAction: { method: 'GET', path: 'transparency' },\n },\n },\n mcp: { include: ['list', 'get', 'create'] },\n cli: true,\n})\nexport class ContentVersion extends SmrtObject {\n @foreignKey('Content', { required: true, onDelete: 'CASCADE' })\n contentId = '';\n\n version = 1;\n kind: ContentVersionKind = 'manual';\n title = '';\n description = '';\n body = '';\n status = 'draft';\n summary = '';\n snapshot = '{}';\n metadata = '';\n\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n createdAt = new Date();\n updatedAt = new Date();\n\n constructor(options: ContentVersionOptions = {}) {\n super(options);\n if (options.contentId) this.contentId = options.contentId;\n if (options.version !== undefined) this.version = options.version;\n if (options.kind !== undefined) this.kind = options.kind;\n if (options.title !== undefined) this.title = options.title;\n if (options.description !== undefined)\n this.description = options.description;\n if (options.body !== undefined) this.body = options.body;\n if (options.status !== undefined) this.status = options.status;\n if (options.summary !== undefined) this.summary = options.summary;\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.createdAt) this.createdAt = options.createdAt;\n if (options.updatedAt) this.updatedAt = options.updatedAt;\n\n if (options.snapshot !== undefined) {\n this.snapshot =\n typeof options.snapshot === 'string'\n ? options.snapshot\n : JSON.stringify(options.snapshot);\n }\n\n if (options.metadata !== undefined) {\n this.metadata =\n typeof options.metadata === 'string'\n ? options.metadata\n : JSON.stringify(options.metadata);\n }\n }\n\n getSnapshot(): Record<string, unknown> {\n try {\n return this.snapshot ? JSON.parse(this.snapshot) : {};\n } catch {\n return {};\n }\n }\n\n getMetadata(): ContentVersionMetadata {\n try {\n return this.metadata ? JSON.parse(this.metadata) : {};\n } catch {\n return {};\n }\n }\n\n getTransparency() {\n const metadata = this.getMetadata();\n const snapshot = this.getSnapshot();\n\n return normalizeContentTransparency(metadata.transparency, {\n snapshotKind: this.kind === 'publication' ? 'published' : 'preview',\n contentId: this.contentId || (snapshot.contentId as string) || null,\n currentContentStatus:\n this.status || (snapshot.status as string) || 'draft',\n publicationVersion: {\n id: (this.id as string) || null,\n version: this.version ?? null,\n kind: this.kind || null,\n summary: this.summary || '',\n createdAt:\n this.createdAt instanceof Date ? this.createdAt.toISOString() : null,\n },\n });\n }\n\n async getTransparencyAction() {\n return this.getTransparency();\n }\n}\n","import { SmrtCollection } from '@happyvertical/smrt-core';\nimport type { FactContentRelationship } from '@happyvertical/smrt-facts';\nimport type { Content } from './content';\nimport type { CreateContentVersionOptions } from './content-governance';\nimport { resolveEffectiveContentGovernance } from './content-governance';\nimport { ContentVersion } from './content-version';\n\nexport class ContentVersionCollection extends SmrtCollection<ContentVersion> {\n static readonly _itemClass = ContentVersion;\n\n private buildSnapshotFactRelationships(\n snapshot: Record<string, unknown>,\n defaultRelationship: FactContentRelationship,\n ): Map<FactContentRelationship, string[]> {\n const byRelationship = new Map<FactContentRelationship, string[]>();\n const rawLinks: unknown[] = Array.isArray(snapshot.factLinks)\n ? snapshot.factLinks\n : [];\n\n for (const rawLink of rawLinks) {\n const link = rawLink as {\n factId?: unknown;\n relationship?: unknown;\n } | null;\n const factId =\n typeof link?.factId === 'string' && link.factId.length > 0\n ? link.factId\n : null;\n const relationship =\n typeof link?.relationship === 'string' && link.relationship.length > 0\n ? (link.relationship as FactContentRelationship)\n : defaultRelationship;\n\n if (!factId) {\n continue;\n }\n\n byRelationship.set(relationship, [\n ...(byRelationship.get(relationship) || []),\n factId,\n ]);\n }\n\n if (\n byRelationship.size === 0 &&\n Array.isArray(snapshot.factIds) &&\n snapshot.factIds.length > 0\n ) {\n byRelationship.set(\n defaultRelationship,\n snapshot.factIds.filter(\n (factId: unknown): factId is string =>\n typeof factId === 'string' && factId.length > 0,\n ),\n );\n }\n\n return byRelationship;\n }\n\n async listForContent(contentId: string): Promise<ContentVersion[]> {\n return this.list({\n where: { contentId },\n orderBy: 'version ASC',\n });\n }\n\n async getLatestForContent(contentId: string): Promise<ContentVersion | null> {\n const versions = await this.listForContent(contentId);\n return versions.length > 0 ? versions[versions.length - 1] : null;\n }\n\n async getLatestPublishedForContent(\n contentId: string,\n ): Promise<ContentVersion | null> {\n const versions = await this.list({\n where: {\n contentId,\n kind: 'publication',\n },\n orderBy: 'version DESC',\n });\n\n return versions[0] || null;\n }\n\n async getVersion(\n contentId: string,\n versionNumber: number,\n ): Promise<ContentVersion | null> {\n return this.get({\n contentId,\n version: versionNumber,\n });\n }\n\n async getNextVersionNumber(contentId: string): Promise<number> {\n const latest = await this.getLatestForContent(contentId);\n return latest ? latest.version + 1 : 1;\n }\n\n async createSnapshot(\n content: Content,\n options: CreateContentVersionOptions = {},\n ): Promise<ContentVersion> {\n if (!content.id) {\n throw new Error('Cannot create a version for unsaved content');\n }\n\n const version = await this.getNextVersionNumber(content.id as string);\n const governance = await resolveEffectiveContentGovernance({\n contentType: content.type,\n contentVariant: content.variant,\n db: this.db,\n tenantId: content.tenantId ?? null,\n });\n const [references, referenceEdges, assets, factsState] = await Promise.all([\n typeof content.getReferences === 'function'\n ? content.getReferences()\n : [],\n // Capture per-edge citation pins so restore can reconstruct them.\n // `getReferences()` resolves to Content objects and loses targetVersion.\n typeof content.getReferenceEdges === 'function'\n ? content.getReferenceEdges()\n : Promise.resolve([]),\n typeof content.getAssets === 'function' ? content.getAssets() : [],\n typeof content.getFactsState === 'function' &&\n governance.factLinkingEnabled\n ? content.getFactsState()\n : {\n factIds: [],\n facts: [],\n factLinks: [],\n },\n ]);\n\n const baseSnapshot = {\n id: content.id,\n slug: content.slug,\n context: content.context,\n name: content.name,\n type: content.type,\n variant: content.variant,\n fileKey: content.fileKey,\n author: content.author,\n title: content.title,\n description: content.description,\n body: content.body,\n bodyFormat: content.bodyFormat,\n publish_date: content.publish_date,\n url: content.url,\n source: content.source,\n original_url: content.original_url,\n language: content.language,\n tags: [...content.tags],\n category: content.category,\n status: content.status,\n state: content.state,\n metadata: content.metadata,\n thumbnailAssetId: content.thumbnailAssetId,\n referenceIds: references.map((reference) => reference.id).filter(Boolean),\n // Full edges with citation pins; `referenceIds` retained for back-compat\n // with snapshots written before pin-aware restore (#1387 #3).\n referenceEdges: referenceEdges.filter((edge) => Boolean(edge.targetId)),\n assetIds: assets.map((asset) => asset.id).filter(Boolean),\n factIds: factsState.factIds,\n factLinks: factsState.factLinks,\n tenantId: content.tenantId,\n _meta_type: content.toJSON()._meta_type,\n };\n const snapshot = {\n ...baseSnapshot,\n ...(options.snapshot || {}),\n };\n const versionSlugBase =\n snapshot.slug ||\n content.slug ||\n content.name ||\n content.title ||\n content.id;\n const versionSlug = `${versionSlugBase}-v${version}`;\n\n return this.create({\n slug: versionSlug,\n context: content.context || '',\n contentId: content.id as string,\n version,\n kind: options.kind || 'manual',\n title: snapshot.title || '',\n description: snapshot.description || '',\n body: snapshot.body || '',\n status: snapshot.status || 'draft',\n summary: options.summary || '',\n snapshot: JSON.stringify(snapshot),\n metadata: JSON.stringify(options.metadata || {}),\n tenantId: content.tenantId,\n });\n }\n\n async restoreIntoContent(\n content: Content,\n versionNumber: number,\n ): Promise<Content> {\n if (!content.id) {\n throw new Error('Cannot restore an unsaved content item');\n }\n\n const version = await this.getVersion(content.id as string, versionNumber);\n if (!version) {\n throw new Error(\n `Content version ${versionNumber} not found for content ${content.id}`,\n );\n }\n\n const snapshot = version.getSnapshot();\n const keysToRestore = [\n 'name',\n 'type',\n 'variant',\n 'fileKey',\n 'author',\n 'title',\n 'description',\n 'body',\n 'bodyFormat',\n 'publish_date',\n 'url',\n 'source',\n 'original_url',\n 'language',\n 'tags',\n 'category',\n 'status',\n 'state',\n 'metadata',\n 'thumbnailAssetId',\n ];\n\n // Restore snapshot values onto the live Content instance by field name.\n // Indexing a class instance by an arbitrary string key requires a record\n // view; the keys are a fixed, known set of Content fields.\n const writableContent = content as unknown as Record<string, unknown>;\n for (const key of keysToRestore) {\n if (snapshot[key] !== undefined) {\n writableContent[key] = snapshot[key];\n }\n }\n\n // Reference edges with citation pins (#1387 #3). Newer snapshots carry\n // `referenceEdges` ({ targetId, targetVersion }); older ones only have\n // `referenceIds`. Either way, seed the pending `referenceIds` from the\n // target ids so `save()` reconciles the set (adds missing, removes extra),\n // then re-apply the saved pins below so restoring \"to vN\" reconstructs the\n // citation pins that existed at vN instead of dropping them to unpinned.\n const snapshotEdges: Array<{\n targetId: string;\n targetVersion: number | null;\n }> = Array.isArray(snapshot.referenceEdges)\n ? (snapshot.referenceEdges as unknown[])\n .filter(\n (edge): edge is { targetId: string; targetVersion?: unknown } =>\n !!edge &&\n typeof edge === 'object' &&\n typeof (edge as { targetId?: unknown }).targetId === 'string' &&\n (edge as { targetId: string }).targetId.length > 0,\n )\n .map((edge) => ({\n targetId: edge.targetId,\n targetVersion:\n typeof edge.targetVersion === 'number'\n ? edge.targetVersion\n : null,\n }))\n : Array.isArray(snapshot.referenceIds)\n ? snapshot.referenceIds\n .filter(\n (id: unknown): id is string =>\n typeof id === 'string' && id.length > 0,\n )\n .map((targetId: string) => ({ targetId, targetVersion: null }))\n : [];\n\n if (\n Array.isArray(snapshot.referenceEdges) ||\n Array.isArray(snapshot.referenceIds)\n ) {\n writableContent.referenceIds = snapshotEdges.map((edge) => edge.targetId);\n }\n\n if (Array.isArray(snapshot.assetIds)) {\n writableContent.assetIds = [...snapshot.assetIds];\n }\n\n await content.save();\n\n // Re-apply the citation pin of EVERY snapshot edge — including UNPINNED\n // ones (`targetVersion: null`). `save()` only reconciles the target-id set\n // (adds missing / removes extra edges) and leaves the pin of an edge that\n // already existed untouched. So restoring an *unpinned* snapshot over an\n // edge that is currently *pinned* must explicitly clear that pin, otherwise\n // the live pin survives the restore and drift never resets. Passing\n // `addReference(target, { targetVersion: null })` clears the pin in place\n // (the junction's `attach` updates the row when `null !== existing`), while\n // a non-null value (re)sets it — so \"restore to vN\" reconstructs exactly\n // the pins that existed at vN.\n //\n // We pass the resolved Content object (not the raw id) because\n // `addReference(string)` treats the string as a URL, not a content id.\n // `addReference` is idempotent on (source, target) and only adjusts\n // targetVersion.\n if (\n snapshotEdges.length > 0 &&\n typeof content.getReferences === 'function' &&\n typeof content.addReference === 'function'\n ) {\n const resolvedReferences = await content.getReferences();\n const resolvedById = new Map(\n resolvedReferences\n .filter((reference) => reference.id)\n .map((reference) => [reference.id as string, reference]),\n );\n for (const edge of snapshotEdges) {\n const target = resolvedById.get(edge.targetId);\n if (target) {\n await content.addReference(target, {\n targetVersion: edge.targetVersion,\n });\n }\n }\n }\n\n const governance = await resolveEffectiveContentGovernance({\n contentType: content.type,\n contentVariant: content.variant,\n db: this.db,\n tenantId: content.tenantId ?? null,\n });\n\n if (\n governance.isGoverned &&\n governance.factLinkingEnabled &&\n typeof content.getFactLinks === 'function' &&\n typeof content.syncFacts === 'function'\n ) {\n const desiredByRelationship = this.buildSnapshotFactRelationships(\n snapshot,\n governance.defaultFactRelationship,\n );\n const currentLinks = await content.getFactLinks();\n const currentRelationships = new Set(\n currentLinks.map(\n (link) =>\n (link.relationship as FactContentRelationship) ||\n governance.defaultFactRelationship,\n ),\n );\n const relationshipsToSync = new Set<FactContentRelationship>([\n ...currentRelationships,\n ...desiredByRelationship.keys(),\n ]);\n\n for (const relationship of relationshipsToSync) {\n await content.syncFacts(\n desiredByRelationship.get(relationship) || [],\n relationship,\n );\n }\n }\n\n return content;\n }\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { D as serializeFact, E as serializeContent, J as getEffectiveContentGovernanceConfig, Q as loadPersistedContentGovernanceDefinitions, nt as resolveEffectiveContentGovernance, w as Content, x as executeContentQuery } from "./content-query-BsGgJ4XY.js";
2
+ import { D as serializeFact, E as serializeContent, J as getEffectiveContentGovernanceConfig, Q as loadPersistedContentGovernanceDefinitions, nt as resolveEffectiveContentGovernance, w as Content, x as executeContentQuery } from "./content-query-BfnrHwlW.js";
3
3
  import { htmlToMarkdown, resolveBodyFormat } from "../body-format.js";
4
4
  import { SmrtCollection, smrt } from "@happyvertical/smrt-core";
5
5
  import { queryGlobal, queryWithGlobals } from "@happyvertical/smrt-tenancy";
@@ -609,4 +609,4 @@ Contents = __decorateClass([smrt({
609
609
  //#endregion
610
610
  export { contents_exports as n, assertSafeRemoteUrl as r, Contents as t };
611
611
 
612
- //# sourceMappingURL=contents-Cs8lWj2I.js.map
612
+ //# sourceMappingURL=contents-BSGdKb3k.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"contents-Cs8lWj2I.js","names":[],"sources":["../../src/safe-remote-url.ts","../../src/contents.ts"],"sourcesContent":["/**\n * Shared SSRF guard for content's outbound fetches (feed sync, mirroring).\n *\n * Any code path that fetches a caller-supplied URL — RSS/Atom feeds, the\n * `Mirror` content type, link previews — is an SSRF vector: an attacker who can\n * influence the URL can make the server fetch `169.254.169.254` cloud metadata,\n * `localhost` admin panels, or other internal services. This module rejects\n * URLs that resolve to private, loopback, link-local, CGNAT, or cloud-metadata\n * ranges before any request is made, and re-validates redirect hops.\n *\n * Unlike a literal-IP-only check, {@link assertSafeRemoteUrl} resolves hostnames\n * via DNS so a public name pointing at a private IP is also caught (S5 #1388).\n */\nimport { lookup as dnsLookup } from 'node:dns/promises';\nimport { isIP } from 'node:net';\n\nexport type ResolvedAddress = { address: string; family?: number };\nexport type ResolveHostname = (hostname: string) => Promise<ResolvedAddress[]>;\n\nexport interface SafeRemoteUrlOptions {\n /** Skip the private-network checks (trusted callers only, e.g. local dev). */\n allowPrivateNetworkHosts?: boolean;\n /** Injectable resolver for tests; defaults to {@link defaultResolveHostname}. */\n resolveHostname?: ResolveHostname;\n}\n\nexport async function defaultResolveHostname(\n hostname: string,\n): Promise<ResolvedAddress[]> {\n return dnsLookup(hostname, { all: true, verbatim: false });\n}\n\nexport function isBlockedIPv4(address: string): boolean {\n const parts = address.split('.');\n if (parts.length !== 4) return true;\n // Strict per-octet validation: `Number('')` is 0, so without a digit check a\n // malformed input like `1..2.3` would parse to `[1,0,2,3]` and be treated as a\n // valid (and possibly allowed) address. Anything not 1-3 digits in 0-255 is\n // unparseable → blocked (review #1562).\n const octets = parts.map((part) =>\n /^\\d{1,3}$/.test(part) ? Number(part) : Number.NaN,\n );\n if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {\n return true;\n }\n\n const [first, second] = octets;\n return (\n first === 0 ||\n first === 10 ||\n first === 127 ||\n first >= 224 ||\n (first === 100 && second >= 64 && second <= 127) ||\n (first === 169 && second === 254) ||\n (first === 172 && second >= 16 && second <= 31) ||\n (first === 192 && second === 168) ||\n (first === 198 && (second === 18 || second === 19))\n );\n}\n\n/**\n * Expand an IPv6 string to its 8 hextets (numbers), or null if unparseable.\n * Handles `::` compression, an embedded dotted-IPv4 tail, and bracketed forms.\n */\nfunction expandIPv6(address: string): number[] | null {\n let work = address.toLowerCase().replace(/^\\[|\\]$/g, '');\n if (work.includes('.')) {\n // Embedded dotted IPv4 tail (e.g. ::ffff:127.0.0.1) → convert to 2 hextets.\n const m = work.match(/(\\d{1,3}(?:\\.\\d{1,3}){3})$/);\n if (!m) return null;\n const o = m[1].split('.').map(Number);\n if (o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;\n const hi = ((o[0] << 8) | o[1]).toString(16);\n const lo = ((o[2] << 8) | o[3]).toString(16);\n work = `${work.slice(0, work.length - m[1].length)}${hi}:${lo}`;\n }\n const halves = work.split('::');\n if (halves.length > 2) return null;\n const head = halves[0] ? halves[0].split(':') : [];\n const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];\n const groups =\n halves.length === 2\n ? [\n ...head,\n ...Array(Math.max(0, 8 - head.length - tail.length)).fill('0'),\n ...tail,\n ]\n : head;\n if (groups.length !== 8) return null;\n const hextets = groups.map((g) =>\n /^[0-9a-f]{1,4}$/.test(g) ? Number.parseInt(g, 16) : Number.NaN,\n );\n return hextets.some((n) => Number.isNaN(n)) ? null : hextets;\n}\n\nexport function isBlockedIPv6(address: string): boolean {\n const hextets = expandIPv6(address);\n if (hextets) {\n // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d, deprecated)\n // both embed an IPv4 in the last 2 hextets — decode and apply the IPv4\n // blocklist so a loopback/private IPv4 can't be smuggled through any IPv6\n // encoding (compressed, expanded, dotted, or hex) (review #1562, P1).\n const firstFiveZero = hextets.slice(0, 5).every((h) => h === 0);\n if (firstFiveZero && (hextets[5] === 0xffff || hextets[5] === 0)) {\n const [, , , , , , g6, g7] = hextets;\n const ipv4 = `${g6 >> 8}.${g6 & 0xff}.${g7 >> 8}.${g7 & 0xff}`;\n return isBlockedIPv4(ipv4);\n }\n }\n\n const normalized = address.toLowerCase().replace(/^\\[|\\]$/g, '');\n return (\n normalized === '::' ||\n normalized === '::1' ||\n normalized.startsWith('fc') ||\n normalized.startsWith('fd') ||\n /^fe[89ab]/.test(normalized) ||\n normalized.startsWith('ff')\n );\n}\n\nexport function isBlockedAddress(address: string): boolean {\n const family = isIP(address);\n if (family === 4) return isBlockedIPv4(address);\n if (family === 6) return isBlockedIPv6(address);\n // Anything that isn't a recognisable IP literal (after DNS resolution should\n // have produced one) is treated as unsafe.\n return true;\n}\n\n/**\n * Parse and validate a remote URL for outbound fetching. Rejects non-http(s)\n * schemes, embedded credentials, and hosts that resolve to non-public ranges.\n * Returns the parsed {@link URL} on success; throws a descriptive `Error`\n * otherwise.\n */\nexport async function assertSafeRemoteUrl(\n rawUrl: string,\n options: SafeRemoteUrlOptions = {},\n): Promise<URL> {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n throw new Error('Remote URL must be an absolute URL');\n }\n\n if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n throw new Error('Remote URL must use http or https');\n }\n if (url.username || url.password) {\n throw new Error('Remote URL must not include credentials');\n }\n if (!url.hostname) {\n throw new Error('Remote URL must include a hostname');\n }\n\n if (options.allowPrivateNetworkHosts) return url;\n\n const resolver = options.resolveHostname ?? defaultResolveHostname;\n const addresses =\n isIP(url.hostname) === 0\n ? await resolver(url.hostname)\n : [{ address: url.hostname }];\n\n if (\n !addresses.length ||\n addresses.some(({ address }) => isBlockedAddress(address))\n ) {\n throw new Error('Remote URL must resolve to a public network address');\n }\n\n return url;\n}\n\n/** Redirect status codes that reroute a request to a new Location. */\nconst REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);\nconst DEFAULT_MAX_REDIRECTS = 5;\nconst DEFAULT_RESOLVE_TIMEOUT_MS = 10_000;\n\nexport interface SafeRedirectOptions extends SafeRemoteUrlOptions {\n /** Maximum redirect hops to follow before failing. Default 5. */\n maxRedirects?: number;\n /** Per-hop timeout in ms. Default 10s. */\n timeoutMs?: number;\n /** Injectable fetch (primarily for tests). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Resolve a URL's redirect chain and return the final safe {@link URL}, with\n * EVERY hop re-validated through {@link assertSafeRemoteUrl}.\n *\n * Use this before handing a URL to a fetcher that follows redirects on its own\n * (e.g. `fetchDocument`): the up-front {@link assertSafeRemoteUrl} check alone\n * can't stop an allowed public host from `30x`-redirecting into an internal /\n * loopback / metadata host, which would defeat the SSRF guard (review #1562).\n *\n * Redirects are followed with `GET` + `redirect: 'manual'` to match downstream\n * GET-based fetchers; response bodies are discarded (the redirect bodies are\n * empty and the terminal body is left for the caller to re-fetch), so this does\n * not double-download content.\n */\nexport async function resolveSafeFinalUrl(\n rawUrl: string,\n options: SafeRedirectOptions = {},\n): Promise<URL> {\n const fetchImpl = options.fetchImpl ?? fetch;\n const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;\n const timeoutMs = options.timeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS;\n let current = await assertSafeRemoteUrl(rawUrl, options);\n\n for (let hop = 0; hop <= maxRedirects; hop += 1) {\n const response = await fetchImpl(current, {\n method: 'GET',\n redirect: 'manual',\n signal: AbortSignal.timeout(timeoutMs),\n });\n // Release the connection without downloading the body.\n try {\n await response.body?.cancel();\n } catch {\n // best-effort\n }\n\n if (!REDIRECT_STATUSES.has(response.status)) {\n return current;\n }\n const location = response.headers.get('location');\n if (!location) return current;\n // Re-validate the resolved redirect target before following it.\n current = await assertSafeRemoteUrl(\n new URL(location, current).toString(),\n options,\n );\n }\n\n throw new Error('Remote URL exceeded the maximum number of redirects');\n}\n\n/**\n * Strip userinfo (`user:pass@`) from a URL so it can be safely logged or echoed\n * in an error message. Returns a placeholder for unparseable input. Never let a\n * credential-bearing URL reach logs/errors verbatim (review #1562).\n */\nexport function redactUrlCredentials(raw: string): string {\n try {\n const url = new URL(raw);\n if (url.username || url.password) {\n url.username = '';\n url.password = '';\n return url.toString();\n }\n return raw;\n } catch {\n return '[unparseable url]';\n }\n}\n","import { writeFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport { fetchDocument } from '@happyvertical/documents';\nimport { ensureDirectoryExists } from '@happyvertical/files';\nimport { createLogger } from '@happyvertical/logger';\nimport type { SmrtCollectionOptions } from '@happyvertical/smrt-core';\nimport { SmrtCollection, smrt } from '@happyvertical/smrt-core';\nimport type { Image } from '@happyvertical/smrt-images';\nimport { queryGlobal, queryWithGlobals } from '@happyvertical/smrt-tenancy';\nimport type { DataQueryResult } from '@happyvertical/smrt-types';\nimport { makeSlug } from '@happyvertical/utils';\nimport YAML from 'yaml';\nimport { htmlToMarkdown, resolveBodyFormat } from './body-format';\nimport { Content } from './content';\nimport {\n getEffectiveContentGovernanceConfig,\n loadPersistedContentGovernanceDefinitions,\n resolveEffectiveContentGovernance,\n} from './content-governance';\nimport {\n type ContentQueryCollection,\n executeContentQuery,\n} from './content-query';\nimport {\n type ResolveHostname,\n redactUrlCredentials,\n resolveSafeFinalUrl,\n} from './safe-remote-url';\nimport { serializeContent, serializeFact } from './serialization';\nimport type {\n ThumbnailOptions,\n ThumbnailStrategy,\n} from './thumbnail-generator';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Options accepted by {@link Contents.generateMissingThumbnails}. Also reused\n * (as a `Partial`) for the `thumbnail` config block passed through the\n * collection constructor from `smrt.config.js`.\n */\nexport interface GenerateMissingThumbnailsOptions {\n /**\n * Thumbnail generation strategy\n */\n strategy: ThumbnailStrategy;\n\n /**\n * Optional filter for content to process\n */\n where?: Record<string, unknown>;\n\n /**\n * Maximum number of thumbnails to generate\n */\n limit?: number;\n\n // Headline card options\n brandColor?: string;\n backgroundColor?: string;\n logoUrl?: string;\n template?: 'default' | 'news' | 'minimal';\n\n // Static map options\n mapProvider?: 'mapbox' | 'google';\n zoom?: number;\n\n // AI options\n style?: 'photorealistic' | 'illustration' | 'abstract' | 'minimal';\n\n // Common options\n width?: number;\n height?: number;\n}\n\n/**\n * Configuration options for Contents collection\n */\nexport interface ContentsOptions extends SmrtCollectionOptions {\n /**\n * Directory to store content files\n */\n contentDir?: string;\n\n /**\n * Default thumbnail-generation settings sourced from `smrt.config.js`. Merged\n * under per-call options in `generateMissingThumbnails`.\n */\n thumbnail?: Partial<GenerateMissingThumbnailsOptions>;\n}\n\nfunction isAIClientOptions(\n ai: SmrtCollectionOptions['ai'],\n): ai is AIClientOptions {\n return (\n !!ai &&\n typeof ai === 'object' &&\n !('embed' in ai) &&\n !('generateImage' in ai)\n );\n}\n\n/**\n * Collection for managing Content objects\n *\n * The Contents collection provides functionality for managing and manipulating\n * collections of Content objects, including saving to the filesystem and\n * mirroring content from remote URLs.\n */\n@smrt({\n api: {\n include: [\n 'browseFacts',\n 'getBySlug',\n 'getGovernanceDefinitionsAction',\n 'resolveGovernanceAction',\n 'queryAction',\n ],\n routes: {\n queryAction: {\n scope: 'collection',\n method: 'POST',\n path: 'query',\n },\n browseFacts: {\n scope: 'collection',\n method: 'GET',\n path: 'facts',\n },\n getBySlug: {\n scope: 'collection',\n method: 'GET',\n path: 'by-slug',\n },\n getGovernanceDefinitionsAction: {\n scope: 'collection',\n method: 'GET',\n path: 'governance',\n },\n resolveGovernanceAction: {\n scope: 'collection',\n method: 'GET',\n path: 'governance/resolve',\n },\n },\n },\n mcp: false,\n cli: false,\n})\nexport class Contents extends SmrtCollection<Content> {\n /**\n * Class constructor for collection items\n */\n static _itemClass = Content;\n\n /**\n * Configuration options\n */\n public options: ContentsOptions = {} as ContentsOptions;\n\n /**\n * Directory to store content files\n */\n public contentDir?: string;\n\n /**\n * Cache for loaded content\n */\n public loaded: Map<string, Content>;\n\n /**\n * Creates a new Contents collection\n *\n * Use the static `create()` method inherited from SmrtCollection for proper initialization.\n *\n * @param options - Configuration options\n */\n constructor(options: ContentsOptions = {}) {\n super(options);\n this.options = options;\n this.loaded = new Map();\n }\n\n /**\n * Gets the database interface\n *\n * @returns Database interface\n */\n getDb() {\n return this._db;\n }\n\n /**\n * Initializes the collection\n *\n * @returns Promise that resolves to this instance\n */\n public async initialize(): Promise<this> {\n await super.initialize();\n return this;\n }\n\n private async getFactCollection() {\n const { FactCollection } = await import('@happyvertical/smrt-facts');\n return FactCollection.create(this.options);\n }\n\n /**\n * Bounded, tenant-safe content query (`POST /api/v1/contents/query`).\n *\n * Accepts the canonical `DataQueryRequest` envelope (#2444) and returns a\n * normalized `DataQueryResult`, so a list surface can filter, sort, page,\n * count, and facet server-side instead of hydrating the whole collection.\n *\n * The request body carries no authority: only schema-declared field ids are\n * accepted, and tenant scoping is applied by `executeContentQuery` itself\n * (fail-closed to global rows when tenancy is enabled with no active tenant\n * context). Applications that need additional site/organization scoping call\n * `executeContentQuery` directly with a trusted `scope`.\n *\n * The parameter is named `options` deliberately: the route generator passes\n * the raw request body straight through as a single `options` argument, so\n * the wire body IS the `DataQueryRequest` rather than a wrapper object.\n *\n * @param options Untrusted `DataQueryRequest` from the caller.\n * @returns A validated, bounded `DataQueryResult`.\n */\n public async queryAction(options: unknown): Promise<DataQueryResult> {\n return executeContentQuery(\n this as unknown as ContentQueryCollection,\n options,\n );\n }\n\n public async browseFacts(\n options: {\n q?: string;\n query?: string;\n limit?: number | string;\n offset?: number | string;\n minSimilarity?: number | string;\n includeSuperseded?: boolean | string;\n latestOnly?: boolean | string;\n tenantId?: string | null;\n } = {},\n ) {\n try {\n const facts = await this.getFactCollection();\n const query = options.query || options.q || '';\n const limit =\n options.limit !== undefined ? Number(options.limit) : undefined;\n const offset =\n options.offset !== undefined ? Number(options.offset) : undefined;\n const minSimilarity =\n options.minSimilarity !== undefined\n ? Number(options.minSimilarity)\n : undefined;\n const includeSuperseded =\n options.includeSuperseded === true ||\n options.includeSuperseded === 'true';\n const latestOnly =\n options.latestOnly === undefined\n ? true\n : options.latestOnly === true || options.latestOnly === 'true';\n\n const results = await facts.browseCatalog(query, {\n limit: Number.isFinite(limit) ? limit : undefined,\n offset: Number.isFinite(offset) ? offset : undefined,\n minSimilarity: Number.isFinite(minSimilarity)\n ? minSimilarity\n : undefined,\n includeSuperseded,\n latestOnly,\n tenantId: options.tenantId ?? null,\n });\n\n return results.map(serializeFact);\n } catch (error) {\n // Gracefully handle missing facts table (cross-package dependency)\n if (\n typeof error === 'object' &&\n error !== null &&\n (error as { code?: unknown }).code === 'DB_SCHEMA_MISSING'\n ) {\n return [];\n }\n throw error;\n }\n }\n\n public async getBySlug(\n options: {\n slug?: string;\n context?: string;\n status?: string;\n tenantId?: string | null;\n } = {},\n ) {\n if (!options.slug) {\n throw new Error('slug is required');\n }\n\n const where: {\n slug: string;\n context: string;\n tenantId?: string | null;\n } = {\n slug: options.slug,\n context: options.context || '',\n };\n if (options.tenantId !== undefined) {\n where.tenantId = options.tenantId;\n }\n\n const content = await this.get(where);\n\n if (!content) {\n return null;\n }\n\n if (options.status && content.status !== options.status) {\n return null;\n }\n\n return serializeContent(content);\n }\n\n public async getGovernanceDefinitionsAction(\n options: { tenantId?: string | null } = {},\n ) {\n const [effective, persisted] = await Promise.all([\n getEffectiveContentGovernanceConfig({\n db: this.db,\n tenantId: options.tenantId,\n }),\n loadPersistedContentGovernanceDefinitions({\n db: this.db,\n tenantId: options.tenantId,\n }),\n ]);\n\n return {\n effective: {\n policies: effective.policies.map((policy) => ({\n ...policy,\n ...(persisted.policies.find((item) => item.key === policy.key) || {}),\n })),\n profiles: effective.profiles.map((profile) => ({\n ...profile,\n ...(persisted.profiles.find((item) => item.key === profile.key) ||\n {}),\n })),\n assignments: effective.assignments.map((assignment) => ({\n ...assignment,\n ...(persisted.assignments.find(\n (item) => item.key === assignment.key,\n ) || {}),\n })),\n },\n persisted: {\n policies: persisted.policies,\n profiles: persisted.profiles,\n assignments: persisted.assignments,\n },\n };\n }\n\n public async resolveGovernanceAction(\n options: {\n type?: string;\n variant?: string | null;\n tenantId?: string | null;\n } = {},\n ) {\n return resolveEffectiveContentGovernance({\n contentType: options.type || null,\n contentVariant: options.variant || null,\n db: this.db,\n tenantId: options.tenantId,\n });\n }\n\n /**\n * Mirrors content from a remote URL\n *\n * Downloads and stores content from a remote URL, extracting text\n * and saving it as a Content object.\n *\n * @param options - Mirror options\n * @param options.url - URL to mirror\n * @param options.mirrorDir - Directory for caching mirrored files\n * @param options.context - Context for the mirrored content\n * @returns Promise resolving to the mirrored Content object\n * @throws Error if URL is invalid or missing\n */\n public async mirror(options: {\n url: string;\n mirrorDir?: string;\n context?: string;\n /**\n * Skip the SSRF host-blocking checks. Only set this for fully trusted,\n * operator-supplied URLs (e.g. local development), never for URLs that\n * originate from end users or external content.\n */\n allowPrivateNetworkHosts?: boolean;\n /** Injectable DNS resolver (primarily for tests). */\n resolveHostname?: ResolveHostname;\n /** Injectable fetch for redirect resolution (primarily for tests). */\n fetchImpl?: typeof fetch;\n }) {\n if (!options.url) {\n throw new Error('No URL provided');\n }\n // Validate the URL AND block private/loopback/link-local/metadata hosts\n // before fetching — `mirror()` fetches an arbitrary caller-supplied URL,\n // which is a classic SSRF sink (e.g. http://169.254.169.254/ metadata).\n // `fetchDocument` follows redirects on its own, so we resolve the redirect\n // chain ourselves first — re-validating every hop — and hand it the\n // already-validated terminal URL, closing the public-host-30x-to-internal\n // bypass (S5 #1388 / review #1562).\n let url: URL;\n try {\n url = await resolveSafeFinalUrl(options.url, {\n allowPrivateNetworkHosts: options.allowPrivateNetworkHosts,\n resolveHostname: options.resolveHostname,\n fetchImpl: options.fetchImpl,\n });\n } catch (error) {\n // Never echo the raw URL — it may carry userinfo credentials (review #1562).\n const safeUrl = redactUrlCredentials(options.url);\n logger.error('Refusing to mirror unsafe URL', { error, url: safeUrl });\n throw new Error(`Invalid URL provided: ${safeUrl}`);\n }\n const existing = await this.get({ url: options.url });\n if (existing) {\n return existing;\n }\n\n // Fetch and process the document via the already-validated terminal URL.\n const doc = await fetchDocument(url.toString(), {\n cacheDir: options?.mirrorDir,\n });\n\n const filename = url.pathname.split('/').pop();\n const nameWithoutExtension = filename?.replace(/\\.[^/.]+$/, '');\n const title = nameWithoutExtension?.replace(/[-_]/g, ' ');\n const slug = makeSlug(title as string);\n\n // Extract text from all document parts\n const body = doc.parts.map((part) => part.content).join('\\n\\n');\n if (body) {\n const content = new Content({\n url: options.url,\n type: 'mirror',\n title,\n slug,\n context: options.context || '',\n body,\n });\n await content.initialize();\n await content.save();\n return content;\n }\n }\n\n /**\n * Writes a Content object to the filesystem as a markdown file\n *\n * @param options - Options for writing the content file\n * @param options.content - Content object to write\n * @param options.contentDir - Directory to write the file to\n * @returns Promise that resolves when the file is written\n * @throws Error if contentDir is not provided\n */\n public async writeContentFile(options: {\n content: Content;\n contentDir: string;\n }) {\n const { content, contentDir } = options;\n if (!contentDir) {\n throw new Error('No content dir provided');\n }\n\n const { body } = content;\n const frontMatter = {\n title: content.title,\n slug: content.slug,\n context: content.context,\n author: content.author,\n publish_date: content.publish_date,\n };\n\n let output = '';\n if (frontMatter && Object.keys(frontMatter).length > 0) {\n output += '---\\n';\n output += YAML.stringify(frontMatter);\n output += '---\\n';\n }\n\n // Filesystem exports are markdown regardless of the editor save format.\n let formattedBody = body || '';\n const bodyFormat = resolveBodyFormat(content.bodyFormat, body);\n if (bodyFormat === 'html') {\n formattedBody = htmlToMarkdown(body || '');\n } else if (body && !this.isMarkdown(body)) {\n formattedBody = this.formatAsMarkdown(body);\n }\n output += formattedBody;\n\n const pathParts = [\n contentDir,\n content.context || '', // if empty, use empty string\n content.slug,\n 'index.md',\n ].filter(Boolean); // remove empty strings\n\n const outputFile = path.join(...(pathParts as string[]));\n\n // `context` and `slug` are persisted, caller-influenced fields. Without a\n // guard, a value like `../../etc/cron.d/x` escapes `contentDir` and lets an\n // export overwrite arbitrary files (path traversal). Confirm the joined\n // path still lives under the resolved content directory (S5 #1388).\n const resolvedDir = path.resolve(contentDir);\n const resolvedFile = path.resolve(outputFile);\n if (\n resolvedFile !== resolvedDir &&\n !resolvedFile.startsWith(resolvedDir + path.sep)\n ) {\n throw new Error(\n 'Refusing to write content file outside of the content directory',\n );\n }\n\n await ensureDirectoryExists(path.dirname(outputFile));\n await writeFile(outputFile, output);\n }\n\n /**\n * Checks if text appears to be in markdown format\n *\n * @param text - Text to check\n * @returns Boolean indicating if the text contains markdown syntax\n */\n private isMarkdown(text: string): boolean {\n // Basic check for common markdown indicators\n const markdownIndicators = [\n /^#\\s/m, // Headers\n /\\*\\*.+\\*\\*/, // Bold\n /\\*.+\\*/, // Italic\n /\\[.+\\]\\(.+\\)/, // Links\n /^\\s*[-*+]\\s/m, // Lists\n /^\\s*\\d+\\.\\s/m, // Numbered lists\n /```[\\s\\S]*```/, // Code blocks\n /^\\s*>/m, // Blockquotes\n ];\n\n return markdownIndicators.some((indicator) => indicator.test(text));\n }\n\n /**\n * Formats plain text as simple markdown\n *\n * @param text - Plain text to format\n * @returns Text formatted as basic markdown\n */\n private formatAsMarkdown(text: string): string {\n // Basic formatting of plain text to markdown\n return text\n .split(/\\n\\n+/)\n .map((paragraph) => paragraph.trim())\n .filter(Boolean)\n .join('\\n\\n');\n }\n\n /**\n * Synchronizes content to the filesystem\n *\n * Writes all article-type Content objects to the filesystem\n * as markdown files.\n *\n * @param options - Sync options\n * @param options.contentDir - Directory to write content files to\n * @returns Promise that resolves when synchronization is complete\n */\n public async syncContentDir(options: { contentDir?: string }) {\n const contentFilter = {\n type: 'article',\n };\n\n const contents = await this.list({ where: contentFilter });\n for (const content of contents) {\n await this.writeContentFile({\n content,\n contentDir: options.contentDir || this.options.contentDir || '',\n });\n }\n }\n\n /**\n * Generate thumbnails for content that doesn't have one\n *\n * @param options - Options for bulk thumbnail generation\n * @returns Promise resolving to result object with generated images and failed content IDs\n *\n * @example Generate headline cards for all published articles\n * ```typescript\n * const result = await contents.generateMissingThumbnails({\n * strategy: 'headline-card',\n * where: { type: 'article', status: 'published' },\n * brandColor: '#1a56db'\n * });\n * console.log(`Generated ${result.images.length} thumbnails`);\n * if (result.failed.length > 0) {\n * console.warn(`Failed to generate ${result.failed.length} thumbnails`);\n * }\n * ```\n */\n public async generateMissingThumbnails(\n options: GenerateMissingThumbnailsOptions,\n ): Promise<{\n images: Image[];\n failed: Array<{ contentId: string; error: string }>;\n }> {\n // Merge with thumbnail config from smrt.config.js (passed via constructor)\n // This allows CLI users to configure defaults in smrt.config.js:\n // thumbnail: { strategy: 'headline-card', brandColor: '#1976d2' }\n const configDefaults = this.options?.thumbnail || {};\n const mergedOptions = {\n ...configDefaults,\n ...options,\n };\n\n // Build query for content missing thumbnails\n const whereClause = {\n ...mergedOptions.where,\n thumbnailAssetId: null,\n };\n\n const contents = await this.list({\n where: whereClause,\n limit: mergedOptions.limit,\n });\n\n const generatedImages: Image[] = [];\n const failed: Array<{ contentId: string; error: string }> = [];\n\n for (const content of contents) {\n try {\n // Build thumbnail options based on strategy\n let thumbnailOptions: ThumbnailOptions;\n\n switch (mergedOptions.strategy) {\n case 'headline-card':\n thumbnailOptions = {\n strategy: 'headline-card',\n brandColor: mergedOptions.brandColor,\n backgroundColor: mergedOptions.backgroundColor,\n logoUrl: mergedOptions.logoUrl,\n template: mergedOptions.template,\n width: mergedOptions.width,\n height: mergedOptions.height,\n };\n break;\n\n case 'static-map':\n thumbnailOptions = {\n strategy: 'static-map',\n mapProvider: mergedOptions.mapProvider,\n zoom: mergedOptions.zoom,\n width: mergedOptions.width,\n height: mergedOptions.height,\n };\n break;\n\n case 'ai-generate':\n thumbnailOptions = {\n strategy: 'ai-generate',\n style: mergedOptions.style,\n width: mergedOptions.width,\n height: mergedOptions.height,\n ai: isAIClientOptions(this.options.ai)\n ? this.options.ai\n : undefined,\n };\n break;\n\n default:\n throw new Error(`Unknown strategy: ${mergedOptions.strategy}`);\n }\n\n const image = await content.generateThumbnail(thumbnailOptions);\n generatedImages.push(image);\n } catch (error) {\n const errorMessage =\n error instanceof Error ? error.message : String(error);\n logger.error(\n `Failed to generate thumbnail for content ${content.id}: ${errorMessage}`,\n );\n failed.push({\n contentId: content.id ?? 'unknown',\n error: errorMessage,\n });\n }\n }\n\n return { images: generatedImages, failed };\n }\n\n // ============================================\n // Tenant Helper Methods\n // ============================================\n\n /**\n * Find all content belonging to a specific tenant\n *\n * @param tenantId - The tenant ID to filter by\n * @returns Promise resolving to array of Content objects for the tenant\n *\n * @example\n * ```typescript\n * const tenantContent = await contents.findByTenant('tenant-123');\n * ```\n */\n async findByTenant(tenantId: string): Promise<Content[]> {\n return this.list({ where: { tenantId } });\n }\n\n /**\n * Find all global content (not associated with any tenant).\n *\n * Routes through the shared tenant-global helper so it does not throw under\n * an active tenant context (an explicit `tenant_id IS NULL` filter would be\n * flagged as an isolation violation). (#1600)\n *\n * @returns Promise resolving to array of global Content objects\n *\n * @example\n * ```typescript\n * const globalContent = await contents.findGlobal();\n * ```\n */\n async findGlobal(): Promise<Content[]> {\n return queryGlobal<Content>(this);\n }\n\n /**\n * Find content for a tenant including global content.\n *\n * This returns both tenant-specific content and global content (tenantId is null),\n * useful for showing a tenant their content plus any shared/global resources.\n *\n * Fails closed if an active tenant context requests a different tenant's\n * rows; the admin/system path keeps the cross-tenant capability. (#1600)\n *\n * @param tenantId - The tenant ID to include\n * @returns Promise resolving to array of Content objects (tenant + global)\n *\n * @example\n * ```typescript\n * const allAccessibleContent = await contents.findWithGlobals('tenant-123');\n * ```\n */\n async findWithGlobals(tenantId: string): Promise<Content[]> {\n return queryWithGlobals<Content>(this, tenantId, 'Content.findWithGlobals');\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"contents-BSGdKb3k.js","names":[],"sources":["../../src/safe-remote-url.ts","../../src/contents.ts"],"sourcesContent":["/**\n * Shared SSRF guard for content's outbound fetches (feed sync, mirroring).\n *\n * Any code path that fetches a caller-supplied URL — RSS/Atom feeds, the\n * `Mirror` content type, link previews — is an SSRF vector: an attacker who can\n * influence the URL can make the server fetch `169.254.169.254` cloud metadata,\n * `localhost` admin panels, or other internal services. This module rejects\n * URLs that resolve to private, loopback, link-local, CGNAT, or cloud-metadata\n * ranges before any request is made, and re-validates redirect hops.\n *\n * Unlike a literal-IP-only check, {@link assertSafeRemoteUrl} resolves hostnames\n * via DNS so a public name pointing at a private IP is also caught (S5 #1388).\n */\nimport { lookup as dnsLookup } from 'node:dns/promises';\nimport { isIP } from 'node:net';\n\nexport type ResolvedAddress = { address: string; family?: number };\nexport type ResolveHostname = (hostname: string) => Promise<ResolvedAddress[]>;\n\nexport interface SafeRemoteUrlOptions {\n /** Skip the private-network checks (trusted callers only, e.g. local dev). */\n allowPrivateNetworkHosts?: boolean;\n /** Injectable resolver for tests; defaults to {@link defaultResolveHostname}. */\n resolveHostname?: ResolveHostname;\n}\n\nexport async function defaultResolveHostname(\n hostname: string,\n): Promise<ResolvedAddress[]> {\n return dnsLookup(hostname, { all: true, verbatim: false });\n}\n\nexport function isBlockedIPv4(address: string): boolean {\n const parts = address.split('.');\n if (parts.length !== 4) return true;\n // Strict per-octet validation: `Number('')` is 0, so without a digit check a\n // malformed input like `1..2.3` would parse to `[1,0,2,3]` and be treated as a\n // valid (and possibly allowed) address. Anything not 1-3 digits in 0-255 is\n // unparseable → blocked (review #1562).\n const octets = parts.map((part) =>\n /^\\d{1,3}$/.test(part) ? Number(part) : Number.NaN,\n );\n if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {\n return true;\n }\n\n const [first, second] = octets;\n return (\n first === 0 ||\n first === 10 ||\n first === 127 ||\n first >= 224 ||\n (first === 100 && second >= 64 && second <= 127) ||\n (first === 169 && second === 254) ||\n (first === 172 && second >= 16 && second <= 31) ||\n (first === 192 && second === 168) ||\n (first === 198 && (second === 18 || second === 19))\n );\n}\n\n/**\n * Expand an IPv6 string to its 8 hextets (numbers), or null if unparseable.\n * Handles `::` compression, an embedded dotted-IPv4 tail, and bracketed forms.\n */\nfunction expandIPv6(address: string): number[] | null {\n let work = address.toLowerCase().replace(/^\\[|\\]$/g, '');\n if (work.includes('.')) {\n // Embedded dotted IPv4 tail (e.g. ::ffff:127.0.0.1) → convert to 2 hextets.\n const m = work.match(/(\\d{1,3}(?:\\.\\d{1,3}){3})$/);\n if (!m) return null;\n const o = m[1].split('.').map(Number);\n if (o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;\n const hi = ((o[0] << 8) | o[1]).toString(16);\n const lo = ((o[2] << 8) | o[3]).toString(16);\n work = `${work.slice(0, work.length - m[1].length)}${hi}:${lo}`;\n }\n const halves = work.split('::');\n if (halves.length > 2) return null;\n const head = halves[0] ? halves[0].split(':') : [];\n const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];\n const groups =\n halves.length === 2\n ? [\n ...head,\n ...Array(Math.max(0, 8 - head.length - tail.length)).fill('0'),\n ...tail,\n ]\n : head;\n if (groups.length !== 8) return null;\n const hextets = groups.map((g) =>\n /^[0-9a-f]{1,4}$/.test(g) ? Number.parseInt(g, 16) : Number.NaN,\n );\n return hextets.some((n) => Number.isNaN(n)) ? null : hextets;\n}\n\nexport function isBlockedIPv6(address: string): boolean {\n const hextets = expandIPv6(address);\n if (hextets) {\n // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d, deprecated)\n // both embed an IPv4 in the last 2 hextets — decode and apply the IPv4\n // blocklist so a loopback/private IPv4 can't be smuggled through any IPv6\n // encoding (compressed, expanded, dotted, or hex) (review #1562, P1).\n const firstFiveZero = hextets.slice(0, 5).every((h) => h === 0);\n if (firstFiveZero && (hextets[5] === 0xffff || hextets[5] === 0)) {\n const [, , , , , , g6, g7] = hextets;\n const ipv4 = `${g6 >> 8}.${g6 & 0xff}.${g7 >> 8}.${g7 & 0xff}`;\n return isBlockedIPv4(ipv4);\n }\n }\n\n const normalized = address.toLowerCase().replace(/^\\[|\\]$/g, '');\n return (\n normalized === '::' ||\n normalized === '::1' ||\n normalized.startsWith('fc') ||\n normalized.startsWith('fd') ||\n /^fe[89ab]/.test(normalized) ||\n normalized.startsWith('ff')\n );\n}\n\nexport function isBlockedAddress(address: string): boolean {\n const family = isIP(address);\n if (family === 4) return isBlockedIPv4(address);\n if (family === 6) return isBlockedIPv6(address);\n // Anything that isn't a recognisable IP literal (after DNS resolution should\n // have produced one) is treated as unsafe.\n return true;\n}\n\n/**\n * Parse and validate a remote URL for outbound fetching. Rejects non-http(s)\n * schemes, embedded credentials, and hosts that resolve to non-public ranges.\n * Returns the parsed {@link URL} on success; throws a descriptive `Error`\n * otherwise.\n */\nexport async function assertSafeRemoteUrl(\n rawUrl: string,\n options: SafeRemoteUrlOptions = {},\n): Promise<URL> {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n throw new Error('Remote URL must be an absolute URL');\n }\n\n if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n throw new Error('Remote URL must use http or https');\n }\n if (url.username || url.password) {\n throw new Error('Remote URL must not include credentials');\n }\n if (!url.hostname) {\n throw new Error('Remote URL must include a hostname');\n }\n\n if (options.allowPrivateNetworkHosts) return url;\n\n const resolver = options.resolveHostname ?? defaultResolveHostname;\n const addresses =\n isIP(url.hostname) === 0\n ? await resolver(url.hostname)\n : [{ address: url.hostname }];\n\n if (\n !addresses.length ||\n addresses.some(({ address }) => isBlockedAddress(address))\n ) {\n throw new Error('Remote URL must resolve to a public network address');\n }\n\n return url;\n}\n\n/** Redirect status codes that reroute a request to a new Location. */\nconst REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);\nconst DEFAULT_MAX_REDIRECTS = 5;\nconst DEFAULT_RESOLVE_TIMEOUT_MS = 10_000;\n\nexport interface SafeRedirectOptions extends SafeRemoteUrlOptions {\n /** Maximum redirect hops to follow before failing. Default 5. */\n maxRedirects?: number;\n /** Per-hop timeout in ms. Default 10s. */\n timeoutMs?: number;\n /** Injectable fetch (primarily for tests). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Resolve a URL's redirect chain and return the final safe {@link URL}, with\n * EVERY hop re-validated through {@link assertSafeRemoteUrl}.\n *\n * Use this before handing a URL to a fetcher that follows redirects on its own\n * (e.g. `fetchDocument`): the up-front {@link assertSafeRemoteUrl} check alone\n * can't stop an allowed public host from `30x`-redirecting into an internal /\n * loopback / metadata host, which would defeat the SSRF guard (review #1562).\n *\n * Redirects are followed with `GET` + `redirect: 'manual'` to match downstream\n * GET-based fetchers; response bodies are discarded (the redirect bodies are\n * empty and the terminal body is left for the caller to re-fetch), so this does\n * not double-download content.\n */\nexport async function resolveSafeFinalUrl(\n rawUrl: string,\n options: SafeRedirectOptions = {},\n): Promise<URL> {\n const fetchImpl = options.fetchImpl ?? fetch;\n const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;\n const timeoutMs = options.timeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS;\n let current = await assertSafeRemoteUrl(rawUrl, options);\n\n for (let hop = 0; hop <= maxRedirects; hop += 1) {\n const response = await fetchImpl(current, {\n method: 'GET',\n redirect: 'manual',\n signal: AbortSignal.timeout(timeoutMs),\n });\n // Release the connection without downloading the body.\n try {\n await response.body?.cancel();\n } catch {\n // best-effort\n }\n\n if (!REDIRECT_STATUSES.has(response.status)) {\n return current;\n }\n const location = response.headers.get('location');\n if (!location) return current;\n // Re-validate the resolved redirect target before following it.\n current = await assertSafeRemoteUrl(\n new URL(location, current).toString(),\n options,\n );\n }\n\n throw new Error('Remote URL exceeded the maximum number of redirects');\n}\n\n/**\n * Strip userinfo (`user:pass@`) from a URL so it can be safely logged or echoed\n * in an error message. Returns a placeholder for unparseable input. Never let a\n * credential-bearing URL reach logs/errors verbatim (review #1562).\n */\nexport function redactUrlCredentials(raw: string): string {\n try {\n const url = new URL(raw);\n if (url.username || url.password) {\n url.username = '';\n url.password = '';\n return url.toString();\n }\n return raw;\n } catch {\n return '[unparseable url]';\n }\n}\n","import { writeFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport { fetchDocument } from '@happyvertical/documents';\nimport { ensureDirectoryExists } from '@happyvertical/files';\nimport { createLogger } from '@happyvertical/logger';\nimport type { SmrtCollectionOptions } from '@happyvertical/smrt-core';\nimport { SmrtCollection, smrt } from '@happyvertical/smrt-core';\nimport type { Image } from '@happyvertical/smrt-images';\nimport { queryGlobal, queryWithGlobals } from '@happyvertical/smrt-tenancy';\nimport type { DataQueryResult } from '@happyvertical/smrt-types';\nimport { makeSlug } from '@happyvertical/utils';\nimport YAML from 'yaml';\nimport { htmlToMarkdown, resolveBodyFormat } from './body-format';\nimport { Content } from './content';\nimport {\n getEffectiveContentGovernanceConfig,\n loadPersistedContentGovernanceDefinitions,\n resolveEffectiveContentGovernance,\n} from './content-governance';\nimport {\n type ContentQueryCollection,\n executeContentQuery,\n} from './content-query';\nimport {\n type ResolveHostname,\n redactUrlCredentials,\n resolveSafeFinalUrl,\n} from './safe-remote-url';\nimport { serializeContent, serializeFact } from './serialization';\nimport type {\n ThumbnailOptions,\n ThumbnailStrategy,\n} from './thumbnail-generator';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Options accepted by {@link Contents.generateMissingThumbnails}. Also reused\n * (as a `Partial`) for the `thumbnail` config block passed through the\n * collection constructor from `smrt.config.js`.\n */\nexport interface GenerateMissingThumbnailsOptions {\n /**\n * Thumbnail generation strategy\n */\n strategy: ThumbnailStrategy;\n\n /**\n * Optional filter for content to process\n */\n where?: Record<string, unknown>;\n\n /**\n * Maximum number of thumbnails to generate\n */\n limit?: number;\n\n // Headline card options\n brandColor?: string;\n backgroundColor?: string;\n logoUrl?: string;\n template?: 'default' | 'news' | 'minimal';\n\n // Static map options\n mapProvider?: 'mapbox' | 'google';\n zoom?: number;\n\n // AI options\n style?: 'photorealistic' | 'illustration' | 'abstract' | 'minimal';\n\n // Common options\n width?: number;\n height?: number;\n}\n\n/**\n * Configuration options for Contents collection\n */\nexport interface ContentsOptions extends SmrtCollectionOptions {\n /**\n * Directory to store content files\n */\n contentDir?: string;\n\n /**\n * Default thumbnail-generation settings sourced from `smrt.config.js`. Merged\n * under per-call options in `generateMissingThumbnails`.\n */\n thumbnail?: Partial<GenerateMissingThumbnailsOptions>;\n}\n\nfunction isAIClientOptions(\n ai: SmrtCollectionOptions['ai'],\n): ai is AIClientOptions {\n return (\n !!ai &&\n typeof ai === 'object' &&\n !('embed' in ai) &&\n !('generateImage' in ai)\n );\n}\n\n/**\n * Collection for managing Content objects\n *\n * The Contents collection provides functionality for managing and manipulating\n * collections of Content objects, including saving to the filesystem and\n * mirroring content from remote URLs.\n */\n@smrt({\n api: {\n include: [\n 'browseFacts',\n 'getBySlug',\n 'getGovernanceDefinitionsAction',\n 'resolveGovernanceAction',\n 'queryAction',\n ],\n routes: {\n queryAction: {\n scope: 'collection',\n method: 'POST',\n path: 'query',\n },\n browseFacts: {\n scope: 'collection',\n method: 'GET',\n path: 'facts',\n },\n getBySlug: {\n scope: 'collection',\n method: 'GET',\n path: 'by-slug',\n },\n getGovernanceDefinitionsAction: {\n scope: 'collection',\n method: 'GET',\n path: 'governance',\n },\n resolveGovernanceAction: {\n scope: 'collection',\n method: 'GET',\n path: 'governance/resolve',\n },\n },\n },\n mcp: false,\n cli: false,\n})\nexport class Contents extends SmrtCollection<Content> {\n /**\n * Class constructor for collection items\n */\n static _itemClass = Content;\n\n /**\n * Configuration options\n */\n public options: ContentsOptions = {} as ContentsOptions;\n\n /**\n * Directory to store content files\n */\n public contentDir?: string;\n\n /**\n * Cache for loaded content\n */\n public loaded: Map<string, Content>;\n\n /**\n * Creates a new Contents collection\n *\n * Use the static `create()` method inherited from SmrtCollection for proper initialization.\n *\n * @param options - Configuration options\n */\n constructor(options: ContentsOptions = {}) {\n super(options);\n this.options = options;\n this.loaded = new Map();\n }\n\n /**\n * Gets the database interface\n *\n * @returns Database interface\n */\n getDb() {\n return this._db;\n }\n\n /**\n * Initializes the collection\n *\n * @returns Promise that resolves to this instance\n */\n public async initialize(): Promise<this> {\n await super.initialize();\n return this;\n }\n\n private async getFactCollection() {\n const { FactCollection } = await import('@happyvertical/smrt-facts');\n return FactCollection.create(this.options);\n }\n\n /**\n * Bounded, tenant-safe content query (`POST /api/v1/contents/query`).\n *\n * Accepts the canonical `DataQueryRequest` envelope (#2444) and returns a\n * normalized `DataQueryResult`, so a list surface can filter, sort, page,\n * count, and facet server-side instead of hydrating the whole collection.\n *\n * The request body carries no authority: only schema-declared field ids are\n * accepted, and tenant scoping is applied by `executeContentQuery` itself\n * (fail-closed to global rows when tenancy is enabled with no active tenant\n * context). Applications that need additional site/organization scoping call\n * `executeContentQuery` directly with a trusted `scope`.\n *\n * The parameter is named `options` deliberately: the route generator passes\n * the raw request body straight through as a single `options` argument, so\n * the wire body IS the `DataQueryRequest` rather than a wrapper object.\n *\n * @param options Untrusted `DataQueryRequest` from the caller.\n * @returns A validated, bounded `DataQueryResult`.\n */\n public async queryAction(options: unknown): Promise<DataQueryResult> {\n return executeContentQuery(\n this as unknown as ContentQueryCollection,\n options,\n );\n }\n\n public async browseFacts(\n options: {\n q?: string;\n query?: string;\n limit?: number | string;\n offset?: number | string;\n minSimilarity?: number | string;\n includeSuperseded?: boolean | string;\n latestOnly?: boolean | string;\n tenantId?: string | null;\n } = {},\n ) {\n try {\n const facts = await this.getFactCollection();\n const query = options.query || options.q || '';\n const limit =\n options.limit !== undefined ? Number(options.limit) : undefined;\n const offset =\n options.offset !== undefined ? Number(options.offset) : undefined;\n const minSimilarity =\n options.minSimilarity !== undefined\n ? Number(options.minSimilarity)\n : undefined;\n const includeSuperseded =\n options.includeSuperseded === true ||\n options.includeSuperseded === 'true';\n const latestOnly =\n options.latestOnly === undefined\n ? true\n : options.latestOnly === true || options.latestOnly === 'true';\n\n const results = await facts.browseCatalog(query, {\n limit: Number.isFinite(limit) ? limit : undefined,\n offset: Number.isFinite(offset) ? offset : undefined,\n minSimilarity: Number.isFinite(minSimilarity)\n ? minSimilarity\n : undefined,\n includeSuperseded,\n latestOnly,\n tenantId: options.tenantId ?? null,\n });\n\n return results.map(serializeFact);\n } catch (error) {\n // Gracefully handle missing facts table (cross-package dependency)\n if (\n typeof error === 'object' &&\n error !== null &&\n (error as { code?: unknown }).code === 'DB_SCHEMA_MISSING'\n ) {\n return [];\n }\n throw error;\n }\n }\n\n public async getBySlug(\n options: {\n slug?: string;\n context?: string;\n status?: string;\n tenantId?: string | null;\n } = {},\n ) {\n if (!options.slug) {\n throw new Error('slug is required');\n }\n\n const where: {\n slug: string;\n context: string;\n tenantId?: string | null;\n } = {\n slug: options.slug,\n context: options.context || '',\n };\n if (options.tenantId !== undefined) {\n where.tenantId = options.tenantId;\n }\n\n const content = await this.get(where);\n\n if (!content) {\n return null;\n }\n\n if (options.status && content.status !== options.status) {\n return null;\n }\n\n return serializeContent(content);\n }\n\n public async getGovernanceDefinitionsAction(\n options: { tenantId?: string | null } = {},\n ) {\n const [effective, persisted] = await Promise.all([\n getEffectiveContentGovernanceConfig({\n db: this.db,\n tenantId: options.tenantId,\n }),\n loadPersistedContentGovernanceDefinitions({\n db: this.db,\n tenantId: options.tenantId,\n }),\n ]);\n\n return {\n effective: {\n policies: effective.policies.map((policy) => ({\n ...policy,\n ...(persisted.policies.find((item) => item.key === policy.key) || {}),\n })),\n profiles: effective.profiles.map((profile) => ({\n ...profile,\n ...(persisted.profiles.find((item) => item.key === profile.key) ||\n {}),\n })),\n assignments: effective.assignments.map((assignment) => ({\n ...assignment,\n ...(persisted.assignments.find(\n (item) => item.key === assignment.key,\n ) || {}),\n })),\n },\n persisted: {\n policies: persisted.policies,\n profiles: persisted.profiles,\n assignments: persisted.assignments,\n },\n };\n }\n\n public async resolveGovernanceAction(\n options: {\n type?: string;\n variant?: string | null;\n tenantId?: string | null;\n } = {},\n ) {\n return resolveEffectiveContentGovernance({\n contentType: options.type || null,\n contentVariant: options.variant || null,\n db: this.db,\n tenantId: options.tenantId,\n });\n }\n\n /**\n * Mirrors content from a remote URL\n *\n * Downloads and stores content from a remote URL, extracting text\n * and saving it as a Content object.\n *\n * @param options - Mirror options\n * @param options.url - URL to mirror\n * @param options.mirrorDir - Directory for caching mirrored files\n * @param options.context - Context for the mirrored content\n * @returns Promise resolving to the mirrored Content object\n * @throws Error if URL is invalid or missing\n */\n public async mirror(options: {\n url: string;\n mirrorDir?: string;\n context?: string;\n /**\n * Skip the SSRF host-blocking checks. Only set this for fully trusted,\n * operator-supplied URLs (e.g. local development), never for URLs that\n * originate from end users or external content.\n */\n allowPrivateNetworkHosts?: boolean;\n /** Injectable DNS resolver (primarily for tests). */\n resolveHostname?: ResolveHostname;\n /** Injectable fetch for redirect resolution (primarily for tests). */\n fetchImpl?: typeof fetch;\n }) {\n if (!options.url) {\n throw new Error('No URL provided');\n }\n // Validate the URL AND block private/loopback/link-local/metadata hosts\n // before fetching — `mirror()` fetches an arbitrary caller-supplied URL,\n // which is a classic SSRF sink (e.g. http://169.254.169.254/ metadata).\n // `fetchDocument` follows redirects on its own, so we resolve the redirect\n // chain ourselves first — re-validating every hop — and hand it the\n // already-validated terminal URL, closing the public-host-30x-to-internal\n // bypass (S5 #1388 / review #1562).\n let url: URL;\n try {\n url = await resolveSafeFinalUrl(options.url, {\n allowPrivateNetworkHosts: options.allowPrivateNetworkHosts,\n resolveHostname: options.resolveHostname,\n fetchImpl: options.fetchImpl,\n });\n } catch (error) {\n // Never echo the raw URL — it may carry userinfo credentials (review #1562).\n const safeUrl = redactUrlCredentials(options.url);\n logger.error('Refusing to mirror unsafe URL', { error, url: safeUrl });\n throw new Error(`Invalid URL provided: ${safeUrl}`);\n }\n const existing = await this.get({ url: options.url });\n if (existing) {\n return existing;\n }\n\n // Fetch and process the document via the already-validated terminal URL.\n const doc = await fetchDocument(url.toString(), {\n cacheDir: options?.mirrorDir,\n });\n\n const filename = url.pathname.split('/').pop();\n const nameWithoutExtension = filename?.replace(/\\.[^/.]+$/, '');\n const title = nameWithoutExtension?.replace(/[-_]/g, ' ');\n const slug = makeSlug(title as string);\n\n // Extract text from all document parts\n const body = doc.parts.map((part) => part.content).join('\\n\\n');\n if (body) {\n const content = new Content({\n url: options.url,\n type: 'mirror',\n title,\n slug,\n context: options.context || '',\n body,\n });\n await content.initialize();\n await content.save();\n return content;\n }\n }\n\n /**\n * Writes a Content object to the filesystem as a markdown file\n *\n * @param options - Options for writing the content file\n * @param options.content - Content object to write\n * @param options.contentDir - Directory to write the file to\n * @returns Promise that resolves when the file is written\n * @throws Error if contentDir is not provided\n */\n public async writeContentFile(options: {\n content: Content;\n contentDir: string;\n }) {\n const { content, contentDir } = options;\n if (!contentDir) {\n throw new Error('No content dir provided');\n }\n\n const { body } = content;\n const frontMatter = {\n title: content.title,\n slug: content.slug,\n context: content.context,\n author: content.author,\n publish_date: content.publish_date,\n };\n\n let output = '';\n if (frontMatter && Object.keys(frontMatter).length > 0) {\n output += '---\\n';\n output += YAML.stringify(frontMatter);\n output += '---\\n';\n }\n\n // Filesystem exports are markdown regardless of the editor save format.\n let formattedBody = body || '';\n const bodyFormat = resolveBodyFormat(content.bodyFormat, body);\n if (bodyFormat === 'html') {\n formattedBody = htmlToMarkdown(body || '');\n } else if (body && !this.isMarkdown(body)) {\n formattedBody = this.formatAsMarkdown(body);\n }\n output += formattedBody;\n\n const pathParts = [\n contentDir,\n content.context || '', // if empty, use empty string\n content.slug,\n 'index.md',\n ].filter(Boolean); // remove empty strings\n\n const outputFile = path.join(...(pathParts as string[]));\n\n // `context` and `slug` are persisted, caller-influenced fields. Without a\n // guard, a value like `../../etc/cron.d/x` escapes `contentDir` and lets an\n // export overwrite arbitrary files (path traversal). Confirm the joined\n // path still lives under the resolved content directory (S5 #1388).\n const resolvedDir = path.resolve(contentDir);\n const resolvedFile = path.resolve(outputFile);\n if (\n resolvedFile !== resolvedDir &&\n !resolvedFile.startsWith(resolvedDir + path.sep)\n ) {\n throw new Error(\n 'Refusing to write content file outside of the content directory',\n );\n }\n\n await ensureDirectoryExists(path.dirname(outputFile));\n await writeFile(outputFile, output);\n }\n\n /**\n * Checks if text appears to be in markdown format\n *\n * @param text - Text to check\n * @returns Boolean indicating if the text contains markdown syntax\n */\n private isMarkdown(text: string): boolean {\n // Basic check for common markdown indicators\n const markdownIndicators = [\n /^#\\s/m, // Headers\n /\\*\\*.+\\*\\*/, // Bold\n /\\*.+\\*/, // Italic\n /\\[.+\\]\\(.+\\)/, // Links\n /^\\s*[-*+]\\s/m, // Lists\n /^\\s*\\d+\\.\\s/m, // Numbered lists\n /```[\\s\\S]*```/, // Code blocks\n /^\\s*>/m, // Blockquotes\n ];\n\n return markdownIndicators.some((indicator) => indicator.test(text));\n }\n\n /**\n * Formats plain text as simple markdown\n *\n * @param text - Plain text to format\n * @returns Text formatted as basic markdown\n */\n private formatAsMarkdown(text: string): string {\n // Basic formatting of plain text to markdown\n return text\n .split(/\\n\\n+/)\n .map((paragraph) => paragraph.trim())\n .filter(Boolean)\n .join('\\n\\n');\n }\n\n /**\n * Synchronizes content to the filesystem\n *\n * Writes all article-type Content objects to the filesystem\n * as markdown files.\n *\n * @param options - Sync options\n * @param options.contentDir - Directory to write content files to\n * @returns Promise that resolves when synchronization is complete\n */\n public async syncContentDir(options: { contentDir?: string }) {\n const contentFilter = {\n type: 'article',\n };\n\n const contents = await this.list({ where: contentFilter });\n for (const content of contents) {\n await this.writeContentFile({\n content,\n contentDir: options.contentDir || this.options.contentDir || '',\n });\n }\n }\n\n /**\n * Generate thumbnails for content that doesn't have one\n *\n * @param options - Options for bulk thumbnail generation\n * @returns Promise resolving to result object with generated images and failed content IDs\n *\n * @example Generate headline cards for all published articles\n * ```typescript\n * const result = await contents.generateMissingThumbnails({\n * strategy: 'headline-card',\n * where: { type: 'article', status: 'published' },\n * brandColor: '#1a56db'\n * });\n * console.log(`Generated ${result.images.length} thumbnails`);\n * if (result.failed.length > 0) {\n * console.warn(`Failed to generate ${result.failed.length} thumbnails`);\n * }\n * ```\n */\n public async generateMissingThumbnails(\n options: GenerateMissingThumbnailsOptions,\n ): Promise<{\n images: Image[];\n failed: Array<{ contentId: string; error: string }>;\n }> {\n // Merge with thumbnail config from smrt.config.js (passed via constructor)\n // This allows CLI users to configure defaults in smrt.config.js:\n // thumbnail: { strategy: 'headline-card', brandColor: '#1976d2' }\n const configDefaults = this.options?.thumbnail || {};\n const mergedOptions = {\n ...configDefaults,\n ...options,\n };\n\n // Build query for content missing thumbnails\n const whereClause = {\n ...mergedOptions.where,\n thumbnailAssetId: null,\n };\n\n const contents = await this.list({\n where: whereClause,\n limit: mergedOptions.limit,\n });\n\n const generatedImages: Image[] = [];\n const failed: Array<{ contentId: string; error: string }> = [];\n\n for (const content of contents) {\n try {\n // Build thumbnail options based on strategy\n let thumbnailOptions: ThumbnailOptions;\n\n switch (mergedOptions.strategy) {\n case 'headline-card':\n thumbnailOptions = {\n strategy: 'headline-card',\n brandColor: mergedOptions.brandColor,\n backgroundColor: mergedOptions.backgroundColor,\n logoUrl: mergedOptions.logoUrl,\n template: mergedOptions.template,\n width: mergedOptions.width,\n height: mergedOptions.height,\n };\n break;\n\n case 'static-map':\n thumbnailOptions = {\n strategy: 'static-map',\n mapProvider: mergedOptions.mapProvider,\n zoom: mergedOptions.zoom,\n width: mergedOptions.width,\n height: mergedOptions.height,\n };\n break;\n\n case 'ai-generate':\n thumbnailOptions = {\n strategy: 'ai-generate',\n style: mergedOptions.style,\n width: mergedOptions.width,\n height: mergedOptions.height,\n ai: isAIClientOptions(this.options.ai)\n ? this.options.ai\n : undefined,\n };\n break;\n\n default:\n throw new Error(`Unknown strategy: ${mergedOptions.strategy}`);\n }\n\n const image = await content.generateThumbnail(thumbnailOptions);\n generatedImages.push(image);\n } catch (error) {\n const errorMessage =\n error instanceof Error ? error.message : String(error);\n logger.error(\n `Failed to generate thumbnail for content ${content.id}: ${errorMessage}`,\n );\n failed.push({\n contentId: content.id ?? 'unknown',\n error: errorMessage,\n });\n }\n }\n\n return { images: generatedImages, failed };\n }\n\n // ============================================\n // Tenant Helper Methods\n // ============================================\n\n /**\n * Find all content belonging to a specific tenant\n *\n * @param tenantId - The tenant ID to filter by\n * @returns Promise resolving to array of Content objects for the tenant\n *\n * @example\n * ```typescript\n * const tenantContent = await contents.findByTenant('tenant-123');\n * ```\n */\n async findByTenant(tenantId: string): Promise<Content[]> {\n return this.list({ where: { tenantId } });\n }\n\n /**\n * Find all global content (not associated with any tenant).\n *\n * Routes through the shared tenant-global helper so it does not throw under\n * an active tenant context (an explicit `tenant_id IS NULL` filter would be\n * flagged as an isolation violation). (#1600)\n *\n * @returns Promise resolving to array of global Content objects\n *\n * @example\n * ```typescript\n * const globalContent = await contents.findGlobal();\n * ```\n */\n async findGlobal(): Promise<Content[]> {\n return queryGlobal<Content>(this);\n }\n\n /**\n * Find content for a tenant including global content.\n *\n * This returns both tenant-specific content and global content (tenantId is null),\n * useful for showing a tenant their content plus any shared/global resources.\n *\n * Fails closed if an active tenant context requests a different tenant's\n * rows; the admin/system path keeps the cross-tenant capability. (#1600)\n *\n * @param tenantId - The tenant ID to include\n * @returns Promise resolving to array of Content objects (tenant + global)\n *\n * @example\n * ```typescript\n * const allAccessibleContent = await contents.findWithGlobals('tenant-123');\n * ```\n */\n async findWithGlobals(tenantId: string): Promise<Content[]> {\n return queryWithGlobals<Content>(this, tenantId, 'Content.findWithGlobals');\n }\n}\n"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { t as __exportAll } from "./chunks/rolldown-runtime-D7D4PA-g.js";
2
- import { $ as parseContentReviewResponse, A as normalizeContentTransparency, B as getAcceptedContentReviewStatuses, C as resolveContentTenantReadScope, F as smrtContentReviewPrompt, G as getContentReviewProfile, H as getContentReviewKind, I as smrtContentThumbnailAIGeneratePrompt, J as getEffectiveContentGovernanceConfig, K as getContentReviewProfileKeys, L as buildContentGovernanceAssignmentKey, M as ContentReference, N as promptMessageOptions, O as getQueryRows, P as smrtContentApplyCorrectionPrompt, R as buildContentReviewPrompt, S as mergeContentQueryScope, T as ThumbnailGenerator, U as getContentReviewPolicies, V as getContentGovernanceConfig, W as getContentReviewPolicy, X as hasStaticContentGovernancePolicy, Y as getFallbackPolicyKind, Z as hasStaticContentGovernanceProfile, _ as assertContentQuerySchema, a as CONTENT_QUERY_IDENTITY_FIELD, at as isAssetAssociable, b as clearContentQuerySchemaCache, c as CONTENT_QUERY_MIN_RESULT_BYTES, d as DATA_QUERY_MAX_JSON_DEPTH, et as resetContentGovernanceConfig, f as DATA_QUERY_MAX_JSON_STRING_LENGTH, g as MAX_CONTENT_QUERY_OR_BRANCHES, h as DATA_QUERY_MAX_WARNING_LENGTH, i as CONTENT_QUERY_EXCLUDED_FIELD_IDS, it as ContentAsset, j as ContentReferences, k as isMissingTableError, l as DATA_QUERY_FORBIDDEN_JSON_KEYS, m as DATA_QUERY_MAX_WARNINGS, n as CONTENT_QUERY_DEFAULT_PAGE_LIMIT, nt as resolveEffectiveContentGovernance, o as CONTENT_QUERY_MAX_PAGE_LIMIT, ot as isMetadataAccessor, p as DATA_QUERY_MAX_STRING_LENGTH, q as getContentReviewRequirements, r as CONTENT_QUERY_DEFAULT_SORT, rt as ContentAssetCollection, s as CONTENT_QUERY_MAX_RESULT_BYTES, t as CONTENT_QUERY_CLASS_NAME, tt as resolveConfiguredContentGovernance, u as DATA_QUERY_MAX_JSON_CONTAINER_ITEMS, v as buildContentQuerySchema, w as Content, x as executeContentQuery, y as buildDataQuerySchemaForClass, z as configureContentGovernance } from "./chunks/content-query-BsGgJ4XY.js";
2
+ import { $ as parseContentReviewResponse, A as normalizeContentTransparency, B as getAcceptedContentReviewStatuses, C as resolveContentTenantReadScope, F as smrtContentReviewPrompt, G as getContentReviewProfile, H as getContentReviewKind, I as smrtContentThumbnailAIGeneratePrompt, J as getEffectiveContentGovernanceConfig, K as getContentReviewProfileKeys, L as buildContentGovernanceAssignmentKey, M as ContentReference, N as promptMessageOptions, O as getQueryRows, P as smrtContentApplyCorrectionPrompt, R as buildContentReviewPrompt, S as mergeContentQueryScope, T as ThumbnailGenerator, U as getContentReviewPolicies, V as getContentGovernanceConfig, W as getContentReviewPolicy, X as hasStaticContentGovernancePolicy, Y as getFallbackPolicyKind, Z as hasStaticContentGovernanceProfile, _ as assertContentQuerySchema, a as CONTENT_QUERY_IDENTITY_FIELD, at as isAssetAssociable, b as clearContentQuerySchemaCache, c as CONTENT_QUERY_MIN_RESULT_BYTES, d as DATA_QUERY_MAX_JSON_DEPTH, et as resetContentGovernanceConfig, f as DATA_QUERY_MAX_JSON_STRING_LENGTH, g as MAX_CONTENT_QUERY_OR_BRANCHES, h as DATA_QUERY_MAX_WARNING_LENGTH, i as CONTENT_QUERY_EXCLUDED_FIELD_IDS, it as ContentAsset, j as ContentReferences, k as isMissingTableError, l as DATA_QUERY_FORBIDDEN_JSON_KEYS, m as DATA_QUERY_MAX_WARNINGS, n as CONTENT_QUERY_DEFAULT_PAGE_LIMIT, nt as resolveEffectiveContentGovernance, o as CONTENT_QUERY_MAX_PAGE_LIMIT, ot as isMetadataAccessor, p as DATA_QUERY_MAX_STRING_LENGTH, q as getContentReviewRequirements, r as CONTENT_QUERY_DEFAULT_SORT, rt as ContentAssetCollection, s as CONTENT_QUERY_MAX_RESULT_BYTES, t as CONTENT_QUERY_CLASS_NAME, tt as resolveConfiguredContentGovernance, u as DATA_QUERY_MAX_JSON_CONTAINER_ITEMS, v as buildContentQuerySchema, w as Content, x as executeContentQuery, y as buildDataQuerySchemaForClass, z as configureContentGovernance } from "./chunks/content-query-BfnrHwlW.js";
3
3
  import { DEFAULT_CONTENT_BODY_FORMAT, bodyToEditorHtml, editorHtmlToBody, extractBodyImages, htmlToMarkdown, renderMarkdownToHtml, resolveBodyFormat, sanitizeHtml, stripHtml } from "./body-format.js";
4
4
  import { r as ContentCorrection, t as ContentCorrectionCollection } from "./chunks/content-corrections-CKuY-MSv.js";
5
5
  import { contentEditorAssistantContextToChatProps, createContentEditorAssistantContext, sanitizeContentEditorAssistantFieldUpdates } from "./content-editor-assistant.js";
6
- import { r as assertSafeRemoteUrl, t as Contents } from "./chunks/contents-Cs8lWj2I.js";
6
+ import { r as assertSafeRemoteUrl, t as Contents } from "./chunks/contents-BSGdKb3k.js";
7
7
  import { r as ContentReview, t as ContentReviewCollection } from "./chunks/content-reviews-D6o0zK8s.js";
8
- import { r as ContentVersion, t as ContentVersionCollection } from "./chunks/content-versions-C6K6FtcF.js";
8
+ import { r as ContentVersion, t as ContentVersionCollection } from "./chunks/content-versions-DCwXHfgo.js";
9
9
  import { evaluateContentPublishReadiness } from "./publish-readiness.js";
10
10
  import { ObjectRegistry, SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, smrt } from "@happyvertical/smrt-core";
11
11
  import { AssetCollection, AssetStatusCollection, AssetTypeCollection } from "@happyvertical/smrt-assets";
@@ -888,7 +888,7 @@ var ContentContribution = class extends SmrtObject {
888
888
  return ContentContributorCollection.create(this.options);
889
889
  }
890
890
  async getContentsCollection() {
891
- const { Contents } = await import("./chunks/contents-Cs8lWj2I.js").then((n) => n.n);
891
+ const { Contents } = await import("./chunks/contents-BSGdKb3k.js").then((n) => n.n);
892
892
  return Contents.create({ db: this.db });
893
893
  }
894
894
  async getAssetCollection() {
@@ -2,7 +2,7 @@
2
2
  "version": "1.0.0",
3
3
  "timestamp": 0,
4
4
  "packageName": "@happyvertical/smrt-content",
5
- "packageVersion": "0.51.0",
5
+ "packageVersion": "0.51.1",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-content:ContentAsset": {
8
8
  "name": "contentasset",
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as assertContentQuerySchema, t as CONTENT_QUERY_CLASS_NAME, v as buildContentQuerySchema, w as Content, x as executeContentQuery } from "./chunks/content-query-BsGgJ4XY.js";
1
+ import { _ as assertContentQuerySchema, t as CONTENT_QUERY_CLASS_NAME, v as buildContentQuerySchema, w as Content, x as executeContentQuery } from "./chunks/content-query-BfnrHwlW.js";
2
2
  import { MAX_DATA_QUERY_PAGE_LIMIT, createDataQueryFingerprint, normalizeDataQueryRequest, startRestServer } from "@happyvertical/smrt-core";
3
3
  import { createHash } from "node:crypto";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -3,12 +3,12 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-content",
6
- "packageVersion": "0.51.0",
6
+ "packageVersion": "0.51.1",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "7afa597147948304f48b32ecb22b98d4fa2e3198730fb990eae5609894463c33",
11
- "packageJson": "f7a006a891fd2eba37df45979fad513350738cc1bc8b9e6f5359dc5c417f13be",
10
+ "manifest": "d887f0dd60d0210ba80c55666650888e149c8e7241ae8cfdec6805e8c8985584",
11
+ "packageJson": "2807dae46e3ac16c4aee81dad62338baeb164d6faa5db2ffabdac927e02add79",
12
12
  "agents": "9bfa2ade9b555e0c49fe38053f0413a36bb497eb9fce51c2b4628e228a6e98f2",
13
13
  "moduleDoc:agents/content-list.md": "e5e990d6688f2a89fe521741e9af6e202dad5fa827307c727cfb5db92a83145a"
14
14
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-content",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "smrtJsdoc": "strict",
5
5
  "description": "Content processing module for SMRT framework - handles documents, web content, and media",
6
6
  "type": "module",
@@ -55,18 +55,18 @@
55
55
  "@happyvertical/logger": "^0.89.9",
56
56
  "@happyvertical/ocr": "^0.61.4",
57
57
  "@happyvertical/pdf": "^0.65.9",
58
- "@happyvertical/smrt-assets": "0.51.0",
59
- "@happyvertical/smrt-agents": "0.51.0",
60
- "@happyvertical/smrt-chat": "0.51.0",
61
- "@happyvertical/smrt-core": "0.51.0",
62
- "@happyvertical/smrt-facts": "0.51.0",
63
- "@happyvertical/smrt-images": "0.51.0",
64
- "@happyvertical/smrt-messages": "0.51.0",
65
- "@happyvertical/smrt-prompts": "0.51.0",
66
- "@happyvertical/smrt-profiles": "0.51.0",
67
- "@happyvertical/smrt-tenancy": "0.51.0",
68
- "@happyvertical/smrt-types": "0.51.0",
69
- "@happyvertical/smrt-ui": "0.51.0",
58
+ "@happyvertical/smrt-assets": "0.51.1",
59
+ "@happyvertical/smrt-agents": "0.51.1",
60
+ "@happyvertical/smrt-chat": "0.51.1",
61
+ "@happyvertical/smrt-core": "0.51.1",
62
+ "@happyvertical/smrt-facts": "0.51.1",
63
+ "@happyvertical/smrt-images": "0.51.1",
64
+ "@happyvertical/smrt-messages": "0.51.1",
65
+ "@happyvertical/smrt-prompts": "0.51.1",
66
+ "@happyvertical/smrt-profiles": "0.51.1",
67
+ "@happyvertical/smrt-tenancy": "0.51.1",
68
+ "@happyvertical/smrt-types": "0.51.1",
69
+ "@happyvertical/smrt-ui": "0.51.1",
70
70
  "@happyvertical/spider": "^1.1.13",
71
71
  "@happyvertical/sql": "^0.89.9",
72
72
  "@happyvertical/utils": "^0.89.9",
@@ -83,9 +83,9 @@
83
83
  },
84
84
  "devDependencies": {
85
85
  "@faker-js/faker": "^10.5.0",
86
- "@happyvertical/smrt-playground": "0.51.0",
87
- "@happyvertical/smrt-users": "0.51.0",
88
- "@happyvertical/smrt-vitest": "0.51.0",
86
+ "@happyvertical/smrt-playground": "0.51.1",
87
+ "@happyvertical/smrt-users": "0.51.1",
88
+ "@happyvertical/smrt-vitest": "0.51.1",
89
89
  "@sveltejs/kit": "^2.69.1",
90
90
  "@sveltejs/package": "^2.5.8",
91
91
  "@sveltejs/vite-plugin-svelte": "^7.1.2",