feat(train): the instance's own map tiles, drawn by Train's style (ADR 0030, D50)

Protomaps' basemap cut for Europe and served by pmtiles beside Train's server
(compose.maps.yaml), drawn by Train's own style, light and dark, which the
server serves. Only people signed in get tiles: map.source hands a key that
Caddy has the server check before each tile; nothing logs a tile.

Also fixes .dockerignore, which left apps/train/ out of the build context, so
Train's server's and BRouter's images couldn't have been built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 16:04:05 +01:00
commit 190c3872f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 3726 additions and 97 deletions

View file

@ -1,25 +1,85 @@
import 'package:halkyon_train_server/src/map/instance_map.dart';
import 'package:halkyon_train_server/src/map/map_key.dart';
import 'package:halkyon_train_server/src/map/map_source_config.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
import '../test_tools/serverpod_test_tools.dart';
/// The map the app draws workouts on, as the instance names it (ADR 0030,
/// D11, D22): for the people signed in, as the provider's key may be in it.
/// D11, D22, D50): for the people signed in, as the provider's key may be in
/// it and the instance's own tiles are theirs alone.
void main() {
withServerpod('The map source', (sessionBuilder, endpoints) {
final signedIn = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
final ownTiles = InstanceMap(
mapConfigFrom(const {
'MAP_TILES_URL':
'https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt',
}),
keys: MapKeys('s' * mapKeySecretMinLength),
);
late Serverpod pod;
setUp(() => pod = sessionBuilder.build().serverpod);
tearDown(() => InstanceMap.use(pod, InstanceMap(mapConfigFrom(const {}))));
test('names OpenFreeMap to someone signed in, where the operator sets '
'nothing', () async {
final source = await endpoints.map.source(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
),
);
'nothing, with no key', () async {
InstanceMap.use(pod, InstanceMap(mapConfigFrom(const {})));
final source = await endpoints.map.source(signedIn);
expect(source.name, 'OpenFreeMap');
expect(source.styleUrl, startsWith('https://tiles.openfreemap.org/'));
expect(source.tileKey, isNull);
});
test("hands the instance's own tiles with Train's styles and a key "
'their check takes', () async {
InstanceMap.use(pod, ownTiles);
final source = await endpoints.map.source(signedIn);
expect(source.darkStyleUrl, endsWith('/map/style/dark.json'));
expect(ownTiles.allows(['Bearer ${source.tileKey}']), isTrue);
expect(ownTiles.style('light'), contains('/tiles/europe/{z}/{x}/{y}'));
});
test("refuses a tile without a key, or with one it didn't make", () {
final other = MapKeys('t' * mapKeySecretMinLength).issue();
expect(ownTiles.allows(null), isFalse);
expect(ownTiles.allows(['Bearer $other']), isFalse);
expect(ownTiles.allows([other]), isFalse);
});
test('refuses every tile, and has no style, where the tiles are no '
"instance's own", () {
final provider = InstanceMap(
mapConfigFrom(const {
'MAP_STYLE_URL': 'https://maps.example.org/style.json',
}),
keys: MapKeys('s' * mapKeySecretMinLength),
);
final key = ownTiles.keys!.issue();
expect(provider.allows(['Bearer $key']), isFalse);
expect(provider.style('light'), isNull);
expect(ownTiles.style('satellite'), isNull);
});
test("won't start serving its own tiles without a secret to key them", () {
InstanceMap.use(
pod,
InstanceMap(
mapConfigFrom(const {
'MAP_TILES_URL': 'https://train.example.org/tiles/{z}/{x}/{y}.mvt',
}),
),
);
expect(() => checkMapKeySecret(pod), throwsStateError);
});
test('names nothing to nobody signed in', () async {

View file

@ -0,0 +1,60 @@
import 'package:clock/clock.dart';
import 'package:halkyon_train_server/src/map/map_key.dart';
import 'package:test/test.dart';
/// The keys the instance's own tiles are asked with (ADR 0030, D50): valid
/// 30 days, naming no one, refused once expired or changed.
void main() {
final keys = MapKeys('s' * mapKeySecretMinLength);
final day = DateTime.utc(2026, 10, 29, 12);
test('a key issued is valid, and the same all day', () {
withClock(Clock.fixed(day), () {
final key = keys.issue();
expect(key, startsWith('m1.'));
expect(keys.valid(key), isTrue);
withClock(Clock.fixed(day.add(const Duration(hours: 11))), () {
expect(keys.issue(), key);
});
});
});
test('a key is valid for 30 days, and refused after', () {
final key = withClock(Clock.fixed(day), keys.issue);
withClock(Clock.fixed(day.add(const Duration(days: 30))), () {
expect(keys.valid(key), isTrue);
});
withClock(Clock.fixed(day.add(const Duration(days: 31))), () {
expect(keys.valid(key), isFalse);
});
});
test('a key changed, made under another secret, or naming a day beyond '
"a key's lifetime is refused", () {
withClock(Clock.fixed(day), () {
final key = keys.issue();
final [prefix, issuedDay, mac] = key.split('.');
final otherSecret = MapKeys('t' * mapKeySecretMinLength).issue();
final later = '$prefix.${int.parse(issuedDay) + 400}.$mac';
final changed =
'$prefix.$issuedDay.${mac.substring(1)}${mac[0] == 'A' ? 'B' : 'A'}';
for (final refused in [
otherSecret,
later,
changed,
'$prefix.$issuedDay',
'm2.$issuedDay.$mac',
'',
]) {
expect(keys.valid(refused), isFalse, reason: refused);
}
});
});
test("the key is a bearer's in the Authorization header", () {
expect(MapKeys.bearerOf(['Bearer m1.1.abc']), 'm1.1.abc');
expect(MapKeys.bearerOf(['Basic dXNlcjpwdw==']), isNull);
expect(MapKeys.bearerOf(['Bearer']), isNull);
expect(MapKeys.bearerOf(null), isNull);
});
}

View file

@ -1,7 +1,8 @@
import 'package:halkyon_train_server/src/map/map_source_config.dart';
import 'package:test/test.dart';
/// The instance's map source as its operator sets it (ADR 0030, D11, D22).
/// The instance's map source as its operator sets it (ADR 0030, D11, D22,
/// D50).
void main() {
test('without a setting it is OpenFreeMap, named, with its attribution', () {
final source = mapSourceFrom(const {});
@ -65,4 +66,41 @@ void main() {
expect(source.aboutUrl, isNull);
});
test("the instance's own tiles are drawn by Train's style beside them, "
'light and dark, and come before a provider', () {
final config = mapConfigFrom(const {
'MAP_TILES_URL': 'https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt',
'MAP_STYLE_URL': 'https://maps.example.org/style.json',
});
expect(
config.tiles,
'https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt',
);
expect(config.source.name, 'train.example.org');
expect(
config.source.styleUrl,
'https://train.example.org/map/style/light.json',
);
expect(
config.source.darkStyleUrl,
'https://train.example.org/map/style/dark.json',
);
expect(config.source.attribution, contains('Protomaps'));
expect(config.source.tileKey, isNull);
});
test("tiles that aren't HTTPS or lack a coordinate are never used", () {
for (final url in [
'http://train.example.org/tiles/{z}/{x}/{y}.mvt',
'https://train.example.org/tiles/{z}/{x}.mvt',
'https://{z}/{x}/{y}',
'',
]) {
final config = mapConfigFrom({'MAP_TILES_URL': url});
expect(config.tiles, isNull, reason: url);
expect(config.source.name, 'OpenFreeMap', reason: url);
}
});
}

View file

@ -0,0 +1,89 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_train_server/src/map/train_style.dart';
import 'package:test/test.dart';
/// Train's style for the instance's own tiles (ADR 0030, D50). The app's
/// tests read the copies this test keeps in its fixtures with
/// vector_tile_renderer, which takes them without a warning; run with
/// `UPDATE_STYLE_FIXTURES=1` after changing the style.
void main() {
const tiles = 'https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt';
/// Protomaps' basemap's layers, schema v4.
const schema = {
'boundaries',
'buildings',
'earth',
'landcover',
'landuse',
'places',
'pois',
'roads',
'transit',
'water',
};
test('reads the tiles given, at zooms 0 to 15, with their credit', () {
final style = trainStyle(tiles: tiles, dark: false);
final source =
(style['sources']! as Map<String, Object?>)['protomaps']!
as Map<String, Object?>;
expect(source['tiles'], [tiles]);
expect(source['maxzoom'], 15);
expect(source['attribution'], contains('OpenStreetMap'));
});
test("each layer reads one of the schema's layers, its ids unique", () {
for (final dark in [false, true]) {
final layers =
(trainStyle(tiles: tiles, dark: dark)['layers']! as List<Object?>)
.cast<Map<String, Object?>>();
expect({
for (final layer in layers) layer['id'],
}, hasLength(layers.length));
for (final layer in layers.skip(1)) {
expect(
schema,
contains(layer['source-layer']),
reason: '${layer['id']}',
);
}
}
});
test("names come in the app's language where the tiles have it, else as "
'they are', () {
String names(String? language) => jsonEncode(
trainStyle(tiles: tiles, dark: false, language: language)['layers'],
);
expect(names('de'), contains('name:de'));
expect(names('en'), contains('name:en'));
expect(names('fr'), isNot(contains('name:')));
expect(names(null), isNot(contains('name:')));
});
test("the app's fixtures are the style as it is", () {
final fixtures = {
'train_style_light_de.json': trainStyle(
tiles: tiles,
dark: false,
language: 'de',
),
'train_style_dark.json': trainStyle(tiles: tiles, dark: true),
};
for (final MapEntry(key: name, value: style) in fixtures.entries) {
final file = File('../halkyon_train_flutter/test/fixtures/map/$name');
final json = '${const JsonEncoder.withIndent(' ').convert(style)}\n';
if (Platform.environment['UPDATE_STYLE_FIXTURES'] == '1') {
file.writeAsStringSync(json);
}
expect(
file.existsSync() ? file.readAsStringSync() : null,
json,
reason: '$name: run with UPDATE_STYLE_FIXTURES=1',
);
}
});
}