The server half of the assistants (points 4 to 8, D27 to D46): the operator sets language models in deploy/assistants.env, grants them to people with limits, and the module forwards their questions with the operator's key, counting what each used, never what was asked. - assistantsFrom: ASSISTANTS, ASSISTANT_ID_KEY, ASSISTANT_<ID>_NAME, _PROTOCOL (anthropic, openai, openai-responses), _BASE_URL, _MODEL, _KEY, _IMAGES, _PRICE_INPUT and _PRICE_OUTPUT (per million tokens), ASSISTANT_CURRENCY, ASSISTANT_OWN_KEYS; anything unusable stops the server as it starts, naming the variable. - The assistant endpoint (identified): offered, agree (D40), ask as a Serverpod stream (D35): the text as the model writes it, then the whole turn, sent once the request is counted; figures, the caller's own. Checked in order: at most 2 questions on their way per person (tooManyAtOnce), the grant, the agreement, limits in the pricing's unit now (limitOutdated), the month's and the day's limits (with when they reset, UTC), the question (malformed, imagesNotRead, tooLarge past 400,000 characters, an image over 3,932,160 bytes or more than 20 images); output capped at 16,384 tokens. Each request carries the person's opaque ID (HMAC-SHA256 under ASSISTANT_ID_KEY, D30). Once sent, the provider's reply is read to its end even if the app cancels, and counted on a session of its own; a provider's failure is logged by kind and status, told as providerKey, providerBusy (with its wait), providerRefused or providerFailed. - The assistantOperator endpoint (identified and operator, on the register): setUp, grants, grant (assistantNotSet, invalidLimit, notFound), revoke, figures of a UTC month. - Tables assistant_grant and assistant_figures (per person, assistant and UTC day), in the module's, Learn's and Todo's migrations. The daily pruning keeps this month and the two before (forgetOldAssistantFigures, D41); deletePerson deletes both. A member offers no assistant (D43). - instance.basics() gains assistantOwnKeys. - /privacy (D39, addPrivacyPageRoute, on the register's web server, before the profile pages' route): made from the instance's settings, English or German, no JavaScript: who may sign up, where the data and backups are, what the module keeps (the account, sessions, IP addresses, the server's log), the host's privacyPagePoints (Learn's: studysets synced, private ones encrypted on the devices with what stays readable, what is published, dictionary lookups, the learner count), each assistant with its model and host, what goes and what is counted, and whether personal keys are allowed. Never a key. Tests: 70 on the assistants (grants, limits, refusals in order, streaming, cancelling, counting, concurrency, the operator's denials, deletion, pruning), 19 on the settings, 8 on the privacy page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
162 lines
5 KiB
Dart
162 lines
5 KiB
Dart
import 'package:clock/clock.dart';
|
|
import 'package:halkyon_base_server/base.dart';
|
|
import 'package:halkyon_oidc_server/testing.dart';
|
|
import 'package:serverpod/serverpod.dart';
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
|
|
import '../test_tools/serverpod_test_tools.dart';
|
|
|
|
/// The app the tests' host is.
|
|
const testAppName = 'Halkyon Test';
|
|
|
|
/// The id of the app the tests' host serves.
|
|
const testApp = 'test';
|
|
|
|
/// Where the tests' host says its project publishes its source.
|
|
const testUpstreamSourceCodeUrl = 'https://code.example.org/halkyon_test';
|
|
|
|
/// The oldest app build the tests' host works with.
|
|
const testMinimumAppBuild = 7;
|
|
|
|
/// Sets the module up on the pod [sessionBuilder]'s sessions run on, as a
|
|
/// host does before its pod starts, for [app], with [deletePersonData] as
|
|
/// its deletions, [profilePageNote] at the foot of profile pages and
|
|
/// [privacyPagePoints] on the privacy page, and
|
|
/// authentication exactly as hosts run it
|
|
/// (`auth_config.dart`). Not on `Serverpod.instance`: that is the pod made
|
|
/// last, and a test file with several `withServerpod` groups has a pod each.
|
|
void useBase(
|
|
TestSessionBuilder sessionBuilder, {
|
|
String app = testApp,
|
|
List<PersonDataDeletion> deletePersonData = const [],
|
|
ProfilePageNote? profilePageNote,
|
|
List<PrivacyPagePoint> privacyPagePoints = const [],
|
|
AccountsMode accounts = const HoldsRegister(),
|
|
}) {
|
|
final pod = sessionBuilder.build().serverpod;
|
|
HalkyonBase.configure(
|
|
pod,
|
|
HalkyonBase(
|
|
app: app,
|
|
appName: testAppName,
|
|
upstreamSourceCodeUrl: testUpstreamSourceCodeUrl,
|
|
minimumAppBuild: testMinimumAppBuild,
|
|
deletePersonData: deletePersonData,
|
|
profilePageNote: profilePageNote,
|
|
privacyPagePoints: privacyPagePoints,
|
|
accounts: accounts,
|
|
),
|
|
);
|
|
// No password sign-in unless a test gives it keys, and no SimpleLogin
|
|
// unless a test gives it an app, whatever the test run's passwords and
|
|
// environment hold.
|
|
usePasswordKeys(pod, null);
|
|
useOfferedWays(pod, null);
|
|
useSimpleLoginApp(pod, null);
|
|
AuthServices.set(
|
|
tokenManagerBuilders: tokenManagerBuilders,
|
|
identityProviderBuilders: identityProviderBuilders(pod),
|
|
);
|
|
}
|
|
|
|
/// SimpleLogin on the pod [sessionBuilder]'s sessions run on (ADR 0020), the
|
|
/// instance's app answered by [fake] (none for null), and authentication set
|
|
/// up anew as hosts run it.
|
|
void useSimpleLogin(TestSessionBuilder sessionBuilder, FakeProvider? fake) {
|
|
final pod = sessionBuilder.build().serverpod;
|
|
useSimpleLoginApp(
|
|
pod,
|
|
fake == null
|
|
? null
|
|
: (
|
|
clientId: fake.clientId,
|
|
clientSecret: fake.clientSecret,
|
|
callbackUrl: fake.callbackUrl,
|
|
),
|
|
client: fake?.client,
|
|
);
|
|
AuthServices.set(
|
|
tokenManagerBuilders: tokenManagerBuilders,
|
|
identityProviderBuilders: identityProviderBuilders(pod),
|
|
);
|
|
}
|
|
|
|
/// Signs [person] in on a new device and returns the new session's id.
|
|
Future<UuidValue> signInNewDevice(
|
|
Session session,
|
|
UuidValue person, {
|
|
Set<Scope> scopes = const {},
|
|
String method = 'test',
|
|
}) async {
|
|
Future<Set<UuidValue>> sessionIds() async => {
|
|
for (final serverSideSession in await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(person),
|
|
))
|
|
serverSideSession.id!,
|
|
};
|
|
|
|
final before = await sessionIds();
|
|
await AuthServices.instance.tokenManager.createToken(
|
|
session,
|
|
authUserId: person,
|
|
method: method,
|
|
scopes: scopes,
|
|
);
|
|
return (await sessionIds()).difference(before).single;
|
|
}
|
|
|
|
/// A person as codes by mail left them before passwords (ADR 0017):
|
|
/// identified, with [address] as their only way in, and no password; with
|
|
/// [handle] where given (ADR 0020, D5), their profile private.
|
|
Future<UuidValue> newPersonWithAddress(
|
|
Session session,
|
|
String address, {
|
|
String? handle,
|
|
}) async {
|
|
final person = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
// INSERT INTO "email_identity" ("authUserId", "addressHash", "createdAt")
|
|
// VALUES ($1, $2, $3)
|
|
await EmailIdentity.db.insertRow(
|
|
session,
|
|
EmailIdentity(
|
|
authUserId: person,
|
|
addressHash: addressHash(session.serverpod, address),
|
|
createdAt: clock.now(),
|
|
),
|
|
);
|
|
if (handle != null) {
|
|
// INSERT INTO "public_profile" (…) VALUES (…)
|
|
await PublicProfile.db.insertRow(
|
|
session,
|
|
PublicProfile(
|
|
authUserId: person,
|
|
handle: handle,
|
|
handleKey: handle.toLowerCase(),
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
verifiedLinks: const [],
|
|
updatedAt: clock.now(),
|
|
),
|
|
);
|
|
}
|
|
return person;
|
|
}
|
|
|
|
/// An anonymous account, as apps made them on their first start until
|
|
/// ADR 0020, D25, left from before.
|
|
Future<UuidValue> newAnonymousPerson(
|
|
Session session, {
|
|
DateTime? createdAt,
|
|
}) async {
|
|
final person = (await AuthServices.instance.authUsers.create(session)).id;
|
|
await AnonymousAccount.db.insertRow(
|
|
session,
|
|
AnonymousAccount(authUserId: person, createdAt: createdAt),
|
|
);
|
|
return person;
|
|
}
|