From 1eb9e0a69f52464797c60be496cf8816125d461b Mon Sep 17 00:00:00 2001
From: Andrew McCallum
Date: Fri, 30 Oct 2026 14:18:04 +0000
Subject: [PATCH] Connect intervals.icu by signing in (OAuth), keeping API keys
Servers that register an intervals.icu OAuth app and set
INTERVALS_CLIENT_ID and INTERVALS_CLIENT_SECRET offer a "Connect with
intervals.icu" button that asks only to read activities. Pasting an API
key still works everywhere, and is all there is on servers without an
app. Existing connections keep working (auth_method defaults to api_key).
The sign-in uses PKCE and a signed, short-lived cookie for the state.
Disconnecting revokes the token. Settings refreshes while a sync runs, so
the first sync after the redirect shows up, and the PWA no longer caches
navigations under /api.
Co-Authored-By: Claude Opus 5.5
---
.env.example | 8 +
README.md | 6 +-
ROADMAP.md | 3 +-
apps/api/src/config.ts | 17 +-
apps/api/src/integrations.spec.ts | 249 +-
apps/api/src/integrations/intervals.ts | 119 +-
apps/api/src/integrations/sync.ts | 19 +-
apps/api/src/routes/integrations.ts | 261 +-
apps/web-e2e/fake-intervals.mjs | 38 +-
apps/web-e2e/playwright.config.mts | 2 +
apps/web-e2e/src/smoke.spec.ts | 34 +-
.../web/src/components/intervals-settings.tsx | 247 +-
apps/web/src/lib/integrations.ts | 3 +
apps/web/src/pages/privacy.tsx | 7 +-
apps/web/vite.config.mts | 6 +-
docs/ARCHITECTURE.md | 12 +-
docs/PRIVACY.md | 5 +-
.../migrations/0008_intervals_oauth.sql | 1 +
.../migrations/meta/0008_snapshot.json | 3140 +++++++++++++++++
.../migrations/meta/_journal.json | 7 +
libs/db/src/schema/integrations.ts | 10 +-
libs/schemas/src/integrations.ts | 22 +
22 files changed, 4069 insertions(+), 147 deletions(-)
create mode 100644 libs/db-migrations/migrations/0008_intervals_oauth.sql
create mode 100644 libs/db-migrations/migrations/meta/0008_snapshot.json
diff --git a/.env.example b/.env.example
index 35a8165..18b8377 100644
--- a/.env.example
+++ b/.env.example
@@ -38,6 +38,14 @@ TRUST_PROXY=false
# from it. Leave empty to turn watch sync off.
# INTERVALS_URL=https://intervals.icu
+# Optional: an intervals.icu OAuth app, so members connect with a button
+# instead of pasting an API key (pasting a key keeps working). Apply at
+# https://intervals.icu/oauth/apply with this redirect URI:
+# /api/integrations/intervals/oauth/callback
+# and wait for intervals.icu to approve it. Use the client id and secret here.
+# INTERVALS_CLIENT_ID=
+# INTERVALS_CLIENT_SECRET=
+
# Web Push keys for notifications. If unset, a pair is generated on first
# start and kept in DATA_DIR/vapid.json. Changing them means everyone has to
# turn notifications on again. Generate with: npx web-push generate-vapid-keys
diff --git a/README.md b/README.md
index 13f36ba..c891de9 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,10 @@ Open the URL. The first account you create becomes the admin. Everything is stor
Passkeys need HTTPS (or `localhost`), so put openRun behind a reverse proxy such as Caddy or Traefik for anything beyond local use. When you do, set `TRUST_PROXY=true`. All settings are listed in [.env.example](.env.example).
+### Watch sync
+
+Members sync their watches through [intervals.icu](https://intervals.icu), which works out of the box: each member pastes their intervals.icu API key in Settings. To give them a "Connect with intervals.icu" button instead, [apply for an intervals.icu app](https://intervals.icu/oauth/apply) with the redirect URI `https:///api/integrations/intervals/oauth/callback`. Once intervals.icu approves it, set `INTERVALS_CLIENT_ID` and `INTERVALS_CLIENT_SECRET` in `.env` and restart. Members who pasted a key keep syncing; pasting a key still works for anyone who prefers it.
+
### Upgrading
Back up first, then:
@@ -48,7 +52,7 @@ Database changes are applied when openRun starts; `docker compose logs openrun`
### Backups
-Everything lives in two places: the `./data` folder and your `.env`. Keep `.env` safe as well, because `BETTER_AUTH_SECRET` signs everyone's sessions and encrypts their intervals.icu keys. A database restored without it works, but everyone has to sign in and paste their intervals.icu key again.
+Everything lives in two places: the `./data` folder and your `.env`. Keep `.env` safe as well, because `BETTER_AUTH_SECRET` signs everyone's sessions and encrypts their intervals.icu keys and sign-ins. A database restored without it works, but everyone has to sign in and connect intervals.icu again.
`./data` holds:
diff --git a/ROADMAP.md b/ROADMAP.md
index 085ae5b..05f37f3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -67,4 +67,5 @@ Through intervals.icu; see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) → Inte
- Capacitor apps for iOS and Android
- Postgres option, OIDC sign-in
- Phone app reading Apple Health and Health Connect (the best route for Apple Watch and Android watches)
-- intervals.icu OAuth and webhooks instead of API keys and polling; ICS feed for club runs
+- ✅ Connecting intervals.icu by signing in (OAuth), on servers with an intervals.icu app; pasting an API key still works
+- intervals.icu webhooks instead of polling, for servers with an app; ICS feed for club runs
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index 3e3b497..662c1a3 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -37,6 +37,13 @@ const envSchema = z.object({
WEATHER_URL: z.string().default('https://api.open-meteo.com/v1/forecast'),
/** intervals.icu, for syncing runs from watches. Empty turns it off. */
INTERVALS_URL: z.string().default('https://intervals.icu'),
+ /**
+ * An intervals.icu OAuth app, so members connect with a button instead of
+ * pasting an API key. Register one at https://intervals.icu/oauth/apply
+ * with ORIGIN/api/integrations/intervals/oauth/callback as the redirect URI.
+ */
+ INTERVALS_CLIENT_ID: z.string().optional(),
+ INTERVALS_CLIENT_SECRET: z.string().optional(),
/** Web Push keys. Generated and kept in DATA_DIR if not set. */
VAPID_PUBLIC_KEY: z.string().optional(),
VAPID_PRIVATE_KEY: z.string().optional(),
@@ -55,6 +62,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
);
}
const dataDir = resolve(e.DATA_DIR);
+ const intervalsUrl = e.INTERVALS_URL.trim().replace(/\/+$/, '') || null;
+ const intervalsClientId = e.INTERVALS_CLIENT_ID?.trim();
+ const intervalsClientSecret = e.INTERVALS_CLIENT_SECRET?.trim();
return {
production,
port: e.PORT,
@@ -82,7 +92,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
trustProxy: e.TRUST_PROXY,
placeSearchUrl: e.PLACE_SEARCH_URL.trim() || null,
weatherUrl: e.WEATHER_URL.trim() || null,
- intervalsUrl: e.INTERVALS_URL.trim().replace(/\/+$/, '') || null,
+ intervalsUrl,
+ /** Null when members have to paste an API key instead. */
+ intervalsOAuth:
+ intervalsUrl && intervalsClientId && intervalsClientSecret
+ ? { clientId: intervalsClientId, clientSecret: intervalsClientSecret }
+ : null,
vapidKeys:
e.VAPID_PUBLIC_KEY && e.VAPID_PRIVATE_KEY
? { publicKey: e.VAPID_PUBLIC_KEY, privateKey: e.VAPID_PRIVATE_KEY }
diff --git a/apps/api/src/integrations.spec.ts b/apps/api/src/integrations.spec.ts
index 8baeb16..79040bc 100644
--- a/apps/api/src/integrations.spec.ts
+++ b/apps/api/src/integrations.spec.ts
@@ -1,3 +1,4 @@
+import { createHash } from 'node:crypto';
import { gzipSync } from 'node:zlib';
import { eq } from 'drizzle-orm';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -16,6 +17,7 @@ afterEach(() => {
});
const KEY = 'good-api-key-123';
+const TOKEN = 'good-oauth-token-456';
const DAY = 86_400_000;
type FakeActivity = {
@@ -30,19 +32,53 @@ type FakeActivity = {
/**
* A pretend intervals.icu: one athlete, whose activities each have a FIT
- * file starting at `start_date`. Records every request.
+ * file starting at `start_date`. Takes the API key or, after an OAuth
+ * sign-in, the access token. Records every request.
*/
function fakeIntervals(activities: FakeActivity[], athleteId = 'i42') {
const requests: URL[] = [];
- const state = { status: 200 };
+ const state = {
+ status: 200,
+ /** The PKCE challenge the code was issued for. */
+ codeChallenge: '',
+ tokenRequests: [] as URLSearchParams[],
+ revoked: [] as string[],
+ };
const fetch = vi.fn(async (input: string | URL, init?: RequestInit) => {
const url = new URL(input);
requests.push(url);
+ const path = url.pathname;
+ if (path === '/api/oauth/token') {
+ const form = new URLSearchParams(String(init?.body));
+ state.tokenRequests.push(form);
+ const challenge = createHash('sha256')
+ .update(form.get('code_verifier') ?? '')
+ .digest('base64url');
+ if (
+ form.get('client_id') !== 'openrun-test' ||
+ form.get('client_secret') !== 'shh' ||
+ form.get('code') !== 'good-code' ||
+ challenge !== state.codeChallenge
+ ) {
+ return new Response('Bad request', { status: 400 });
+ }
+ return Response.json({
+ token_type: 'Bearer',
+ access_token: TOKEN,
+ scope: 'ACTIVITY:READ',
+ athlete: { id: athleteId, name: 'Ann Runner' },
+ });
+ }
const auth = new Headers(init?.headers).get('authorization');
- const expected = `Basic ${Buffer.from(`API_KEY:${KEY}`).toString('base64')}`;
- if (auth !== expected) return new Response('Unauthorized', { status: 401 });
+ const key = `Basic ${Buffer.from(`API_KEY:${KEY}`).toString('base64')}`;
+ if (auth !== key && auth !== `Bearer ${TOKEN}`) {
+ return new Response('Unauthorized', { status: 401 });
+ }
if (state.status !== 200) return new Response('', { status: state.status });
- const path = url.pathname;
+ if (path === '/api/v1/disconnect-app' && init?.method === 'DELETE') {
+ state.revoked.push(auth);
+ return new Response(null, { status: 200 });
+ }
if (path === '/api/v1/athlete/0') {
return Response.json({ id: athleteId, name: 'Ann Runner' });
}
@@ -313,6 +349,7 @@ describe('intervals.icu sync', () => {
const res = await get(t.app, '/api/integrations', cookie);
expect(integrationsSchema.parse(await res.json()).intervals).toEqual({
available: false,
+ oauth: false,
connection: null,
});
const put = await send(
@@ -363,3 +400,205 @@ describe('intervals.icu sync', () => {
expect(share?.device).toBe('Garmin Forerunner 965');
});
});
+
+describe('intervals.icu sign-in (OAuth)', () => {
+ const OAUTH_ENV = {
+ INTERVALS_CLIENT_ID: 'openrun-test',
+ INTERVALS_CLIENT_SECRET: 'shh',
+ };
+
+ /**
+ * Starts the sign-in like the browser would and returns where intervals.icu
+ * would send the member back, plus the cookie that remembers it.
+ */
+ async function startSignIn(
+ t: ReturnType,
+ cookie: string,
+ fake: ReturnType,
+ query = '?syncFrom=2026-03-01',
+ ) {
+ const res = await get(
+ t.app,
+ `/api/integrations/intervals/oauth/start${query}`,
+ cookie,
+ );
+ expect(res.status).toBe(302);
+ const to = new URL(res.headers.get('location') ?? '');
+ fake.state.codeChallenge = to.searchParams.get('code_challenge') ?? '';
+ const pending =
+ res.headers.get('set-cookie')?.split(';')[0] ?? 'missing-cookie';
+ return { to, state: to.searchParams.get('state') ?? '', pending };
+ }
+
+ async function comeBack(
+ t: ReturnType,
+ cookie: string,
+ pending: string,
+ query: string,
+ ) {
+ const res = await get(
+ t.app,
+ `/api/integrations/intervals/oauth/callback?${query}`,
+ `${cookie}; ${pending}`,
+ );
+ expect(res.status).toBe(302);
+ return res.headers.get('location');
+ }
+
+ it('is offered only when the server has an OAuth app', async () => {
+ const plain = setup();
+ const { cookie } = await signUp(plain.app, 'ann@example.com');
+ const res = await get(plain.app, '/api/integrations', cookie);
+ expect(integrationsSchema.parse(await res.json()).intervals.oauth).toBe(
+ false,
+ );
+ const start = await get(
+ plain.app,
+ '/api/integrations/intervals/oauth/start',
+ cookie,
+ );
+ expect(start.status).toBe(404);
+
+ const t = setup(OAUTH_ENV);
+ const { cookie: c2 } = await signUp(t.app, 'ann@example.com');
+ const res2 = await get(t.app, '/api/integrations', c2);
+ expect(integrationsSchema.parse(await res2.json()).intervals.oauth).toBe(
+ true,
+ );
+ });
+
+ it('connects with a token and syncs with it', async () => {
+ const t = setup(OAUTH_ENV);
+ const { cookie } = await signUp(t.app, 'ann@example.com');
+ const fake = fakeIntervals([
+ { id: 'i1', start_date: daysAgo(1), type: 'Run' },
+ ]);
+ const { to, state, pending } = await startSignIn(t, cookie, fake);
+ expect(`${to.origin}${to.pathname}`).toBe(
+ 'https://intervals.icu/oauth/authorize',
+ );
+ expect(Object.fromEntries(to.searchParams)).toMatchObject({
+ client_id: 'openrun-test',
+ redirect_uri:
+ 'http://localhost:3000/api/integrations/intervals/oauth/callback',
+ scope: 'ACTIVITY:READ',
+ code_challenge_method: 'S256',
+ });
+ expect(pending).toMatch(/^openrun_intervals_oauth=/);
+
+ const back = await comeBack(
+ t,
+ cookie,
+ pending,
+ `code=good-code&state=${state}`,
+ );
+ expect(back).toBe('/settings?intervals=connected');
+
+ // The first sync runs in the background after the redirect.
+ await vi.waitFor(async () => {
+ const res = await get(t.app, '/api/integrations', cookie);
+ const { connection } = integrationsSchema.parse(
+ await res.json(),
+ ).intervals;
+ expect(connection).toMatchObject({
+ accountId: 'i42',
+ authMethod: 'oauth',
+ syncFrom: '2026-03-01',
+ syncing: false,
+ importedCount: 1,
+ });
+ });
+ const row = t.db.select().from(schema.integration).get();
+ expect(row?.credentials).not.toContain(TOKEN);
+
+ // Disconnecting revokes the token.
+ await send(t.app, cookie, 'DELETE', '/api/integrations/intervals');
+ expect(fake.state.revoked).toEqual([`Bearer ${TOKEN}`]);
+ });
+
+ it('sends the member back with a reason when it goes wrong', async () => {
+ const t = setup(OAUTH_ENV);
+ const { cookie } = await signUp(t.app, 'ann@example.com');
+ const fake = fakeIntervals([]);
+
+ // They said no on intervals.icu.
+ let s = await startSignIn(t, cookie, fake);
+ expect(
+ await comeBack(
+ t,
+ cookie,
+ s.pending,
+ `error=access_denied&state=${s.state}`,
+ ),
+ ).toBe('/settings?intervals=denied');
+
+ // A forged or stale state, or no cookie at all.
+ s = await startSignIn(t, cookie, fake);
+ expect(
+ await comeBack(t, cookie, s.pending, 'code=good-code&state=someone-else'),
+ ).toBe('/settings?intervals=expired');
+ expect(
+ await comeBack(t, cookie, '', `code=good-code&state=${s.state}`),
+ ).toBe('/settings?intervals=expired');
+
+ // Someone else's sign-in can't be finished in this member's browser.
+ s = await startSignIn(t, cookie, fake);
+ const { cookie: bob } = await signUp(t.app, 'bob@example.com');
+ expect(
+ await comeBack(t, bob, s.pending, `code=good-code&state=${s.state}`),
+ ).toBe('/settings?intervals=expired');
+
+ // intervals.icu won't swap the code.
+ s = await startSignIn(t, cookie, fake);
+ expect(
+ await comeBack(t, cookie, s.pending, `code=bad-code&state=${s.state}`),
+ ).toBe('/settings?intervals=failed');
+
+ expect(t.db.select().from(schema.integration).all()).toEqual([]);
+ });
+
+ it('keeps one account per person, and lets go of the spare token', async () => {
+ const t = setup(OAUTH_ENV);
+ const { cookie: ann } = await signUp(t.app, 'ann@example.com');
+ const fake = fakeIntervals([]);
+ await send(t.app, ann, 'PUT', '/api/integrations/intervals', {
+ apiKey: KEY,
+ syncFrom: '2026-01-01',
+ });
+
+ const { cookie: bob } = await signUp(t.app, 'bob@example.com');
+ const s = await startSignIn(t, bob, fake);
+ expect(
+ await comeBack(t, bob, s.pending, `code=good-code&state=${s.state}`),
+ ).toBe('/settings?intervals=taken');
+ expect(fake.state.revoked).toEqual([`Bearer ${TOKEN}`]);
+ });
+
+ it('switches a key connection to OAuth, keeping its start date and runs', async () => {
+ const t = setup(OAUTH_ENV);
+ const { cookie } = await signUp(t.app, 'ann@example.com');
+ const fake = fakeIntervals([
+ { id: 'i1', start_date: daysAgo(1), type: 'Run' },
+ ]);
+ await send(t.app, cookie, 'PUT', '/api/integrations/intervals', {
+ apiKey: KEY,
+ syncFrom: '2026-01-01',
+ });
+ await send(t.app, cookie, 'POST', '/api/integrations/intervals/sync');
+
+ // Reconnecting from Settings sends no start date.
+ const s = await startSignIn(t, cookie, fake, '');
+ await comeBack(t, cookie, s.pending, `code=good-code&state=${s.state}`);
+ await vi.waitFor(async () => {
+ const res = await get(t.app, '/api/integrations', cookie);
+ expect(
+ integrationsSchema.parse(await res.json()).intervals.connection,
+ ).toMatchObject({
+ authMethod: 'oauth',
+ syncFrom: '2026-01-01',
+ importedCount: 1,
+ syncing: false,
+ });
+ });
+ });
+});
diff --git a/apps/api/src/integrations/intervals.ts b/apps/api/src/integrations/intervals.ts
index 3fa1340..f36ee7e 100644
--- a/apps/api/src/integrations/intervals.ts
+++ b/apps/api/src/integrations/intervals.ts
@@ -1,7 +1,12 @@
import { z } from 'zod';
// The bits of the intervals.icu API we use, authenticated with the member's
-// personal API key. https://forum.intervals.icu/t/api-access-to-intervals-icu/609
+// personal API key or an OAuth access token.
+// https://forum.intervals.icu/t/api-access-to-intervals-icu/609
+// https://forum.intervals.icu/t/intervals-icu-oauth-support/2759
+
+/** How openRun signs in for a member: their API key, or an OAuth token. */
+export type IntervalsAuth = { method: 'api_key' | 'oauth'; secret: string };
export class IntervalsError extends Error {
constructor(
@@ -11,7 +16,7 @@ export class IntervalsError extends Error {
super(message);
}
- /** The key was refused: revoked, regenerated or mistyped. */
+ /** The key or token was refused: revoked, regenerated or mistyped. */
get badKey() {
return this.status === 401 || this.status === 403;
}
@@ -36,13 +41,27 @@ export type IntervalsActivity = z.infer;
const TIMEOUT_MS = 30_000;
-async function call(baseUrl: string, apiKey: string, path: string) {
+const USER_AGENT = 'openRun (self-hosted running log)';
+
+function authorization({ method, secret }: IntervalsAuth) {
+ return method === 'oauth'
+ ? `Bearer ${secret}`
+ : `Basic ${Buffer.from(`API_KEY:${secret}`).toString('base64')}`;
+}
+
+async function call(
+ baseUrl: string,
+ auth: IntervalsAuth,
+ path: string,
+ init: RequestInit = {},
+) {
let res: Response;
try {
res = await fetch(`${baseUrl}/api/v1${path}`, {
+ ...init,
headers: {
- authorization: `Basic ${Buffer.from(`API_KEY:${apiKey}`).toString('base64')}`,
- 'user-agent': 'openRun (self-hosted running log)',
+ authorization: authorization(auth),
+ 'user-agent': USER_AGENT,
},
signal: AbortSignal.timeout(TIMEOUT_MS),
});
@@ -52,7 +71,9 @@ async function call(baseUrl: string, apiKey: string, path: string) {
if (res.ok) return res;
if (res.status === 401 || res.status === 403) {
throw new IntervalsError(
- "intervals.icu didn't accept the API key",
+ auth.method === 'oauth'
+ ? 'intervals.icu no longer lets openRun read your runs'
+ : "intervals.icu didn't accept the API key",
res.status,
);
}
@@ -63,8 +84,8 @@ async function call(baseUrl: string, apiKey: string, path: string) {
}
/** The athlete the key belongs to. */
-export async function getAthlete(baseUrl: string, apiKey: string) {
- const res = await call(baseUrl, apiKey, '/athlete/0');
+export async function getAthlete(baseUrl: string, auth: IntervalsAuth) {
+ const res = await call(baseUrl, auth, '/athlete/0');
return athleteSchema.parse(await res.json());
}
@@ -77,13 +98,13 @@ const ACTIVITY_FIELDS = Object.keys(activitySchema.shape).join(',');
/** Activities that started between two days, inclusive. */
export async function listActivities(
baseUrl: string,
- apiKey: string,
+ auth: IntervalsAuth,
oldest: string,
newest: string,
): Promise {
const res = await call(
baseUrl,
- apiKey,
+ auth,
`/athlete/0/activities?oldest=${oldest}&newest=${newest}&fields=${ACTIVITY_FIELDS}`,
);
return z.array(activitySchema).parse(await res.json());
@@ -95,18 +116,92 @@ export async function listActivities(
*/
export async function downloadActivity(
baseUrl: string,
- apiKey: string,
+ auth: IntervalsAuth,
activity: IntervalsActivity,
): Promise {
const original = ['fit', 'gpx'].includes(activity.file_type ?? '');
const res = await call(
baseUrl,
- apiKey,
+ auth,
`/activity/${encodeURIComponent(activity.id)}/${original ? 'file' : 'fit-file'}`,
);
return new Uint8Array(await res.arrayBuffer());
}
+export type IntervalsOAuthApp = { clientId: string; clientSecret: string };
+
+/** Reading activities is all openRun needs. */
+const OAUTH_SCOPE = 'ACTIVITY:READ';
+
+/** Where to send the member to let openRun read their activities. */
+export function authorizeUrl(
+ baseUrl: string,
+ app: IntervalsOAuthApp,
+ params: { redirectUri: string; state: string; codeChallenge: string },
+) {
+ const url = new URL(`${baseUrl}/oauth/authorize`);
+ url.search = new URLSearchParams({
+ client_id: app.clientId,
+ redirect_uri: params.redirectUri,
+ scope: OAUTH_SCOPE,
+ state: params.state,
+ code_challenge: params.codeChallenge,
+ code_challenge_method: 'S256',
+ }).toString();
+ return url.toString();
+}
+
+const tokenSchema = z.object({
+ access_token: z.string().min(1),
+ athlete: athleteSchema,
+});
+
+/**
+ * Swaps the code from the redirect for an access token. The code only lasts
+ * two minutes. Tokens don't expire; there's no refresh token.
+ */
+export async function exchangeCode(
+ baseUrl: string,
+ app: IntervalsOAuthApp,
+ code: string,
+ codeVerifier: string,
+) {
+ let res: Response;
+ try {
+ res = await fetch(`${baseUrl}/api/oauth/token`, {
+ method: 'POST',
+ headers: { 'user-agent': USER_AGENT },
+ body: new URLSearchParams({
+ client_id: app.clientId,
+ client_secret: app.clientSecret,
+ code,
+ code_verifier: codeVerifier,
+ }),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ } catch {
+ throw new IntervalsError("Couldn't reach intervals.icu", 0);
+ }
+ if (!res.ok) {
+ throw new IntervalsError(
+ `intervals.icu refused the sign-in (${res.status})`,
+ res.status,
+ );
+ }
+ const body = tokenSchema.parse(await res.json());
+ return { accessToken: body.access_token, athlete: body.athlete };
+}
+
+/** Revokes an OAuth token, so intervals.icu stops listing openRun. */
+export async function disconnectApp(baseUrl: string, accessToken: string) {
+ await call(
+ baseUrl,
+ { method: 'oauth', secret: accessToken },
+ '/disconnect-app',
+ { method: 'DELETE' },
+ );
+}
+
/** Running activity types; everything else is left on intervals.icu. */
export const RUN_TYPES = new Set(['Run', 'TrailRun', 'VirtualRun']);
diff --git a/apps/api/src/integrations/sync.ts b/apps/api/src/integrations/sync.ts
index d75f9ef..5d00ea9 100644
--- a/apps/api/src/integrations/sync.ts
+++ b/apps/api/src/integrations/sync.ts
@@ -16,6 +16,7 @@ import { getPrivacySettings } from '../privacy.js';
import { decryptSecret } from '../secrets.js';
import {
type IntervalsActivity,
+ type IntervalsAuth,
IntervalsError,
RUN_TYPES,
deviceOf,
@@ -69,16 +70,19 @@ export async function syncIntegration(
let error: string | null = null;
let status: Integration['status'] = 'ok';
try {
- const apiKey = decryptSecret(config.authSecret, integration.credentials);
- if (!apiKey) {
+ const secret = decryptSecret(config.authSecret, integration.credentials);
+ if (!secret) {
throw new IntervalsError(
- "The saved API key can't be read any more; paste it again",
+ integration.authMethod === 'oauth'
+ ? "The saved sign-in can't be read any more; connect again"
+ : "The saved API key can't be read any more; paste it again",
401,
);
}
+ const auth: IntervalsAuth = { method: integration.authMethod, secret };
const listed = await listActivities(
config.intervalsUrl,
- apiKey,
+ auth,
day(new Date(integration.syncFrom.getTime() - DAY_MS)),
day(new Date(now.getTime() + DAY_MS)),
);
@@ -117,7 +121,7 @@ export async function syncIntegration(
db,
config,
integration,
- { baseUrl: config.intervalsUrl, apiKey },
+ { baseUrl: config.intervalsUrl, auth },
item,
);
counts[outcome]++;
@@ -176,7 +180,7 @@ async function importOne(
db: Db,
config: Config,
integration: Integration,
- { baseUrl, apiKey }: { baseUrl: string; apiKey: string },
+ { baseUrl, auth }: { baseUrl: string; auth: IntervalsAuth },
{ activity, startedAt }: { activity: IntervalsActivity; startedAt: Date },
): Promise<'imported' | 'duplicates' | 'failed'> {
const remember = (
@@ -197,7 +201,7 @@ async function importOne(
.run();
// Network trouble and rate limits end the sync; the run is tried next time.
- const data = await downloadActivity(baseUrl, apiKey, activity);
+ const data = await downloadActivity(baseUrl, auth, activity);
let parsed;
try {
parsed = await parseActivityFile(data);
@@ -312,6 +316,7 @@ export function describeConnection(
return {
accountId: integration.accountId,
accountName: integration.accountName,
+ authMethod: integration.authMethod,
syncFrom: day(integration.syncFrom),
status: integration.status,
lastError: integration.lastError,
diff --git a/apps/api/src/routes/integrations.ts b/apps/api/src/routes/integrations.ts
index fc7a2e3..0f40402 100644
--- a/apps/api/src/routes/integrations.ts
+++ b/apps/api/src/routes/integrations.ts
@@ -1,20 +1,32 @@
+import { createHash, randomBytes } from 'node:crypto';
import { zValidator } from '@hono/zod-validator';
import { and, eq } from 'drizzle-orm';
import { type Context, Hono } from 'hono';
+import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
+import { z } from 'zod';
import { schema } from '@openrun/db';
import {
type Integrations,
+ type IntervalsOAuthResult,
connectIntervalsSchema,
+ startIntervalsOAuthSchema,
updateIntervalsSchema,
} from '@openrun/schemas';
import { type AuthedEnv, requireUser } from '../context.js';
-import { IntervalsError, getAthlete } from '../integrations/intervals.js';
+import {
+ type IntervalsAuth,
+ IntervalsError,
+ authorizeUrl,
+ disconnectApp,
+ exchangeCode,
+ getAthlete,
+} from '../integrations/intervals.js';
import {
describeConnection,
isSyncing,
syncIntegration,
} from '../integrations/sync.js';
-import { encryptSecret } from '../secrets.js';
+import { decryptSecret, encryptSecret } from '../secrets.js';
type Ctx = Context;
@@ -37,6 +49,7 @@ function overview(c: Ctx): Integrations {
return {
intervals: {
available: c.get('config').intervalsUrl !== null,
+ oauth: c.get('config').intervalsOAuth !== null,
connection: row ? describeConnection(c.get('db'), row) : null,
},
};
@@ -44,6 +57,83 @@ function overview(c: Ctx): Integrations {
const startOfDay = (day: string) => new Date(`${day}T00:00:00Z`);
+/**
+ * Saves the member's connection, replacing any earlier one. A different
+ * intervals.icu account starts afresh. Returns false if that account is
+ * already connected to someone else here.
+ */
+function saveConnection(
+ c: Ctx,
+ athlete: { id: string; name?: string | null },
+ auth: IntervalsAuth,
+ syncFrom: Date | null,
+) {
+ const db = c.get('db');
+ const userId = c.get('user').id;
+ const taken = db
+ .select({ userId: schema.integration.userId })
+ .from(schema.integration)
+ .where(
+ and(
+ eq(schema.integration.provider, 'intervals'),
+ eq(schema.integration.accountId, athlete.id),
+ ),
+ )
+ .get();
+ if (taken && taken.userId !== userId) return false;
+
+ const existing = findIntervals(c);
+ const values = {
+ accountId: athlete.id,
+ accountName: athlete.name ?? null,
+ credentials: encryptSecret(c.get('config').authSecret, auth.secret),
+ authMethod: auth.method,
+ syncFrom:
+ syncFrom ??
+ existing?.syncFrom ??
+ new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)),
+ status: 'ok' as const,
+ lastError: null,
+ };
+ if (existing && existing.accountId === athlete.id) {
+ db.update(schema.integration)
+ .set(values)
+ .where(eq(schema.integration.id, existing.id))
+ .run();
+ } else {
+ db.transaction((tx) => {
+ if (existing) {
+ tx.delete(schema.integration)
+ .where(eq(schema.integration.id, existing.id))
+ .run();
+ }
+ tx.insert(schema.integration)
+ .values({ ...values, userId, provider: 'intervals' })
+ .run();
+ });
+ }
+ return true;
+}
+
+const TAKEN =
+ 'That intervals.icu account is already connected to someone else on this server.';
+
+/** Remembers an OAuth sign-in in progress between leaving and coming back. */
+const OAUTH_COOKIE = 'openrun_intervals_oauth';
+const OAUTH_PATH = '/api/integrations/intervals/oauth';
+const oauthStateSchema = z.object({
+ state: z.string(),
+ verifier: z.string(),
+ userId: z.string(),
+ syncFrom: z.iso.date().nullable(),
+});
+
+const base64url = (b: Buffer) => b.toString('base64url');
+const redirectUri = (c: Ctx) =>
+ `${c.get('config').origin}${OAUTH_PATH}/callback`;
+const backToSettings = (c: Ctx, result: IntervalsOAuthResult) =>
+ c.redirect(`/settings?intervals=${result}`);
+
export const integrationRoutes = new Hono()
.use(requireUser)
.get('/', (c) => c.json(overview(c)))
@@ -57,7 +147,10 @@ export const integrationRoutes = new Hono()
const { apiKey, syncFrom } = c.req.valid('json');
let athlete;
try {
- athlete = await getAthlete(config.intervalsUrl, apiKey);
+ athlete = await getAthlete(config.intervalsUrl, {
+ method: 'api_key',
+ secret: apiKey,
+ });
} catch (e) {
if (e instanceof IntervalsError) {
return c.json(
@@ -72,56 +165,118 @@ export const integrationRoutes = new Hono()
throw e;
}
- const db = c.get('db');
- const userId = c.get('user').id;
- const taken = db
- .select({ userId: schema.integration.userId })
- .from(schema.integration)
- .where(
- and(
- eq(schema.integration.provider, 'intervals'),
- eq(schema.integration.accountId, athlete.id),
- ),
+ if (
+ !saveConnection(
+ c,
+ athlete,
+ { method: 'api_key', secret: apiKey },
+ startOfDay(syncFrom),
)
- .get();
- if (taken && taken.userId !== userId) {
- return c.json(
+ ) {
+ return c.json({ error: TAKEN }, 409);
+ }
+ return c.json(overview(c));
+ })
+
+ /**
+ * Sends the member to intervals.icu to let openRun read their runs. The web
+ * app navigates here rather than fetching it.
+ */
+ .get(
+ '/intervals/oauth/start',
+ zValidator('query', startIntervalsOAuthSchema),
+ async (c) => {
+ const config = c.get('config');
+ if (!config.intervalsUrl || !config.intervalsOAuth) {
+ return c.json(
+ { error: 'intervals.icu sign-in is turned off here' },
+ 404,
+ );
+ }
+ const state = base64url(randomBytes(16));
+ const verifier = base64url(randomBytes(32));
+ await setSignedCookie(
+ c,
+ OAUTH_COOKIE,
+ JSON.stringify({
+ state,
+ verifier,
+ userId: c.get('user').id,
+ syncFrom: c.req.valid('query').syncFrom ?? null,
+ }),
+ config.authSecret,
{
- error:
- 'That intervals.icu account is already connected to someone else on this server.',
+ path: OAUTH_PATH,
+ httpOnly: true,
+ // Sent when intervals.icu redirects back, a top-level navigation.
+ sameSite: 'Lax',
+ secure: config.origin.startsWith('https:'),
+ maxAge: 600,
},
- 409,
);
+ return c.redirect(
+ authorizeUrl(config.intervalsUrl, config.intervalsOAuth, {
+ redirectUri: redirectUri(c),
+ state,
+ codeChallenge: base64url(
+ createHash('sha256').update(verifier).digest(),
+ ),
+ }),
+ );
+ },
+ )
+
+ /** intervals.icu sends the member back here, then on to Settings. */
+ .get('/intervals/oauth/callback', async (c) => {
+ const config = c.get('config');
+ if (!config.intervalsUrl || !config.intervalsOAuth) {
+ return backToSettings(c, 'failed');
+ }
+ const cookie = await getSignedCookie(c, config.authSecret, OAUTH_COOKIE);
+ deleteCookie(c, OAUTH_COOKIE, { path: OAUTH_PATH });
+ let pending;
+ try {
+ pending = oauthStateSchema.parse(JSON.parse(cookie || 'null'));
+ } catch {
+ return backToSettings(c, 'expired');
}
+ const { code, state, error } = c.req.query();
+ if (pending.state !== state || pending.userId !== c.get('user').id) {
+ return backToSettings(c, 'expired');
+ }
+ if (error || !code) return backToSettings(c, 'denied');
- const existing = findIntervals(c);
- const values = {
- accountId: athlete.id,
- accountName: athlete.name ?? null,
- credentials: encryptSecret(config.authSecret, apiKey),
- syncFrom: startOfDay(syncFrom),
- status: 'ok' as const,
- lastError: null,
- };
- if (existing && existing.accountId === athlete.id) {
- db.update(schema.integration)
- .set(values)
- .where(eq(schema.integration.id, existing.id))
- .run();
- } else {
- // A different account starts afresh.
- db.transaction((tx) => {
- if (existing) {
- tx.delete(schema.integration)
- .where(eq(schema.integration.id, existing.id))
- .run();
- }
- tx.insert(schema.integration)
- .values({ ...values, userId, provider: 'intervals' })
- .run();
- });
+ let token;
+ try {
+ token = await exchangeCode(
+ config.intervalsUrl,
+ config.intervalsOAuth,
+ code,
+ pending.verifier,
+ );
+ } catch (e) {
+ if (!(e instanceof IntervalsError)) {
+ console.error('intervals.icu sign-in failed', e);
+ }
+ return backToSettings(c, 'failed');
}
- return c.json(overview(c));
+ const saved = saveConnection(
+ c,
+ token.athlete,
+ { method: 'oauth', secret: token.accessToken },
+ pending.syncFrom ? startOfDay(pending.syncFrom) : null,
+ );
+ if (!saved) {
+ // Let go of the token, so intervals.icu doesn't list openRun for it.
+ await disconnectApp(config.intervalsUrl, token.accessToken).catch(
+ () => undefined,
+ );
+ return backToSettings(c, 'taken');
+ }
+ const row = findIntervals(c);
+ // The first sync carries on after the redirect; Settings shows progress.
+ if (row) void syncIntegration(c.get('db'), config, row);
+ return backToSettings(c, 'connected');
})
/** Changes how far back to import; going further back fetches the gap. */
@@ -146,9 +301,19 @@ export const integrationRoutes = new Hono()
return c.json(overview(c));
})
- /** Disconnects. Runs already imported stay in the log. */
- .delete('/intervals', (c) => {
+ /**
+ * Disconnects. Runs already imported stay in the log. An OAuth token is
+ * revoked too, if intervals.icu can be reached.
+ */
+ .delete('/intervals', async (c) => {
const row = findIntervals(c);
+ const config = c.get('config');
+ if (row?.authMethod === 'oauth' && config.intervalsUrl) {
+ const token = decryptSecret(config.authSecret, row.credentials);
+ if (token) {
+ await disconnectApp(config.intervalsUrl, token).catch(() => undefined);
+ }
+ }
if (row) {
c.get('db')
.delete(schema.integration)
diff --git a/apps/web-e2e/fake-intervals.mjs b/apps/web-e2e/fake-intervals.mjs
index 12e1b4f..f1735e9 100644
--- a/apps/web-e2e/fake-intervals.mjs
+++ b/apps/web-e2e/fake-intervals.mjs
@@ -1,8 +1,10 @@
// A pretend intervals.icu for the e2e tests: one athlete with one run from
-// a Garmin watch yesterday, served as a GPX file. Any key but "wrong" works.
+// a Garmin watch yesterday, served as a GPX file. Any key but "wrong" works,
+// and the OAuth sign-in says yes straight away.
import { createServer } from 'node:http';
const port = Number(process.env.PORT ?? 3109);
+const TOKEN = 'e2e-oauth-token';
const start = new Date(Date.now() - 86_400_000);
start.setUTCHours(7, 0, 0, 0);
@@ -17,18 +19,46 @@ function gpx() {
createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
const auth = req.headers.authorization ?? '';
- const key = Buffer.from(auth.replace(/^Basic /, ''), 'base64')
- .toString()
- .replace(/^API_KEY:/, '');
+ const key = auth.startsWith('Bearer ')
+ ? auth.slice(7) === TOKEN && TOKEN
+ : Buffer.from(auth.replace(/^Basic /, ''), 'base64')
+ .toString()
+ .replace(/^API_KEY:/, '');
const json = (body) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/health') return json({ ok: true });
+ if (url.pathname === '/oauth/authorize') {
+ const back = new URL(url.searchParams.get('redirect_uri') ?? '');
+ back.searchParams.set('code', 'e2e-code');
+ back.searchParams.set('state', url.searchParams.get('state') ?? '');
+ res.writeHead(302, { location: back.toString() });
+ return res.end();
+ }
+ if (url.pathname === '/api/oauth/token' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (chunk) => (body += chunk));
+ req.on('end', () => {
+ const form = new URLSearchParams(body);
+ if (form.get('code') !== 'e2e-code' || !form.get('code_verifier')) {
+ res.writeHead(400);
+ return res.end();
+ }
+ json({
+ token_type: 'Bearer',
+ access_token: TOKEN,
+ scope: 'ACTIVITY:READ',
+ athlete: { id: 'i4242', name: 'Test Runner' },
+ });
+ });
+ return;
+ }
if (!key || key === 'wrong-key') {
res.writeHead(401);
return res.end();
}
+ if (url.pathname === '/api/v1/disconnect-app') return json({});
if (url.pathname === '/api/v1/athlete/0') {
return json({ id: 'i4242', name: 'Test Runner' });
}
diff --git a/apps/web-e2e/playwright.config.mts b/apps/web-e2e/playwright.config.mts
index 56fb5e1..83497c6 100644
--- a/apps/web-e2e/playwright.config.mts
+++ b/apps/web-e2e/playwright.config.mts
@@ -58,6 +58,8 @@ export default defineConfig({
PLACE_SEARCH_URL: 'http://127.0.0.1:9/search',
WEATHER_URL: '',
INTERVALS_URL: 'http://127.0.0.1:3109',
+ INTERVALS_CLIENT_ID: 'openrun-e2e',
+ INTERVALS_CLIENT_SECRET: 'e2e-secret',
},
},
],
diff --git a/apps/web-e2e/src/smoke.spec.ts b/apps/web-e2e/src/smoke.spec.ts
index cd1d0f4..1a2dfcf 100644
--- a/apps/web-e2e/src/smoke.spec.ts
+++ b/apps/web-e2e/src/smoke.spec.ts
@@ -434,12 +434,12 @@ test.describe('clubs', () => {
test('syncs runs from intervals.icu', async ({ page }) => {
await page.goto('/settings');
const card = page.getByRole('region', { name: 'Sync from your watch' });
- await card.getByLabel('intervals.icu API key').fill('wrong-key');
- await card.getByRole('button', { name: 'Connect' }).click();
- await expect(card.getByText(/didn't accept that API key/)).toBeVisible();
-
- await card.getByLabel('intervals.icu API key').fill('a-good-api-key');
- await card.getByRole('button', { name: 'Connect' }).click();
+ // Signs in on (pretend) intervals.icu, which sends us back to Settings.
+ await card
+ .getByRole('button', { name: 'Connect with intervals.icu' })
+ .click();
+ await expect(page).toHaveURL(/\/settings$/);
+ await expect(card.getByText('Your runs are on their way')).toBeVisible();
await expect(card.getByText('Connected to Test Runner')).toBeVisible();
await expect(card.getByText(/1 run imported/)).toBeVisible();
await card.getByText('Sync log').click();
@@ -457,7 +457,27 @@ test.describe('clubs', () => {
await page.goto('/settings');
page.once('dialog', (d) => d.accept());
await card.getByRole('button', { name: 'Disconnect' }).click();
- await expect(card.getByLabel('intervals.icu API key')).toBeVisible();
+ await expect(
+ card.getByRole('button', { name: 'Connect with intervals.icu' }),
+ ).toBeVisible();
+
+ // An API key still works.
+ await card.getByText('Use an API key instead').click();
+ await card.getByLabel('intervals.icu API key').fill('wrong-key');
+ await card.getByRole('button', { name: 'Connect with key' }).click();
+ await expect(card.getByText(/didn't accept that API key/)).toBeVisible();
+ await card.getByLabel('intervals.icu API key').fill('a-good-api-key');
+ await card.getByRole('button', { name: 'Connect with key' }).click();
+ await expect(card.getByText('Connected to Test Runner')).toBeVisible();
+ // The run is already in the log from the first connection.
+ await card.getByText('Sync log').click();
+ await expect(card.getByText('1 already here')).toBeVisible();
+
+ page.once('dialog', (d) => d.accept());
+ await card.getByRole('button', { name: 'Disconnect' }).click();
+ await expect(
+ card.getByRole('button', { name: 'Connect with intervals.icu' }),
+ ).toBeVisible();
});
});
diff --git a/apps/web/src/components/intervals-settings.tsx b/apps/web/src/components/intervals-settings.tsx
index dfc68ad..25befc9 100644
--- a/apps/web/src/components/intervals-settings.tsx
+++ b/apps/web/src/components/intervals-settings.tsx
@@ -1,5 +1,10 @@
-import { type FormEvent, useEffect, useState } from 'react';
-import type { IntegrationSync, IntervalsConnection } from '@openrun/schemas';
+import { type FormEvent, type ReactNode, useEffect, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import type {
+ IntegrationSync,
+ IntervalsConnection,
+ IntervalsOAuthResult,
+} from '@openrun/schemas';
import {
useConnectIntervals,
useDisconnectIntervals,
@@ -11,11 +16,41 @@ import { useInstance } from '../lib/queries';
import { FormError } from './auth-card';
const INTERVALS = 'https://intervals.icu';
+/** The web app navigates here; the API sends the member on to intervals.icu. */
+const OAUTH_START = '/api/integrations/intervals/oauth/start';
+
+const OAUTH_RESULTS: Record = {
+ connected: 'Connected. Your runs are on their way.',
+ denied: "openRun wasn't given access on intervals.icu, so nothing changed.",
+ expired: 'That sign-in took too long or was started elsewhere. Try again.',
+ taken:
+ 'That intervals.icu account is already connected to someone else on this server.',
+ failed: "intervals.icu didn't finish the sign-in. Try again in a minute.",
+};
+
+/** How the intervals.icu sign-in ended, read once from `?intervals=`. */
+function useOAuthResult() {
+ const [params, setParams] = useSearchParams();
+ const [result] = useState(() => {
+ const value = params.get('intervals');
+ return value && value in OAUTH_RESULTS
+ ? (value as IntervalsOAuthResult)
+ : null;
+ });
+ useEffect(() => {
+ if (!params.has('intervals')) return;
+ const next = new URLSearchParams(params);
+ next.delete('intervals');
+ setParams(next, { replace: true });
+ }, [params, setParams]);
+ return result;
+}
export function IntervalsSettings() {
const { data } = useIntegrations();
+ const result = useOAuthResult();
if (!data) return null;
- const { available, connection } = data.intervals;
+ const { available, oauth, connection } = data.intervals;
return (
@@ -35,49 +70,150 @@ export function IntervalsSettings() {
sync to. openRun gets the original file from your watch.
+ {result && (
+
Syncing is turned off on this server. You can still upload FIT and GPX
files.
) : connection ? (
-
{connect.isPending ? 'Checking the key…' : label}
@@ -150,7 +271,13 @@ function KeyForm({
);
}
-function Connected({ connection }: { connection: IntervalsConnection }) {
+function Connected({
+ connection,
+ oauth,
+}: {
+ connection: IntervalsConnection;
+ oauth: boolean;
+}) {
const sync = useSyncIntervals();
const update = useUpdateIntervals();
const disconnect = useDisconnectIntervals();
@@ -190,11 +317,31 @@ function Connected({ connection }: { connection: IntervalsConnection }) {
{connection.status === 'needs_key' ? (
-
- intervals.icu no longer accepts the saved key, so syncing has
- stopped. Paste your current key to start again.
-
-
+ {connection.authMethod === 'oauth' ? (
+ <>
+
+ intervals.icu no longer lets openRun read your runs, so syncing
+ has stopped. Connect again to start again.
+
+ {oauth ? (
+
+ ) : (
+
+ )}
+ >
+ ) : (
+ <>
+
+ intervals.icu no longer accepts the saved key, so syncing has
+ stopped. Paste your current key to start again
+ {oauth ? ', or connect with intervals.icu instead' : ''}.
+
+
+ {oauth && (
+
+ )}
+ >
+ )}
) : (
connection.status === 'error' &&
diff --git a/apps/web/src/lib/integrations.ts b/apps/web/src/lib/integrations.ts
index 6518c2c..79b2d1f 100644
--- a/apps/web/src/lib/integrations.ts
+++ b/apps/web/src/lib/integrations.ts
@@ -8,6 +8,9 @@ export function useIntegrations() {
return useQuery({
queryKey: KEY,
queryFn: () => api('/integrations', integrationsSchema),
+ // A sync can start without this page asking, e.g. after connecting.
+ refetchInterval: (query) =>
+ query.state.data?.intervals.connection?.syncing ? 2000 : false,
});
}
diff --git a/apps/web/src/pages/privacy.tsx b/apps/web/src/pages/privacy.tsx
index 16a60af..40dc0e5 100644
--- a/apps/web/src/pages/privacy.tsx
+++ b/apps/web/src/pages/privacy.tsx
@@ -57,9 +57,10 @@ export function PrivacyPage() {
If you sync from your watch through intervals.icu, the server keeps
- your intervals.icu API key, encrypted. Whoever runs the server could
- still use it to reach your intervals.icu account, but never your
- Garmin or other watch account. Disconnecting removes it.
+ the access you gave it (a sign-in that can only read your activities,
+ or your API key), encrypted. Whoever runs the server could still use
+ it to reach your intervals.icu account, but never your Garmin or other
+ watch account. Disconnecting removes it.
{instance?.contact && (
diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts
index c04db64..68db59a 100644
--- a/apps/web/vite.config.mts
+++ b/apps/web/vite.config.mts
@@ -56,11 +56,13 @@ export default defineConfig(() => ({
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
- // Show the last data we saw when offline. Auth is never cached.
+ // Show the last data we saw when offline. Auth is never cached,
+ // nor pages under /api such as the intervals.icu sign-in redirects.
urlPattern: ({ url, request }) =>
url.pathname.startsWith('/api/') &&
!url.pathname.startsWith('/api/auth/') &&
- request.method === 'GET',
+ request.method === 'GET' &&
+ request.mode !== 'navigate',
handler: 'NetworkFirst',
options: {
cacheName: 'api',
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2c45549..fbeff9d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -106,13 +106,17 @@ Runs come from watches through **intervals.icu**, which is an official partner o
How it works (`apps/api/src/integrations`):
-- A member pastes their intervals.icu API key on Settings. It's checked against `/api/v1/athlete/0`, then stored in `integration.credentials`, encrypted with AES-256-GCM under a key derived from `BETTER_AUTH_SECRET` (`apps/api/src/secrets.ts`). Changing that secret means everyone pastes their key again (`status = needs_key`). One intervals.icu account can be connected to one member.
+- A member connects in one of two ways; `integration.auth_method` says which:
+ - **OAuth** (`oauth`), when the server has an intervals.icu OAuth app (`INTERVALS_CLIENT_ID` and `INTERVALS_CLIENT_SECRET`). Settings is a plain GET form to `/api/integrations/intervals/oauth/start`, which keeps the state, PKCE verifier, member and start date in a signed, HttpOnly cookie for 10 minutes and redirects to intervals.icu asking for `ACTIVITY:READ`. intervals.icu sends the member back to `/oauth/callback`, which checks the state and member, swaps the code for an access token (`/api/oauth/token`, within 2 minutes), saves the connection, starts the first sync in the background and redirects to `/settings?intervals=`. Tokens don't expire and there's no refresh token. Disconnecting revokes the token (`DELETE /api/v1/disconnect-app`). Each self-hosted server registers its own app, because intervals.icu ties an app to its redirect URIs.
+ - **API key** (`api_key`), always available. The member pastes their key; it's checked against `/api/v1/athlete/0`.
+ - Either way the key or token is stored in `integration.credentials`, encrypted with AES-256-GCM under a key derived from `BETTER_AUTH_SECRET` (`apps/api/src/secrets.ts`). Changing that secret means everyone connects again (`status = needs_key`). One intervals.icu account can be connected to one member.
- `syncIntegration()` lists every activity since `sync_from` (only the fields it reads, so a year is tens of kilobytes) and downloads up to 25 new runs per sync, oldest first. It asks for the original file when it's FIT or GPX and for intervals.icu's FIT otherwise (TCX). Files go through the same import as uploads (`insertParsedActivity`) with `source = 'intervals'` and the intervals.icu id as `external_id`; a run that's already there (`findSameRun`) is marked a duplicate. The whole range is listed every time because runs reach intervals.icu late: a watch that syncs days later, or Garmin sending months of history in chunks over several hours after connecting. The list can't be filtered by when an activity arrived.
- `integration_item` remembers every activity looked at, including runs the member later deleted, so nothing is fetched twice and deleted runs stay deleted. Activities that reached intervals.icu from Strava come back as stubs and are recorded as `unavailable`.
-- A refused key stops scheduled syncs until a new key is pasted; other errors are logged and retried next time. Two syncs of one connection never overlap. The last 20 syncs are kept for the sync log.
+- A refused key or token stops scheduled syncs until the member connects again; other errors are logged and retried next time. Two syncs of one connection never overlap. The last 20 syncs are kept for the sync log.
- **Garmin attribution:** intervals.icu's API terms require crediting Garmin on data from Garmin devices. `activity.device` holds e.g. "Garmin Forerunner 265" (built from `device_name`, prefixed with "Garmin" for `GARMIN_CONNECT` activities) and is shown on the feed, the run page and club shares (copied into `club_share.device`).
-- `INTERVALS_URL` points at intervals.icu; empty turns sync off. Tests stub `fetch`; e2e runs `apps/web-e2e/fake-intervals.mjs`.
+- `INTERVALS_URL` points at intervals.icu; empty turns sync off. Tests stub `fetch`; e2e runs `apps/web-e2e/fake-intervals.mjs`, which also approves OAuth sign-ins straight away.
+- The PWA's offline cache skips navigations under `/api`, so the sign-in redirects always reach the server.
-Later: pushing planned workouts to the intervals.icu calendar, which sends them on to Garmin; OAuth and webhooks instead of polling, if openRun registers as an intervals.icu app.
+Later: pushing planned workouts to the intervals.icu calendar, which sends them on to Garmin; webhooks instead of polling, for servers with an OAuth app (webhooks are set up per app).
- **Weather:** Open-Meteo. **Maps:** MapLibre with OSM tiles. Neither needs an API key.
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 2ea7b9c..af9698b 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -102,7 +102,10 @@ Inside one instance, the personal module "sends" a share by calling the club mod
Runs arrive through intervals.icu, which the member signs up to and connects to their watch maker themselves. openRun never sees a Garmin (or other) password.
-- The member's intervals.icu API key is stored encrypted, so a leaked database or backup doesn't expose it. The server needs to read it to sync while the member is away, so **whoever runs the server could use it**. It gives full access to the member's intervals.icu account, but not to their watch maker's account. The Integrations page says this before the key is pasted, names the instance's contact, and explains how to cut it off (disconnect, or make a new key on intervals.icu).
+- The server keeps either an OAuth token or the member's intervals.icu API key, encrypted, so a leaked database or backup doesn't expose it. The server needs to read it to sync while the member is away, so **whoever runs the server could use it**:
+ - An OAuth token (when the server has an intervals.icu app) only allows reading the member's activities. Disconnecting revokes it.
+ - An API key gives full access to the member's intervals.icu account.
+ - Neither reaches the member's watch maker's account. Settings says this before connecting, names the instance's contact, and explains how to cut it off (disconnect, or for a key, make a new one on intervals.icu).
- Imported runs are ordinary runs: private by default (or the member's default visibility), and shared only when the member chooses.
- intervals.icu sees that this server asked for the member's activities, nothing more.
- Garmin and Strava were not used directly. Garmin's unofficial sign-in would have meant holding members' Garmin logins; Strava's terms forbid keeping the data. See `docs/ARCHITECTURE.md` → Integrations.
diff --git a/libs/db-migrations/migrations/0008_intervals_oauth.sql b/libs/db-migrations/migrations/0008_intervals_oauth.sql
new file mode 100644
index 0000000..4195748
--- /dev/null
+++ b/libs/db-migrations/migrations/0008_intervals_oauth.sql
@@ -0,0 +1 @@
+ALTER TABLE `integration` ADD `auth_method` text DEFAULT 'api_key' NOT NULL;
\ No newline at end of file
diff --git a/libs/db-migrations/migrations/meta/0008_snapshot.json b/libs/db-migrations/migrations/meta/0008_snapshot.json
new file mode 100644
index 0000000..55e975c
--- /dev/null
+++ b/libs/db-migrations/migrations/meta/0008_snapshot.json
@@ -0,0 +1,3140 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "935c0d89-63f0-423c-8c25-d6faae57f267",
+ "prevId": "e24a64bf-128f-456d-914a-f02e4c36d20d",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "api_token": {
+ "name": "api_token",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'read'"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "api_token_token_hash_unique": {
+ "name": "api_token_token_hash_unique",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ },
+ "api_token_user_id_idx": {
+ "name": "api_token_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "api_token_user_id_user_id_fk": {
+ "name": "api_token_user_id_user_id_fk",
+ "tableFrom": "api_token",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "used_by": {
+ "name": "used_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_code_unique": {
+ "name": "invite_code_unique",
+ "columns": [
+ "code"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invite_created_by_user_id_fk": {
+ "name": "invite_created_by_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "invite_used_by_user_id_fk": {
+ "name": "invite_used_by_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "used_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "passkey": {
+ "name": "passkey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passkey_user_id_idx": {
+ "name": "passkey_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "passkey_credential_id_idx": {
+ "name": "passkey_credential_id_idx",
+ "columns": [
+ "credential_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passkey_user_id_user_id_fk": {
+ "name": "passkey_user_id_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_user_id_idx": {
+ "name": "session_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "units": {
+ "name": "units",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'metric'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "activity": {
+ "name": "activity",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sport": {
+ "name": "sport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'run'"
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'private'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "distance_m": {
+ "name": "distance_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "elapsed_s": {
+ "name": "elapsed_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "elevation_gain_m": {
+ "name": "elevation_gain_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "avg_heart_rate": {
+ "name": "avg_heart_rate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_heart_rate": {
+ "name": "max_heart_rate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "avg_cadence": {
+ "name": "avg_cadence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rpe": {
+ "name": "rpe",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shoe_id": {
+ "name": "shoe_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "original_file": {
+ "name": "original_file",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "device": {
+ "name": "device",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "activity_user_started_idx": {
+ "name": "activity_user_started_idx",
+ "columns": [
+ "user_id",
+ "started_at"
+ ],
+ "isUnique": false
+ },
+ "activity_source_external_idx": {
+ "name": "activity_source_external_idx",
+ "columns": [
+ "user_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "activity_user_id_user_id_fk": {
+ "name": "activity_user_id_user_id_fk",
+ "tableFrom": "activity",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "activity_shoe_id_shoe_id_fk": {
+ "name": "activity_shoe_id_shoe_id_fk",
+ "tableFrom": "activity",
+ "tableTo": "shoe",
+ "columnsFrom": [
+ "shoe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "activity_stream": {
+ "name": "activity_stream",
+ "columns": {
+ "activity_id": {
+ "name": "activity_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "activity_stream_activity_id_activity_id_fk": {
+ "name": "activity_stream_activity_id_activity_id_fk",
+ "tableFrom": "activity_stream",
+ "tableTo": "activity",
+ "columnsFrom": [
+ "activity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "lap": {
+ "name": "lap",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "activity_id": {
+ "name": "activity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "index": {
+ "name": "index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "distance_m": {
+ "name": "distance_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "avg_heart_rate": {
+ "name": "avg_heart_rate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "elevation_gain_m": {
+ "name": "elevation_gain_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "lap_activity_id_idx": {
+ "name": "lap_activity_id_idx",
+ "columns": [
+ "activity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "lap_activity_id_activity_id_fk": {
+ "name": "lap_activity_id_activity_id_fk",
+ "tableFrom": "lap",
+ "tableTo": "activity",
+ "columnsFrom": [
+ "activity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "personal_best": {
+ "name": "personal_best",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "activity_id": {
+ "name": "activity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "distance_m": {
+ "name": "distance_m",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "achieved_at": {
+ "name": "achieved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "personal_best_user_distance_idx": {
+ "name": "personal_best_user_distance_idx",
+ "columns": [
+ "user_id",
+ "distance_m"
+ ],
+ "isUnique": false
+ },
+ "personal_best_activity_distance_idx": {
+ "name": "personal_best_activity_distance_idx",
+ "columns": [
+ "activity_id",
+ "distance_m"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "personal_best_user_id_user_id_fk": {
+ "name": "personal_best_user_id_user_id_fk",
+ "tableFrom": "personal_best",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "personal_best_activity_id_activity_id_fk": {
+ "name": "personal_best_activity_id_activity_id_fk",
+ "tableFrom": "personal_best",
+ "tableTo": "activity",
+ "columnsFrom": [
+ "activity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "shoe": {
+ "name": "shoe",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "brand": {
+ "name": "brand",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "starting_distance_m": {
+ "name": "starting_distance_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "retired_at": {
+ "name": "retired_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "shoe_user_id_idx": {
+ "name": "shoe_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "shoe_user_id_user_id_fk": {
+ "name": "shoe_user_id_user_id_fk",
+ "tableFrom": "shoe",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "privacy_settings": {
+ "name": "privacy_settings",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_visibility": {
+ "name": "default_visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'private'"
+ },
+ "share_map": {
+ "name": "share_map",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "privacy_settings_user_id_user_id_fk": {
+ "name": "privacy_settings_user_id_user_id_fk",
+ "tableFrom": "privacy_settings",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "privacy_zone": {
+ "name": "privacy_zone",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lat": {
+ "name": "lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lng": {
+ "name": "lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "radius_m": {
+ "name": "radius_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trim_lat": {
+ "name": "trim_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trim_lng": {
+ "name": "trim_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trim_radius_m": {
+ "name": "trim_radius_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "privacy_zone_user_id_idx": {
+ "name": "privacy_zone_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "privacy_zone_user_id_user_id_fk": {
+ "name": "privacy_zone_user_id_user_id_fk",
+ "tableFrom": "privacy_zone",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "club": {
+ "name": "club",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "join_code": {
+ "name": "join_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "require_approval": {
+ "name": "require_approval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "club_join_code_unique": {
+ "name": "club_join_code_unique",
+ "columns": [
+ "join_code"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "club_created_by_user_id_fk": {
+ "name": "club_created_by_user_id_fk",
+ "tableFrom": "club",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "club_location": {
+ "name": "club_location",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_id": {
+ "name": "club_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lat": {
+ "name": "lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lng": {
+ "name": "lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "club_location_club_idx": {
+ "name": "club_location_club_idx",
+ "columns": [
+ "club_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "club_location_club_id_club_id_fk": {
+ "name": "club_location_club_id_club_id_fk",
+ "tableFrom": "club_location",
+ "tableTo": "club",
+ "columnsFrom": [
+ "club_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "club_member": {
+ "name": "club_member",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_id": {
+ "name": "club_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "share_level": {
+ "name": "share_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'attendance'"
+ },
+ "muted": {
+ "name": "muted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "highlights": {
+ "name": "highlights",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "club_member_club_user_idx": {
+ "name": "club_member_club_user_idx",
+ "columns": [
+ "club_id",
+ "user_id"
+ ],
+ "isUnique": true
+ },
+ "club_member_user_idx": {
+ "name": "club_member_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "club_member_club_id_club_id_fk": {
+ "name": "club_member_club_id_club_id_fk",
+ "tableFrom": "club_member",
+ "tableTo": "club",
+ "columnsFrom": [
+ "club_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "club_member_user_id_user_id_fk": {
+ "name": "club_member_user_id_user_id_fk",
+ "tableFrom": "club_member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "club_run": {
+ "name": "club_run",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_id": {
+ "name": "club_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "series_id": {
+ "name": "series_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "starts_at": {
+ "name": "starts_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_id": {
+ "name": "location_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "order_cutoff_min": {
+ "name": "order_cutoff_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "cancelled_reason": {
+ "name": "cancelled_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "weather": {
+ "name": "weather",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "weather_updated_at": {
+ "name": "weather_updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "club_run_club_starts_idx": {
+ "name": "club_run_club_starts_idx",
+ "columns": [
+ "club_id",
+ "starts_at"
+ ],
+ "isUnique": false
+ },
+ "club_run_series_starts_idx": {
+ "name": "club_run_series_starts_idx",
+ "columns": [
+ "series_id",
+ "starts_at"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "club_run_club_id_club_id_fk": {
+ "name": "club_run_club_id_club_id_fk",
+ "tableFrom": "club_run",
+ "tableTo": "club",
+ "columnsFrom": [
+ "club_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "club_run_series_id_club_run_series_id_fk": {
+ "name": "club_run_series_id_club_run_series_id_fk",
+ "tableFrom": "club_run",
+ "tableTo": "club_run_series",
+ "columnsFrom": [
+ "series_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "club_run_location_id_club_location_id_fk": {
+ "name": "club_run_location_id_club_location_id_fk",
+ "tableFrom": "club_run",
+ "tableTo": "club_location",
+ "columnsFrom": [
+ "location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "club_run_series": {
+ "name": "club_run_series",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_id": {
+ "name": "club_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rrule": {
+ "name": "rrule",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "starts_on": {
+ "name": "starts_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_id": {
+ "name": "location_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "order_cutoff_min": {
+ "name": "order_cutoff_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "club_run_series_club_idx": {
+ "name": "club_run_series_club_idx",
+ "columns": [
+ "club_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "club_run_series_club_id_club_id_fk": {
+ "name": "club_run_series_club_id_club_id_fk",
+ "tableFrom": "club_run_series",
+ "tableTo": "club",
+ "columnsFrom": [
+ "club_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "club_run_series_location_id_club_location_id_fk": {
+ "name": "club_run_series_location_id_club_location_id_fk",
+ "tableFrom": "club_run_series",
+ "tableTo": "club_location",
+ "columnsFrom": [
+ "location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "club_share": {
+ "name": "club_share",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_id": {
+ "name": "club_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_run_id": {
+ "name": "club_run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "distance_m": {
+ "name": "distance_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "device": {
+ "name": "device",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "track": {
+ "name": "track",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "club_share_club_source_idx": {
+ "name": "club_share_club_source_idx",
+ "columns": [
+ "club_id",
+ "source_id"
+ ],
+ "isUnique": true
+ },
+ "club_share_run_idx": {
+ "name": "club_share_run_idx",
+ "columns": [
+ "club_run_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "club_share_club_id_club_id_fk": {
+ "name": "club_share_club_id_club_id_fk",
+ "tableFrom": "club_share",
+ "tableTo": "club",
+ "columnsFrom": [
+ "club_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "club_share_user_id_user_id_fk": {
+ "name": "club_share_user_id_user_id_fk",
+ "tableFrom": "club_share",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "club_share_club_run_id_club_run_id_fk": {
+ "name": "club_share_club_run_id_club_run_id_fk",
+ "tableFrom": "club_share",
+ "tableTo": "club_run",
+ "columnsFrom": [
+ "club_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "coffee_order": {
+ "name": "coffee_order",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_run_id": {
+ "name": "club_run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "menu_item_id": {
+ "name": "menu_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item": {
+ "name": "item",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "choices": {
+ "name": "choices",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "coffee_order_run_user_idx": {
+ "name": "coffee_order_run_user_idx",
+ "columns": [
+ "club_run_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "coffee_order_club_run_id_club_run_id_fk": {
+ "name": "coffee_order_club_run_id_club_run_id_fk",
+ "tableFrom": "coffee_order",
+ "tableTo": "club_run",
+ "columnsFrom": [
+ "club_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "coffee_order_user_id_user_id_fk": {
+ "name": "coffee_order_user_id_user_id_fk",
+ "tableFrom": "coffee_order",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "coffee_order_menu_item_id_menu_item_id_fk": {
+ "name": "coffee_order_menu_item_id_menu_item_id_fk",
+ "tableFrom": "coffee_order",
+ "tableTo": "menu_item",
+ "columnsFrom": [
+ "menu_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "coffee_usual": {
+ "name": "coffee_usual",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_id": {
+ "name": "club_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "menu_item_id": {
+ "name": "menu_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "choices": {
+ "name": "choices",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "auto_add": {
+ "name": "auto_add",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "coffee_usual_club_user_idx": {
+ "name": "coffee_usual_club_user_idx",
+ "columns": [
+ "club_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "coffee_usual_club_id_club_id_fk": {
+ "name": "coffee_usual_club_id_club_id_fk",
+ "tableFrom": "coffee_usual",
+ "tableTo": "club",
+ "columnsFrom": [
+ "club_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "coffee_usual_user_id_user_id_fk": {
+ "name": "coffee_usual_user_id_user_id_fk",
+ "tableFrom": "coffee_usual",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "coffee_usual_menu_item_id_menu_item_id_fk": {
+ "name": "coffee_usual_menu_item_id_menu_item_id_fk",
+ "tableFrom": "coffee_usual",
+ "tableTo": "menu_item",
+ "columnsFrom": [
+ "menu_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "menu_item": {
+ "name": "menu_item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_id": {
+ "name": "club_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "hidden": {
+ "name": "hidden",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "menu_item_club_idx": {
+ "name": "menu_item_club_idx",
+ "columns": [
+ "club_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "menu_item_club_id_club_id_fk": {
+ "name": "menu_item_club_id_club_id_fk",
+ "tableFrom": "menu_item",
+ "tableTo": "club",
+ "columnsFrom": [
+ "club_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rsvp": {
+ "name": "rsvp",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "club_run_id": {
+ "name": "club_run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rsvp_run_user_idx": {
+ "name": "rsvp_run_user_idx",
+ "columns": [
+ "club_run_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rsvp_club_run_id_club_run_id_fk": {
+ "name": "rsvp_club_run_id_club_run_id_fk",
+ "tableFrom": "rsvp",
+ "tableTo": "club_run",
+ "columnsFrom": [
+ "club_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rsvp_user_id_user_id_fk": {
+ "name": "rsvp_user_id_user_id_fk",
+ "tableFrom": "rsvp",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_sent": {
+ "name": "notification_sent",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "notification_sent_user_key_idx": {
+ "name": "notification_sent_user_key_idx",
+ "columns": [
+ "user_id",
+ "key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "notification_sent_user_id_user_id_fk": {
+ "name": "notification_sent_user_id_user_id_fk",
+ "tableFrom": "notification_sent",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_settings": {
+ "name": "notification_settings",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "run_reminder": {
+ "name": "run_reminder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'auto'"
+ },
+ "coffee_nudge": {
+ "name": "coffee_nudge",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "run_changes": {
+ "name": "run_changes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "join_requests": {
+ "name": "join_requests",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notification_settings_user_id_user_id_fk": {
+ "name": "notification_settings_user_id_user_id_fk",
+ "tableFrom": "notification_settings",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "push_subscription": {
+ "name": "push_subscription",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "p256dh": {
+ "name": "p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth": {
+ "name": "auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "push_subscription_endpoint_unique": {
+ "name": "push_subscription_endpoint_unique",
+ "columns": [
+ "endpoint"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "push_subscription_user_id_user_id_fk": {
+ "name": "push_subscription_user_id_user_id_fk",
+ "tableFrom": "push_subscription",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_name": {
+ "name": "account_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "credentials": {
+ "name": "credentials",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_method": {
+ "name": "auth_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'api_key'"
+ },
+ "sync_from": {
+ "name": "sync_from",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ok'"
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_user_provider_idx": {
+ "name": "integration_user_provider_idx",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "integration_account_idx": {
+ "name": "integration_account_idx",
+ "columns": [
+ "provider",
+ "account_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "integration_user_id_user_id_fk": {
+ "name": "integration_user_id_user_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "activity_id": {
+ "name": "activity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_item_external_idx": {
+ "name": "integration_item_external_idx",
+ "columns": [
+ "integration_id",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": [
+ "integration_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_activity_id_activity_id_fk": {
+ "name": "integration_item_activity_id_activity_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "activity",
+ "columnsFrom": [
+ "activity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_sync": {
+ "name": "integration_sync",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imported": {
+ "name": "imported",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "duplicates": {
+ "name": "duplicates",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "failed": {
+ "name": "failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "unavailable": {
+ "name": "unavailable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "more": {
+ "name": "more",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_sync_idx": {
+ "name": "integration_sync_idx",
+ "columns": [
+ "integration_id",
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_sync_integration_id_integration_id_fk": {
+ "name": "integration_sync_integration_id_integration_id_fk",
+ "tableFrom": "integration_sync",
+ "tableTo": "integration",
+ "columnsFrom": [
+ "integration_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/libs/db-migrations/migrations/meta/_journal.json b/libs/db-migrations/migrations/meta/_journal.json
index c0fe641..08a5bad 100644
--- a/libs/db-migrations/migrations/meta/_journal.json
+++ b/libs/db-migrations/migrations/meta/_journal.json
@@ -57,6 +57,13 @@
"when": 1793302977984,
"tag": "0007_intervals_sync",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "6",
+ "when": 1793369535554,
+ "tag": "0008_intervals_oauth",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/libs/db/src/schema/integrations.ts b/libs/db/src/schema/integrations.ts
index 4fe0029..6f010d8 100644
--- a/libs/db/src/schema/integrations.ts
+++ b/libs/db/src/schema/integrations.ts
@@ -10,6 +10,8 @@ import { activity } from './runs.js';
import { id, timestamp, timestamps } from './columns.js';
export const integrationProviders = ['intervals'] as const;
+/** How openRun signs in to the provider for this member. */
+export const integrationAuthMethods = ['api_key', 'oauth'] as const;
export const integrationStatuses = ['ok', 'error', 'needs_key'] as const;
export const integrationItemStatuses = [
'imported',
@@ -31,8 +33,14 @@ export const integration = sqliteTable(
/** The account's id at the provider, e.g. intervals.icu's "i12345". */
accountId: text('account_id').notNull(),
accountName: text('account_name'),
- /** Encrypted with a key derived from BETTER_AUTH_SECRET. */
+ /**
+ * The API key or OAuth access token, encrypted with a key derived from
+ * BETTER_AUTH_SECRET.
+ */
credentials: text('credentials').notNull(),
+ authMethod: text('auth_method', { enum: integrationAuthMethods })
+ .notNull()
+ .default('api_key'),
/** Runs that started before this aren't imported. */
syncFrom: timestamp('sync_from').notNull(),
status: text('status', { enum: integrationStatuses })
diff --git a/libs/schemas/src/integrations.ts b/libs/schemas/src/integrations.ts
index 58ebd5d..d01764a 100644
--- a/libs/schemas/src/integrations.ts
+++ b/libs/schemas/src/integrations.ts
@@ -19,6 +19,8 @@ export type IntegrationSync = z.infer;
export const intervalsConnectionSchema = z.object({
accountId: z.string(),
accountName: z.string().nullable(),
+ /** Connected by pasting an API key, or by signing in to intervals.icu. */
+ authMethod: z.enum(['api_key', 'oauth']),
/** Runs from this day on are imported. */
syncFrom: z.iso.date(),
status: integrationStatusSchema,
@@ -35,6 +37,11 @@ export const integrationsSchema = z.object({
intervals: z.object({
/** False when the server has intervals.icu sync turned off. */
available: z.boolean(),
+ /**
+ * True when the server has an intervals.icu OAuth app, so members can
+ * connect with a button. Pasting an API key always works too.
+ */
+ oauth: z.boolean(),
connection: intervalsConnectionSchema.nullable(),
}),
});
@@ -51,3 +58,18 @@ export const connectIntervalsSchema = z.object({
export type ConnectIntervals = z.infer;
export const updateIntervalsSchema = z.object({ syncFrom: z.iso.date() });
+
+/** Starts connecting through intervals.icu's sign-in page. */
+export const startIntervalsOAuthSchema = z.object({
+ syncFrom: z.iso.date().optional(),
+});
+
+/** How the OAuth sign-in ended, as the settings page is told in `?intervals=`. */
+export const intervalsOAuthResults = [
+ 'connected',
+ 'denied',
+ 'expired',
+ 'taken',
+ 'failed',
+] as const;
+export type IntervalsOAuthResult = (typeof intervalsOAuthResults)[number];