feat(creator-packs): Phase 3 — uninstall, license, release key-injection

Completes the Creator Packs feature.

- Uninstall: CreatorPackService.uninstallPack reuses ZimService.delete (removes
  the ZIM, drops it from the Kiwix library, clears the InstalledResource) behind
  DELETE /api/creator-packs/:id. A trash control on installed cards appears only
  on the settings "manage" surface (allowUninstall) — never on the Content
  Explorer block or the wizard — with a danger confirm modal.
- License: draft "Project NOMAD Creator Pack License v1.0" at
  collections/creator-pack-license.md (marked DRAFT — pending legal review;
  personal-use, no-redistribution, official-channel-only, creators retain
  copyright). The install modal links to it ("View license").
- Release key-injection: Dockerfile ARG/ENV CREATOR_PACKS_APP_KEY in the runtime
  stage (empty default → source/CI builds ship unconfigured and hide the UI) +
  build-primary-image.yml passes it from the CREATOR_PACKS_APP_KEY CI secret.
  NOTE FOR JAKE: add that repo secret = the entitlement Worker's key.
- Update flow was already functional (installed-with-update cards → "Update
  pack"); verified.

Browser-tested on NOMAD3: uninstall round-trip (file + DB row + kiwix library
cleaned, then reinstalled), settings-only uninstall control, install-modal
license link, and the "Update available" pill via a temporary catalog bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-07-15 11:41:57 -07:00
parent 9a864cb41b
commit 311171f963
10 changed files with 249 additions and 16 deletions

View File

@ -52,3 +52,4 @@ jobs:
VERSION=${{ inputs.version }}
BUILD_DATE=${{ github.event.workflow_run.created_at }}
VCS_REF=${{ github.sha }}
CREATOR_PACKS_APP_KEY=${{ secrets.CREATOR_PACKS_APP_KEY }}

View File

@ -73,6 +73,17 @@ LABEL org.opencontainers.image.title="Project N.O.M.A.D" \
org.opencontainers.image.licenses="Apache-2.0"
ENV NODE_ENV=production
# Creator Packs entitlement key, injected into OFFICIAL release builds at build
# time (--build-arg CREATOR_PACKS_APP_KEY=... from the CREATOR_PACKS_APP_KEY CI
# secret; see build-primary-image.yml). Baked as an ENV so admin/start/env.ts
# reads it at runtime. Empty by default, so builds from source (and any build
# without the secret) ship UNCONFIGURED and hide the Creator Packs UI. The key
# lands in this public image layer (extractable — the accepted ceiling); rotate
# via `wrangler secret put APP_KEY` + a new image if it leaks.
ARG CREATOR_PACKS_APP_KEY=""
ENV CREATOR_PACKS_APP_KEY=$CREATOR_PACKS_APP_KEY
WORKDIR /app
COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=build /app/build /app

View File

@ -47,4 +47,16 @@ export default class CreatorPacksController {
})
}
}
async uninstall({ params, response }: HttpContext) {
const packId = params.id as string
const result = await this.creatorPackService.uninstallPack(packId)
switch (result.code) {
case 'uninstalled':
return { message: 'Pack uninstalled', filename: result.filename }
case 'not_installed':
return response.status(404).send({ message: `Creator pack is not installed: ${packId}` })
}
}
}

View File

