feat(train): the instance names its map source (ADR 0030, D11, D22)

Train's first endpoint of its own, map.source, names the map workouts
are drawn on, for people signed in only, as a provider's key may be in
its style's address: OpenFreeMap's Liberty style where the operator sets
nothing, else MAP_STYLE_URL (HTTPS only) with MAP_SOURCE_NAME,
MAP_ATTRIBUTION and MAP_ABOUT_URL. A model without a table: no
migration. D22 is recorded in ADR 0030.

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-28 13:42:28 +01:00
commit dd778cb2cd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 574 additions and 4 deletions

View file

@ -21,11 +21,36 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
as _izehhkf5;
import '../map/map_endpoint.dart' as _i35zr6r3;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
class Endpoints extends _is.EndpointDispatch {
@override
void initializeEndpoints(_is.Server server) {
var endpoints = <String, _is.Endpoint>{
'map': _i35zr6r3.MapEndpoint()
..initialize(
server,
'map',
null,
),
};
connectors['map'] = _is.EndpointConnector(
name: 'map',
endpoint: endpoints['map']!,
methodConnectors: {
'source': _is.MethodConnector(
name: 'source',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['map'] as _i35zr6r3.MapEndpoint).source(session),
),
},
);
modules['halkyon_base'] = _ik8molhd.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_idp'] = _iais.Endpoints()

View file

@ -0,0 +1,127 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// The map this instance's workouts are drawn on (ADR 0030, D11, D22), as
/// its operator sets it: a MapLibre style, its source's name and the credit
/// it asks for, and where the source says what it sees. OpenFreeMap where
/// nothing is set.
abstract class MapSource
implements _is.SerializableModel, _is.ProtocolSerialization {
MapSource._({
required this.name,
required this.styleUrl,
required this.attribution,
this.aboutUrl,
});
factory MapSource({
required String name,
required String styleUrl,
required String attribution,
String? aboutUrl,
}) = _MapSourceImpl;
factory MapSource.fromJson(Map<String, dynamic> jsonSerialization) {
return MapSource(
name: jsonSerialization['name'] as String,
styleUrl: jsonSerialization['styleUrl'] as String,
attribution: jsonSerialization['attribution'] as String,
aboutUrl: jsonSerialization['aboutUrl'] as String?,
);
}
/// What the app names as drawing the map, as `OpenFreeMap`.
String name;
/// The MapLibre (Mapbox GL) style the vector tiles are drawn by; a
/// provider's key may be in it, so only people signed in get it.
String styleUrl;
/// The credit the map shows, as the source's terms ask.
String attribution;
/// Where the source says what it keeps of those viewing its maps; null
/// where the operator names nothing.
String? aboutUrl;
/// Returns a shallow copy of this [MapSource]
/// with some or all fields replaced by the given arguments.
@_is.useResult
MapSource copyWith({
String? name,
String? styleUrl,
String? attribution,
String? aboutUrl,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'MapSource',
'name': name,
'styleUrl': styleUrl,
'attribution': attribution,
if (aboutUrl != null) 'aboutUrl': aboutUrl,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'MapSource',
'name': name,
'styleUrl': styleUrl,
'attribution': attribution,
if (aboutUrl != null) 'aboutUrl': aboutUrl,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _MapSourceImpl extends MapSource {
_MapSourceImpl({
required String name,
required String styleUrl,
required String attribution,
String? aboutUrl,
}) : super._(
name: name,
styleUrl: styleUrl,
attribution: attribution,
aboutUrl: aboutUrl,
);
/// Returns a shallow copy of this [MapSource]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
MapSource copyWith({
String? name,
String? styleUrl,
String? attribution,
Object? aboutUrl = _Undefined,
}) {
return MapSource(
name: name ?? this.name,
styleUrl: styleUrl ?? this.styleUrl,
attribution: attribution ?? this.attribution,
aboutUrl: aboutUrl is String? ? aboutUrl : this.aboutUrl,
);
}
}

View file

@ -21,9 +21,11 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
as _izehhkf5;
import 'map/map_source.dart' as _ikw0qg8x;
import 'privacy/content_key.dart' as _imed3x5d;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
export 'map/map_source.dart';
export 'privacy/content_key.dart';
export 'workout/workout.dart';
export 'workout/workout_series.dart';
@ -297,6 +299,9 @@ class Protocol extends _is.DatabaseSerializationManager {
}
}
if (t == _ikw0qg8x.MapSource) {
return _ikw0qg8x.MapSource.fromJson(data) as T;
}
if (t == _imed3x5d.ContentKey) {
return _imed3x5d.ContentKey.fromJson(data) as T;
}
@ -306,6 +311,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i92mefto.WorkoutSeries) {
return _i92mefto.WorkoutSeries.fromJson(data) as T;
}
if (t == _is.getType<_ikw0qg8x.MapSource?>()) {
return (data != null ? _ikw0qg8x.MapSource.fromJson(data) : null) as T;
}
if (t == _is.getType<_imed3x5d.ContentKey?>()) {
return (data != null ? _imed3x5d.ContentKey.fromJson(data) : null) as T;
}
@ -339,6 +347,7 @@ class Protocol extends _is.DatabaseSerializationManager {
static String? getClassNameForType(Type type) {
return switch (type) {
_ikw0qg8x.MapSource => 'MapSource',
_imed3x5d.ContentKey => 'ContentKey',
_i5b9b17z.Workout => 'Workout',
_i92mefto.WorkoutSeries => 'WorkoutSeries',
@ -359,6 +368,8 @@ class Protocol extends _is.DatabaseSerializationManager {
}
switch (data) {
case _ikw0qg8x.MapSource():
return 'MapSource';
case _imed3x5d.ContentKey():
return 'ContentKey';
case _i5b9b17z.Workout():
@ -405,6 +416,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName is! String) {
return super.deserializeByClassName(data);
}
if (dataClassName == 'MapSource') {
return deserialize<_ikw0qg8x.MapSource>(data['data']);
}
if (dataClassName == 'ContentKey') {
return deserialize<_imed3x5d.ContentKey>(data['data']);
}

View file

@ -0,0 +1,2 @@
map:
- source:

View file

@ -0,0 +1,13 @@
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/map/map_source_config.dart';
import 'package:serverpod/serverpod.dart';
/// The map workouts are drawn on (ADR 0030, D11, D22), for the people signed
/// in: an operator's provider may have its key in the style's address.
class MapEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// The instance's map source, as its operator sets it.
Future<MapSource> source(Session session) async => mapSource();
}

View file

@ -0,0 +1,16 @@
### The map this instance's workouts are drawn on (ADR 0030, D11, D22), as
### its operator sets it: a MapLibre style, its source's name and the credit
### it asks for, and where the source says what it sees. OpenFreeMap where
### nothing is set.
class: MapSource
fields:
### What the app names as drawing the map, as `OpenFreeMap`.
name: String
### The MapLibre (Mapbox GL) style the vector tiles are drawn by; a
### provider's key may be in it, so only people signed in get it.
styleUrl: String
### The credit the map shows, as the source's terms ask.
attribution: String
### Where the source says what it keeps of those viewing its maps; null
### where the operator names nothing.
aboutUrl: String?

View file

@ -0,0 +1,44 @@
import 'dart:io';
import 'package:halkyon_train_server/src/generated/protocol.dart';
/// The map source where the operator sets none (ADR 0030, D11): OpenFreeMap,
/// which takes no key and makes no contract, its Liberty style.
final openFreeMap = MapSource(
name: 'OpenFreeMap',
styleUrl: 'https://tiles.openfreemap.org/styles/liberty',
attribution: 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap',
aboutUrl: 'https://openfreemap.org',
);
/// The instance's map source, as its operator's environment sets it.
MapSource mapSource() => mapSourceFrom(Platform.environment);
/// The map source [environment] sets: `MAP_STYLE_URL`, a MapLibre style over
/// HTTPS, with `MAP_SOURCE_NAME` (else the style's host), `MAP_ATTRIBUTION`
/// (else OpenStreetMap's credit) and `MAP_ABOUT_URL` (HTTPS, else none);
/// [openFreeMap] without a style, or with one that isn't an HTTPS address.
MapSource mapSourceFrom(Map<String, String> environment) {
final style = _https(environment['MAP_STYLE_URL']);
if (style == null) {
return openFreeMap;
}
String? set(String name) => switch (environment[name]?.trim()) {
final value? when value.isNotEmpty => value,
_ => null,
};
return MapSource(
name: set('MAP_SOURCE_NAME') ?? style.host,
styleUrl: style.toString(),
attribution: set('MAP_ATTRIBUTION') ?? '© OpenStreetMap contributors',
aboutUrl: _https(environment['MAP_ABOUT_URL'])?.toString(),
);
}
/// [value] as an HTTPS address with a host; null for anything else.
Uri? _https(String? value) {
final uri = Uri.tryParse(value?.trim() ?? '');
return uri != null && uri.isScheme('https') && uri.host.isNotEmpty
? uri
: null;
}