feat(server): the instance names where its source is

Both licences (AGPL for the app, SSPL for the server) require offering the source of what an instance runs to the people who use it. instance.info() now carries sourceCodeUrl: the operator's SOURCE_URL, which they must set when running modified code, and otherwise this project's repository. Serverpod validates its configuration files strictly, so the value comes from the environment; deploy/compose.yaml passes it on from deploy/.env, whose example explains it. A test covers the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 16:19:46 +02:00
commit 863f0899e3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 113 additions and 14 deletions

View file

@ -22,3 +22,9 @@ SESSION_KEY_HASH_PEPPER=
# Empty: GitHub sign-in is off, and the app doesn't offer it.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# Where the source of what this instance runs is, shown in the app's About dialog: both
# licences (AGPL for the app, SSPL for the server) require offering it to the people who
# use the instance. Empty: the project's repository, which is right for unmodified code.
# Running modified code, set it to where your modified source is published.
SOURCE_URL=

View file

@ -50,6 +50,8 @@ services:
# GitHub sign-in, only with this instance's GitHub OAuth app; empty: off.
SERVERPOD_PASSWORD_githubClientId: ${GITHUB_CLIENT_ID:-}
SERVERPOD_PASSWORD_githubClientSecret: ${GITHUB_CLIENT_SECRET:-}
# Where the source of what this instance runs is; empty: the project's.
SOURCE_URL: ${SOURCE_URL:-}
depends_on:
postgres:
condition: service_healthy

View file