@ -2,6 +2,7 @@ import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
import { join } from 'node:path'
import { DockerService } from '#services/docker_service'
import { ZimService } from '#services/zim_service'
import { CollectionManifestService } from '#services/collection_manifest_service'
import { RunDownloadJob } from '#jobs/run_download_job'
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
@ -28,6 +29,10 @@ export type InstallPackResult =
| { code: 'not_found' }
| { code: 'not_configured' }
export type UninstallPackResult =
| { code: 'uninstalled'; filename: string }
| { code: 'not_installed' }
/**
* Creator Packs install rail. Diverges from the curated-collections rail in
* exactly one way: instead of a static manifest URL, the ZIM is fetched from the
@ -129,6 +134,30 @@ export class CreatorPackService {
return { code: 'dispatched', filename }
}
/**
* Uninstall an installed pack: delete the ZIM (which via ZimService.delete also
* removes it from the Kiwix library and clears its InstalledResource row).
* Uses the INSTALLED version (not the catalog's) so we remove the file that's
* actually on disk even if a newer version has since been published.
*/
async uninstallPack(packId: string): Promise<UninstallPackResult> {
const { default: InstalledResource } = await import('#models/installed_resource')
const installed = await InstalledResource.query()
.where('resource_type', 'zim')
.where('resource_id', packId)
.first()
if (!installed) {
return { code: 'not_installed' }
}
const filename = `${packId}_${installed.version}.zim`
const zimService = new ZimService(this.dockerService)
await zimService.delete(filename)
logger.info(`[CreatorPackService] Uninstalled pack ${packId} (${filename})`)
return { code: 'uninstalled', filename }
}
private async ensureKiwixInstalled(): Promise<void> {
try {
const kiwixUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.KIWIX)

View File

@ -7,6 +7,7 @@ import {
IconCircleCheck,
IconLoader2,
IconMovie,
IconTrash,
} from '@tabler/icons-react'
export interface CreatorPackCardProps {
@ -14,9 +15,11 @@ export interface CreatorPackCardProps {
/** In-session wizard selection highlight (before anything is installed). */
selected?: boolean
onClick?: (pack: CreatorPackWithStatus) => void
/** When set, an installed pack shows an uninstall control (settings surface only). */
onUninstall?: (pack: CreatorPackWithStatus) => void
}
const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ pack, selected, onClick }) => {
const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ pack, selected, onClick, onUninstall }) => {
const isInstalled = pack.status === 'installed'
const isDownloading = pack.status === 'downloading'
const hasUpdate = !!pack.available_update_version
@ -91,7 +94,23 @@ const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ pack, selected, onCli
</span>
)}
</div>
{statusBadge}
<div className="flex items-center gap-2 shrink-0">
{statusBadge}
{onUninstall && isInstalled && (
<button
type="button"
title="Uninstall pack"
aria-label="Uninstall pack"
className="p-1 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-colors"
onClick={(e) => {
e.stopPropagation()
onUninstall(pack)
}}
>
<IconTrash className="w-5 h-5" />
</button>
)}
</div>
</div>
</div>
)

View File

