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 }
}
}
+15 -5
View File
@@ -1,14 +1,24 @@
/**
* A store the site's registry offers.
*
* A name and a catalog are what make a store; the repository is optional. When
* there is one it stays the authority on how that store behaves — which platforms
* it offers, where things land — and when there is not, the engine's own defaults
* cover all of it and this record covers the identity. That is the whole reason a
* store needs no repository of its own.
* A name and a catalog are what make a store; the other two fields are optional.
*
* `config` is how that store behaves — which platforms it offers, which release
* statuses it shows, where its games land — in the same shape a store's `config.json`
* had, because it is the same thing moved into the registry. With none, the engine's
* defaults cover all of it and this record covers the identity, which is why a store
* needs nothing of its own to be installable.
*
* `storeRepositoryUrl` is where the store's own repository is, when it has one. It is
* still read as a config source for a registry that has not moved its stores over yet.
*/
export interface RegistryStore {
readonly name: string
readonly catalogUrl: string
readonly storeRepositoryUrl: string | null
/**
* Deliberately not `JsonRecord`: `domain` imports nothing from `infrastructure`, and
* a JSON object is describable without it.
*/
readonly config: Readonly<Record<string, unknown>> | null
}
@@ -1,7 +1,7 @@
import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError'
import type { RegistryStore } from '../../domain/models/RegistryStore'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { asRecord, readRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { BuildConfiguration } from '../config/BuildConfiguration'
import type { HttpTextClient } from '../http/HttpTextClient'
@@ -15,10 +15,10 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
* field a build was packaged with (for shipping a client for another site), and finally
* the address of ours.
*
* A record needs a name and a catalog URL; those two make a store. The repository
* is optional and arrives as null when absent — a store configured by nothing but
* this record installs on the engine's defaults. Records missing either of the two
* required fields are dropped rather than half-used.
* A record needs a name and a catalog URL; those two make a store. The config and the
* repository are both optional and arrive as null when absent — a store configured by
* nothing but this record installs on the engine's defaults. Records missing either of
* the two required fields are dropped rather than half-used.
*/
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string
@@ -53,7 +53,8 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return {
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: repository.length > 0 ? repository : null
storeRepositoryUrl: repository.length > 0 ? repository : null,
config: readRecord(row, 'config')
}
})
.filter((store: RegistryStore): boolean =>
@@ -77,13 +77,15 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
/**
* The store's configuration.
*
* Three cases, and all of them install:
* Four cases, and all of them install:
*
* - **a repository with a config.json** — that file is the authority on how the
* store behaves: which platforms it offers, which statuses it shows, where
* things land;
* - **a config on the registry record** — the authority on how the store behaves:
* which platforms it offers, which statuses it shows, where things land. No
* request at all, because it arrived with the store list;
* - **a repository with a config.json** — the same thing in its older home, read
* for a registry whose stores have not moved over yet;
* - **a repository without one** (404) — the engine's defaults, as below;
* - **no repository at all** — the same defaults, without the round trip.
* - **neither** — the same defaults, without the round trip.
*
* The engine's built-in defaults already cover the host-to-asset mapping, the
* modes, the platforms and the behaviour, so what a store actually has to supply is
@@ -113,6 +115,13 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
storeId: string,
progress: EngineProgressListener
): Promise<Record<string, unknown>> {
// The registry's own answer wins, and needs no request: a store's configuration is
// part of its record now rather than a file in a repository that has to exist and
// stay reachable.
if (store.config !== null) {
progress.onLog?.(`${store.name} is configured by the registry`)
return { ...store.config }
}
const repositoryUrl = store.storeRepositoryUrl
if (repositoryUrl === null) {
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
+23 -4
View File
@@ -76,24 +76,43 @@ class SmokeTest {
}
/**
* A store needs no repository, and a repository needs no config.json: either way
* the engine's defaults carry it. So both absences are reported, not failed.
* Where this store's configuration would come from, in the order the installer asks.
*
* A store needs no config and no repository: either way the engine's defaults carry
* it, so every absence here is reported rather than failed. What is worth seeing is
* *which* source answered — a store still being configured by a repository is a store
* that has not moved over to the registry yet.
*/
private async checkStoreConfig (store: RegistryStore): Promise<void> {
if (store.config !== null) {
const subfolder = this.readSubfolder(store.config)
this.reportOk(' config', `${String(Object.keys(store.config).length)} sections ` +
`from the registry${subfolder === null ? '' : `, subfolder ${subfolder}`}`)
return
}
if (store.storeRepositoryUrl === null) {
this.reportOk(' config', 'no repository — the engine defaults would be used')
this.reportOk(' config', 'none, and no repository — the engine defaults would be used')
return
}
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
try {
const config: unknown = JSON.parse(await this.httpClient.readText(url))
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
this.reportOk(' config.json', `${String(sections)} sections`)
this.reportOk(' config.json', `${String(sections)} sections from the repository ` +
'(not yet moved to the registry)')
} catch (error: unknown) {
this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`)
}
}
/** The prune boundary, which is the field worth seeing at a glance. */
private readSubfolder (config: Readonly<Record<string, unknown>>): string | null {
const paths = config['paths']
if (typeof paths !== 'object' || paths === null) return null
const subfolder = (paths as Readonly<Record<string, unknown>>)['subfolder']
return typeof subfolder === 'string' ? subfolder : null
}
private findStore (): InstalledStore | null {
const sandbox = process.env['SMOKE_HOME']
if (sandbox !== undefined && sandbox.length > 0) {