@ -144,7 +144,8 @@ class EndpointInstance extends _isc.EndpointRef {
@override
String get name => 'instance';
/// This instance's offer; GitHub sign-in only with the operator's OAuth app.
/// This instance's offer: GitHub sign-in only with the operator's OAuth
/// app, and where its source is.
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
'instance',

View file

@ -16,13 +16,20 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// someone else, with its own configuration.
abstract class InstanceInfo
implements _isc.SerializableModel, _isc.ProtocolSerialization {
InstanceInfo._({this.gitHubClientId});
InstanceInfo._({
this.gitHubClientId,
required this.sourceCodeUrl,
});
factory InstanceInfo({String? gitHubClientId}) = _InstanceInfoImpl;
factory InstanceInfo({
String? gitHubClientId,
required String sourceCodeUrl,
}) = _InstanceInfoImpl;
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
return InstanceInfo(
gitHubClientId: jsonSerialization['gitHubClientId'] as String?,
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
);
}
@ -30,15 +37,23 @@ abstract class InstanceInfo
/// sign-in is off here.
String? gitHubClientId;
/// Where the source of what this instance runs is; the licences require
/// offering it to the people who use it.
String sourceCodeUrl;
/// Returns a shallow copy of this [InstanceInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
InstanceInfo copyWith({String? gitHubClientId});
InstanceInfo copyWith({
String? gitHubClientId,
String? sourceCodeUrl,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'InstanceInfo',
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
'sourceCodeUrl': sourceCodeUrl,
};
}
@ -47,6 +62,7 @@ abstract class InstanceInfo
return {
'__className__': 'InstanceInfo',
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
'sourceCodeUrl': sourceCodeUrl,
};
}
@ -59,18 +75,27 @@ abstract class InstanceInfo
class _Undefined {}
class _InstanceInfoImpl extends InstanceInfo {
_InstanceInfoImpl({String? gitHubClientId})
: super._(gitHubClientId: gitHubClientId);
_InstanceInfoImpl({
String? gitHubClientId,
required String sourceCodeUrl,
}) : super._(
gitHubClientId: gitHubClientId,
sourceCodeUrl: sourceCodeUrl,
);
/// Returns a shallow copy of this [InstanceInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
InstanceInfo copyWith({Object? gitHubClientId = _Undefined}) {
InstanceInfo copyWith({
Object? gitHubClientId = _Undefined,
String? sourceCodeUrl,
}) {
return InstanceInfo(
gitHubClientId: gitHubClientId is String?
? gitHubClientId
: this.gitHubClientId,
sourceCodeUrl: sourceCodeUrl ?? this.sourceCodeUrl,
);
}
}

View file

@ -16,13 +16,20 @@ import 'package:serverpod/serverpod.dart' as _is;
/// someone else, with its own configuration.
abstract class InstanceInfo
implements _is.SerializableModel, _is.ProtocolSerialization {
InstanceInfo._({this.gitHubClientId});
InstanceInfo._({
this.gitHubClientId,
required this.sourceCodeUrl,
});
factory InstanceInfo({String? gitHubClientId}) = _InstanceInfoImpl;
factory InstanceInfo({
String? gitHubClientId,
required String sourceCodeUrl,
}) = _InstanceInfoImpl;
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
return InstanceInfo(
gitHubClientId: jsonSerialization['gitHubClientId'] as String?,
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
);
}
@ -30,15 +37,23 @@ abstract class InstanceInfo
/// sign-in is off here.
String? gitHubClientId;
/// Where the source of what this instance runs is; the licences require
/// offering it to the people who use it.
String sourceCodeUrl;
/// Returns a shallow copy of this [InstanceInfo]
/// with some or all fields replaced by the given arguments.
@_is.useResult
InstanceInfo copyWith({String? gitHubClientId});
InstanceInfo copyWith({
String? gitHubClientId,
String? sourceCodeUrl,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'InstanceInfo',
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
'sourceCodeUrl': sourceCodeUrl,
};
}
@ -47,6 +62,7 @@ abstract class InstanceInfo
return {
'__className__': 'InstanceInfo',
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
'sourceCodeUrl': sourceCodeUrl,
};
}
@ -59,18 +75,27 @@ abstract class InstanceInfo
class _Undefined {}
class _InstanceInfoImpl extends InstanceInfo {
_InstanceInfoImpl({String? gitHubClientId})
: super._(gitHubClientId: gitHubClientId);
_InstanceInfoImpl({
String? gitHubClientId,
required String sourceCodeUrl,
}) : super._(
gitHubClientId: gitHubClientId,
sourceCodeUrl: sourceCodeUrl,
);
/// Returns a shallow copy of this [InstanceInfo]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
InstanceInfo copyWith({Object? gitHubClientId = _Undefined}) {
InstanceInfo copyWith({
Object? gitHubClientId = _Undefined,
String? sourceCodeUrl,
}) {
return InstanceInfo(
gitHubClientId: gitHubClientId is String?
? gitHubClientId
: this.gitHubClientId,
sourceCodeUrl: sourceCodeUrl ?? this.sourceCodeUrl,
);
}
}

View file

@ -1,13 +1,16 @@
import 'package:halkyon_learn_server/src/auth/github.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/instance/source_code.dart';
import 'package:serverpod/serverpod.dart';
/// What this instance offers. Public: the app asks before anyone is signed in.
class InstanceEndpoint extends Endpoint {
/// This instance's offer; GitHub sign-in only with the operator's OAuth app.
/// This instance's offer: GitHub sign-in only with the operator's OAuth
/// app, and where its source is.
Future<InstanceInfo> info(Session session) async => InstanceInfo(
gitHubClientId: gitHubSignInAvailable(session.serverpod)
? session.serverpod.getPassword('githubClientId')
: null,
sourceCodeUrl: sourceCodeUrl(),
);
}

View file

@ -5,3 +5,6 @@ fields:
### The client ID of this instance's GitHub OAuth app, or null when GitHub
### sign-in is off here.
gitHubClientId: String?
### Where the source of what this instance runs is; the licences require
### offering it to the people who use it.
sourceCodeUrl: String

View file

@ -0,0 +1,14 @@
import 'dart:io';
/// Where this project's source is published.
const upstreamSourceCodeUrl =
'https://code.halkyon.app/project-halkyon/halkyon_learn';
/// Where the source of what this instance runs is: `SOURCE_URL` if the
/// operator set it, which they must when running modified code, else this
/// project's. Both licences require offering it to the people who use the
/// instance (AGPL-3.0-or-later for the app, SSPL-1.0 for the server).
String sourceCodeUrl() => switch (Platform.environment['SOURCE_URL']) {
final url? when url.isNotEmpty => url,
_ => upstreamSourceCodeUrl,
};

View file

@ -0,0 +1,20 @@
import 'package:halkyon_learn_server/src/instance/source_code.dart';
import 'package:test/test.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('What the instance offers', (sessionBuilder, endpoints) {
test('names this repository as the source when the operator set '
'nothing', () async {
// The tests run without SOURCE_URL.
final info = await endpoints.instance.info(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
);
expect(info.sourceCodeUrl, upstreamSourceCodeUrl);
});
});
}