@ -9,24 +9,37 @@ import StyledModal from '~/components/StyledModal'
import { formatBytes } from '~/lib/util'
import type { CreatorPackWithStatus } from '../../types/collections'
// Canonical Creator Pack License (one license across the seed packs). Opened in a
// new tab from the install modal; install is an online action so an external link
// is fine. A per-pack catalog `license_url` can supersede this later if needed.
const LICENSE_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/blob/main/collections/creator-pack-license.md'
export interface CreatorPacksSectionProps {
/** Show uninstall controls on installed packs (the settings "manage" surface). */
allowUninstall?: boolean
}
/**
* Install-on-click grid of Creator Packs + a confirm modal. Shared by the
* Content Explorer block and the /settings/creator-packs page. Renders NOTHING
* when the build isn't configured (fork / key unset) a fork never sees a
* broken install button. The Easy Setup wizard does NOT use this (it needs
* selection semantics, not install-on-click) and drives CreatorPackCard itself.
* Install-on-click grid of Creator Packs + confirm modals. Shared by the Content
* Explorer block and the /settings/creator-packs page. Renders NOTHING when the
* build isn't configured (fork / key unset) a fork never sees a broken install
* button. The Easy Setup wizard does NOT use this (it needs selection semantics,
* not install-on-click) and drives CreatorPackCard itself.
*/
const CreatorPacksSection: React.FC = () => {
const CreatorPacksSection: React.FC<CreatorPacksSectionProps> = ({ allowUninstall }) => {
const { configured, packs, invalidate: invalidateCreatorPacks } = useCreatorPacks()
const { invalidate: invalidateDownloads } = useDownloads({ filetype: 'zim' })
const { addNotification } = useNotifications()
const [packToInstall, setPackToInstall] = useState<CreatorPackWithStatus | null>(null)
const [installing, setInstalling] = useState(false)
const [packToUninstall, setPackToUninstall] = useState<CreatorPackWithStatus | null>(null)
const [uninstalling, setUninstalling] = useState(false)
if (!configured) return null
const handleConfirm = async () => {
const handleConfirmInstall = async () => {
if (!packToInstall) return
setInstalling(true)
try {
@ -43,6 +56,23 @@ const CreatorPacksSection: React.FC = () => {
}
}
const handleConfirmUninstall = async () => {
if (!packToUninstall) return
setUninstalling(true)
try {
await api.uninstallCreatorPack(packToUninstall.id)
addNotification({ message: `Uninstalled "${packToUninstall.name}"`, type: 'success' })
invalidateCreatorPacks()
invalidateDownloads()
setPackToUninstall(null)
} catch (error) {
console.error('Error uninstalling creator pack:', error)
addNotification({ message: 'An error occurred while uninstalling.', type: 'error' })
} finally {
setUninstalling(false)
}
}
return (
<>
<div className="flex items-center gap-3 mt-8 mb-4">
@ -60,7 +90,12 @@ const CreatorPacksSection: React.FC = () => {
{packs.length > 0 ? (
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
{packs.map((pack) => (
<CreatorPackCard key={pack.id} pack={pack} onClick={setPackToInstall} />
<CreatorPackCard
key={pack.id}
pack={pack}
onClick={setPackToInstall}
onUninstall={allowUninstall ? setPackToUninstall : undefined}
/>
))}
</div>
) : (
@ -72,10 +107,8 @@ const CreatorPacksSection: React.FC = () => {
title={packToInstall ? `Install ${packToInstall.name}?` : 'Install Creator Pack'}
onClose={() => !installing && setPackToInstall(null)}
onCancel={() => setPackToInstall(null)}
onConfirm={handleConfirm}
confirmText={
packToInstall?.available_update_version ? 'Update pack' : 'Install pack'
}
onConfirm={handleConfirmInstall}
confirmText={packToInstall?.available_update_version ? 'Update pack' : 'Install pack'}
confirmIcon="IconDownload"
confirmLoading={installing}
icon={<IconMovie className="w-6 h-6" />}
@ -88,11 +121,41 @@ const CreatorPacksSection: React.FC = () => {
background and appear in Kiwix when ready.
</p>
<p className="text-sm text-text-muted">
Licensed content personal use, not for redistribution.
Licensed content personal use, not for redistribution.{' '}
<a
href={LICENSE_URL}
target="_blank"
rel="noreferrer"
className="text-desert-green underline hover:no-underline"
onClick={(e) => e.stopPropagation()}
>
View license
</a>
</p>
</div>
)}
</StyledModal>
<StyledModal
open={!!packToUninstall}
title={packToUninstall ? `Uninstall ${packToUninstall.name}?` : 'Uninstall Creator Pack'}
onClose={() => !uninstalling && setPackToUninstall(null)}
onCancel={() => setPackToUninstall(null)}
onConfirm={handleConfirmUninstall}
confirmText="Uninstall pack"
confirmIcon="IconTrash"
confirmVariant="danger"
confirmLoading={uninstalling}
icon={<IconMovie className="w-6 h-6" />}
>
{packToUninstall && (
<p className="text-text-secondary">
This removes the downloaded videos (
{formatBytes(packToUninstall.size_mb * 1024 * 1024, 0)}) from this NOMAD. You can
reinstall the pack anytime.
</p>
)}
</StyledModal>
</>
)
}

View File

@ -717,6 +717,15 @@ class API {
})()
}
async uninstallCreatorPack(id: string) {
return catchInternal(async () => {
const response = await this.client.delete<{ message: string; filename?: string }>(
`/creator-packs/${id}`
)
return response.data
})()
}
async listDocs() {
return catchInternal(async () => {
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')

View File

@ -20,7 +20,7 @@ export default function CreatorPacksPage() {
{configured ? (
<>
<CreatorPacksSection />
<CreatorPacksSection allowUninstall />
<div className="mt-10">
<ActiveDownloads filetype="zim" withHeader />
</div>

View File

@ -231,6 +231,7 @@ router
.group(() => {
router.get('/', [CreatorPacksController, 'index'])
router.post('/:id/install', [CreatorPacksController, 'install'])
router.delete('/:id', [CreatorPacksController, 'uninstall'])
})
.prefix('/api/creator-packs')

View File

@ -0,0 +1,88 @@
# Project NOMAD Creator Pack License
**Version 1.0** &nbsp;·&nbsp; License ID: `nomad-creator-pack-1.0`
> **DRAFT — pending legal review.** This document is a starting point drafted for
> convenience, not legal advice, and has not been reviewed by an attorney. The
> terms below describe the intended arrangement; have counsel review and finalize
> before relying on it. Nothing here is a binding offer until finalized.
---
## 1. What this covers
This license governs **Creator Pack content** — the curated video collections
(and their titles, descriptions, thumbnails, and packaging) distributed through
Project NOMAD as "Creator Packs." It does **not** cover the Project NOMAD
software itself, which is licensed separately under Apache License 2.0. Where the
two ever appear to conflict, the Apache 2.0 license governs the software and this
license governs the pack content.
## 2. Ownership
Each Creator retains all copyright and other rights in their own videos and
associated materials. Distribution through Project NOMAD does not transfer
ownership. "Creator" means the individual or organization whose content a pack
contains (for example, Crosstalk Solutions or Project NOMAD).
## 3. Grant to Project NOMAD
Each Creator grants Project NOMAD (Crosstalk Solutions, LLC) a non-exclusive,
revocable right to reproduce, package, host, and distribute their Creator Pack
**through the official Project NOMAD distribution channel only**. This grant does
not permit Project NOMAD to sublicense the content for redistribution outside
that official channel.
## 4. License to end users
Subject to these terms, an end user who installs a Creator Pack on their own
Project NOMAD server is granted a **personal, non-commercial, non-transferable,
non-exclusive** license to store and view that pack's content offline on their
own device(s) for their own use and that of their household or immediate
organization.
## 5. Restrictions
Except as expressly permitted above, you may **not**:
1. **Redistribute or re-host** any Creator Pack or its contents — including
copying pack files to another server, mirror, CDN, bucket, torrent, or file
share, or making them available for download by others.
2. **Bundle or ship** Creator Pack content with any fork, derivative, or
third-party distribution of Project NOMAD or any other product.
3. **Serve** Creator Pack content from any distribution channel other than the
official Project NOMAD channel, or circumvent the entitlement controls that
gate access to it.
4. **Sell, rent, sublicense, or commercially exploit** the content, or use it to
train machine-learning models.
5. **Remove or alter** creator branding, attribution, or license notices.
For clarity: the Apache 2.0 license on the Project NOMAD **software** permits
forking the software, but it grants **no rights** to the Creator Pack **content**,
which remains governed exclusively by this license. A fork may not distribute or
serve Creator Packs.
## 6. Termination
This license terminates automatically if you breach it, and Project NOMAD or a
Creator may revoke it at any time. On termination you must stop using and delete
the affected Creator Pack content. Sections 2, 5, 7, and 8 survive termination.
## 7. No warranty
Creator Pack content is provided **"as is," without warranty of any kind**,
express or implied, including merchantability, fitness for a particular purpose,
and non-infringement.
## 8. Limitation of liability
To the maximum extent permitted by law, neither Project NOMAD, Crosstalk
Solutions, LLC, nor any Creator is liable for any indirect, incidental, special,
consequential, or punitive damages arising from the Creator Pack content or this
license.
---
*Questions about this license or Creator Pack participation: contact Project NOMAD
via https://www.projectnomad.us. Governing law and venue to be specified on legal
review.*