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
3 changed files with 3726 additions and 97 deletions

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',
);
}
});
}