Read a store's config from the registry record
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

`GET /api/stores` records now carry a `config` field — a store's `config.json` moved
into the record that already said what the store is — and the client applies it
directly. Installing a store no longer depends on a second repository existing and
staying reachable, and a store can be configured from the site's admin alone.

The order is registry config, then a repository's `config.json`, then the engine's
defaults. The middle one is why nothing has to move at once: a registry whose stores
have not been migrated is read exactly as before.

The window cannot supply a config. It is handed stores to show and hands one back to
install, but only as an identity: `RegistryStoreDtoMapper.toModel` drops the config and
`StoreProvisioningService` reads the record again from the registry first. A config
decides where files are written and, through `paths.subfolder`, which subtree the store
may later delete from — not a decision the renderer gets to make, for the same reason a
`GameDto` carries no paths. Tested by installing from a record carrying
`subfolder: "ATTACKER"` and `install_root: "/tmp/pwned"` and finding neither on disk.

A store that has left the registry, or a registry that cannot be re-read, still
installs: it falls back to the engine's defaults rather than refusing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 06:43:39 +02:00
co-authored by Claude Opus 5
parent 06f3f2a3b1
commit 045c7bf5b7
9 changed files with 173 additions and 107 deletions
@@ -16,12 +16,20 @@ export class RegistryStoreDtoMapper {
return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store))
}
/** The window hands a record straight back when asking for an install. */
/**
* The window hands a record back when asking for an install — as an identity only.
*
* There is no `config` here on purpose. A store's config decides where files are
* written and which subtree the store may delete from, so it must not be something
* the window can supply; `StoreProvisioningService` reads the real record from the
* registry instead. That is the same rule as `GameDto` carrying no paths.
*/
public toModel (dto: RegistryStoreDto): RegistryStore {
return {
name: dto.name,
catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl
storeRepositoryUrl: dto.storeRepositoryUrl,
config: null
}
}
}
@@ -31,11 +31,44 @@ export class StoreProvisioningService {
}
public async installStore (
store: RegistryStore,
chosen: RegistryStore,
progress?: EngineProgressListener
): Promise<InstalledStore> {
const store = await this.resolveFromRegistry(chosen, progress)
const home = this.stores.resolveDefaultHome(deriveStoreId(store))
const installed = await this.installer.installEngine(home, store, progress)
return this.selection.adoptStore(installed)
}
/**
* The registry's own record for the store that was chosen.
*
* The window is handed stores to display and hands one back to install, but what it
* hands back is not what gets used. A store's config decides where files are written
* and which subtree the store may later delete from, so it cannot be something the
* window supplies — the choice is treated as an identity, a name and a catalog, and
* the record behind it is read again here.
*
* A store that has since left the registry, or a registry that cannot be reached, is
* not a reason to refuse the install: it proceeds on the engine's defaults, which is
* what a store with no config gets anyway.
*/
private async resolveFromRegistry (
chosen: RegistryStore,
progress?: EngineProgressListener
): Promise<RegistryStore> {
try {
const listed = await this.registry.listStores()
const found = listed.find((store: RegistryStore): boolean =>
store.catalogUrl === chosen.catalogUrl && store.name === chosen.name)
if (found !== undefined) return found
progress?.onLog?.(
`${chosen.name} is no longer in the registry — installing on the engine's defaults`)
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error)
progress?.onLog?.(
`the registry could not be read again (${reason}) — installing on the engine's defaults`)
}
return { ...chosen, config: null }
}
}