This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
import { fromBase64, generateX25519KeyPair, toBase64, wipe } from '../crypto/index.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { DevicePlatform } from '../supabase/types.js';
import type { SecretStore } from './secure-storage.js';
export interface RegisterDeviceParams {
name: string; // user-facing, e.g. "Dennis Laptop"
platform: DevicePlatform;
publicKey: Uint8Array; // X25519 public key, 32 bytes
}
export interface DeviceRecord {
id: string;
name: string;
platform: DevicePlatform;
publicKey: Uint8Array;
lastSeenAt: string;
}
export async function registerDevice(
client: AppSupabaseClient,
params: RegisterDeviceParams,
): Promise<DeviceRecord> {
const { data: session } = await client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
const { data, error } = await client
.from('devices')
.insert({
user_id: session.user.id,
name: params.name,
platform: params.platform,
public_key: bytesToPgHex(params.publicKey),
})
.select('id, name, platform, public_key, last_seen_at')
.single();
if (error) throw error;
return {
id: data.id,
name: data.name,
platform: data.platform,
publicKey: pgHexToBytes(data.public_key),
lastSeenAt: data.last_seen_at,
};
}
export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceRecord[]> {
const { data: session } = await client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
const { data, error } = await client
.from('devices')
.select('id, name, platform, public_key, last_seen_at')
.eq('user_id', session.user.id)
.order('last_seen_at', { ascending: false });
if (error) throw error;
return data.map((row) => ({
id: row.id,
name: row.name,
platform: row.platform,
publicKey: pgHexToBytes(row.public_key),
lastSeenAt: row.last_seen_at,
}));
}
export async function touchDeviceLastSeen(
client: AppSupabaseClient,
deviceId: string,
): Promise<void> {
const { error } = await client
.from('devices')
.update({ last_seen_at: new Date().toISOString() })
.eq('id', deviceId);
if (error) throw error;
}
// ---------------------------------------------------------------------------
// End-to-end device provisioning flow.
// ---------------------------------------------------------------------------
export interface ProvisionDeviceParams {
client: AppSupabaseClient;
secretStore: SecretStore;
userId: string;
name: string;
platform: DevicePlatform;
}
export interface ProvisionResult {
device: DeviceRecord;
created: boolean;
}
function privateKeySecretName(userId: string, deviceId: string): string {
return `chatapp.priv.${userId}.${deviceId}`;
}
// Creates a brand-new device: generates an X25519 keypair, registers the public
// half with Supabase, stores the private half in the platform secret store.
export async function provisionNewDevice({
client,
secretStore,
userId,
name,
platform,
}: ProvisionDeviceParams): Promise<DeviceRecord> {
const kp = await generateX25519KeyPair();
const device = await registerDevice(client, {
name,
platform,
publicKey: kp.publicKey,
});
try {
await secretStore.setSecret(privateKeySecretName(userId, device.id), kp.privateKey);
} finally {
wipe(kp.privateKey);
}
return device;
}
// Loads the private key for `deviceId` from the secret store. Returns null if
// this install has never stored one.
export async function loadDevicePrivateKey(
secretStore: SecretStore,
userId: string,
deviceId: string,
): Promise<Uint8Array | null> {
return secretStore.getSecret(privateKeySecretName(userId, deviceId));
}
export async function forgetDevicePrivateKey(
secretStore: SecretStore,
userId: string,
deviceId: string,
): Promise<void> {
await secretStore.removeSecret(privateKeySecretName(userId, deviceId));
}
// Lightweight helpers for platforms that want to cache their current device id
// in JSON storage (separate from the secret store, which only holds raw bytes).
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
export function deviceIdStorageKey(userId: string): string {
return `${DEVICE_ID_STORAGE_KEY_PREFIX}.${userId}`;
}
// Intentional re-exports so app layers only need @chat-app/shared/auth.
export type { SecretStore } from './secure-storage.js';
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
+3
View File
@@ -0,0 +1,3 @@
export * from './device.js';
export * from './magic-link.js';
export * from './profile.js';
+108
View File
@@ -0,0 +1,108 @@
import type { SupportedLocale } from '../i18n/types.js';
import type { AppSupabaseClient } from '../supabase/client.js';
export interface SignupParams {
email: string;
// Login handle. Lowercased server-side; must match ^[a-z0-9_]{3,32}$ once lowercased.
username: string;
// Optional human-readable name. Defaults to username server-side if omitted.
displayName?: string;
// Invite code is mandatory (enforced by the handle_new_user trigger).
inviteCode: string;
// Deep link the magic-link email should bounce back to.
// e.g. `chatapp://auth/callback` on mobile, `http://localhost:1420/auth/callback` on desktop dev.
redirectTo: string;
// Optional initial locale for the profile row. Defaults to 'en' server-side.
locale?: SupportedLocale;
}
// Trigger signup. User receives a magic link email. Completion happens in
// `completeSessionFromUrl` below once the callback fires.
export async function signUpWithMagicLink(
client: AppSupabaseClient,
params: SignupParams,
): Promise<void> {
const { error } = await client.auth.signInWithOtp({
email: params.email,
options: {
emailRedirectTo: params.redirectTo,
shouldCreateUser: true,
data: {
invite_code: params.inviteCode,
username: params.username.toLowerCase(),
display_name: params.displayName ?? params.username,
...(params.locale ? { locale: params.locale } : {}),
},
},
});
if (error) throw error;
}
// Trigger magic-link login for an existing user. No invite code needed.
export async function loginWithMagicLink(
client: AppSupabaseClient,
email: string,
redirectTo: string,
): Promise<void> {
const { error } = await client.auth.signInWithOtp({
email,
options: {
emailRedirectTo: redirectTo,
shouldCreateUser: false,
},
});
if (error) throw error;
}
// Parse a callback URL produced by the magic-link email and establish a session.
// Works for both PKCE (`?code=…`) and legacy token-hash (`#access_token=…`).
export async function completeSessionFromUrl(
client: AppSupabaseClient,
url: string,
): Promise<void> {
const u = new URL(url);
// PKCE flow: exchange the code.
const code = u.searchParams.get('code');
if (code) {
const { error } = await client.auth.exchangeCodeForSession(code);
if (error) throw error;
return;
}
// Hash fragment flow.
if (u.hash && u.hash.length > 1) {
const hash = new URLSearchParams(u.hash.slice(1));
const access_token = hash.get('access_token');
const refresh_token = hash.get('refresh_token');
if (access_token && refresh_token) {
const { error } = await client.auth.setSession({ access_token, refresh_token });
if (error) throw error;
return;
}
}
throw new Error('callback URL contained no auth params');
}
export async function signOut(client: AppSupabaseClient): Promise<void> {
const { error } = await client.auth.signOut();
if (error) throw error;
}
// Verify a 6-digit OTP that arrived via magic-link email. Establishes a
// session in the CURRENT webview — no browser redirect involved. Used by the
// desktop/mobile apps where cross-origin redirects don't carry the session
// back to the native webview.
export async function verifyMagicLinkOtp(
client: AppSupabaseClient,
email: string,
token: string,
): Promise<void> {
const { error } = await client.auth.verifyOtp({
email,
token: token.trim(),
type: 'email',
});
if (error) throw error;
}
+131
View File
@@ -0,0 +1,131 @@
import type { SupportedLocale } from '../i18n/types.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { Database, PresenceState } from '../supabase/types.js';
type ProfileUpdate = Database['public']['Tables']['profiles']['Update'];
export interface Profile {
userId: string;
username: string;
displayName: string;
avatarUrl: string | null;
statusMessage: string | null;
presenceState: PresenceState;
showReadReceipts: boolean;
allowDmsFromStrangers: boolean;
isAdmin: boolean;
locale: SupportedLocale;
}
type ProfileRow = {
user_id: string;
username: string;
display_name: string;
avatar_url: string | null;
status_message: string | null;
presence_state: PresenceState;
show_read_receipts: boolean;
allow_dms_from_strangers: boolean;
is_admin: boolean;
locale: string;
};
const PROFILE_COLS =
'user_id, username, display_name, avatar_url, status_message, presence_state, show_read_receipts, allow_dms_from_strangers, is_admin, locale';
function toSupportedLocale(raw: string): SupportedLocale {
return raw === 'de' ? 'de' : 'en';
}
function mapProfile(row: ProfileRow): Profile {
return {
userId: row.user_id,
username: row.username,
displayName: row.display_name,
avatarUrl: row.avatar_url,
statusMessage: row.status_message,
presenceState: row.presence_state,
showReadReceipts: row.show_read_receipts,
allowDmsFromStrangers: row.allow_dms_from_strangers,
isAdmin: row.is_admin,
locale: toSupportedLocale(row.locale),
};
}
export async function getOwnProfile(client: AppSupabaseClient): Promise<Profile | null> {
const { data: session } = await client.auth.getUser();
if (!session.user) return null;
const { data, error } = await client
.from('profiles')
.select(PROFILE_COLS)
.eq('user_id', session.user.id)
.maybeSingle();
if (error) throw error;
return data ? mapProfile(data as unknown as ProfileRow) : null;
}
export async function getProfileByUsername(
client: AppSupabaseClient,
username: string,
): Promise<Profile | null> {
const { data, error } = await client
.from('profiles')
.select(PROFILE_COLS)
.eq('username', username.toLowerCase())
.maybeSingle();
if (error) throw error;
return data ? mapProfile(data as unknown as ProfileRow) : null;
}
export async function isUsernameAvailable(
client: AppSupabaseClient,
username: string,
): Promise<boolean> {
const { count, error } = await client
.from('profiles')
.select('user_id', { count: 'exact', head: true })
.eq('username', username.toLowerCase());
if (error) throw error;
return (count ?? 0) === 0;
}
export interface UpdateProfileParams {
displayName?: string;
avatarUrl?: string | null;
statusMessage?: string | null;
presenceState?: PresenceState;
showReadReceipts?: boolean;
allowDmsFromStrangers?: boolean;
locale?: SupportedLocale;
}
export async function updateOwnProfile(
client: AppSupabaseClient,
params: UpdateProfileParams,
): Promise<Profile> {
const { data: session } = await client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
const patch: ProfileUpdate = {};
if (params.displayName !== undefined) patch.display_name = params.displayName;
if (params.avatarUrl !== undefined) patch.avatar_url = params.avatarUrl;
if (params.statusMessage !== undefined) patch.status_message = params.statusMessage;
if (params.presenceState !== undefined) patch.presence_state = params.presenceState;
if (params.showReadReceipts !== undefined) patch.show_read_receipts = params.showReadReceipts;
if (params.allowDmsFromStrangers !== undefined) {
patch.allow_dms_from_strangers = params.allowDmsFromStrangers;
}
if (params.locale !== undefined) {
(patch as ProfileUpdate & { locale?: string }).locale = params.locale;
}
const { data, error } = await client
.from('profiles')
.update(patch)
.eq('user_id', session.user.id)
.select(PROFILE_COLS)
.single();
if (error) throw error;
return mapProfile(data as unknown as ProfileRow);
}
@@ -0,0 +1,13 @@
// Platform-provided secret store.
//
// Mobile: expo-secure-store (Keychain / Keystore).
// Desktop M1: localStorage-backed (dev only, clearly flagged insecure).
// Desktop prod: tauri-plugin-stronghold.
//
// All implementations encode Uint8Array values as base64 under the hood.
export interface SecretStore {
getSecret(key: string): Promise<Uint8Array | null>;
setSecret(key: string, value: Uint8Array): Promise<void>;
removeSecret(key: string): Promise<void>;
}