Read a store's config from the registry record
`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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user