diff --git a/alchemy.run.ts b/alchemy.run.ts index c58c7170..91bf22e6 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -5,10 +5,60 @@ import { Redacted } from 'effect'; import * as Effect from 'effect/Effect'; const steamApiKey = Redacted.make(process.env.STEAM_API_KEY!); -const sshAuthKey = process.env.SSH_AUTH_KEY || 'dev-ssh-auth-key-change-in-prod'; const adminSharedSecret = process.env.ADMIN_SHARED_SECRET || 'dev-admin-shared-secret-change-in-prod'; +/** + * Stages where a missing setting is a deploy failure rather than a default. + * + * A stage somebody else can reach has to be configured; a throwaway one a + * developer made this morning does not. The list is the same one that decides + * observability and DNS below, named once so the two cannot drift apart. + */ +const PERMANENT_STAGES = ['production', 'sandbox', 'dev']; + +/** + * Mail settings, refused rather than defaulted when a stage needs them. + * + * Verifying an address is the only way to sign in, so a worker that cannot + * send mail cannot sign anybody in — and the failure to catch is the one where + * that is discovered by a person staring at a screen that says "check your + * email". Checking here turns it into a deploy that stops with the name of the + * variable it wanted. + */ +function mailEnv(stage: string) { + const url = process.env.EMAIL_SEND_URL; + const key = process.env.EMAIL_API_KEY; + const from = process.env.EMAIL_FROM; + + if (PERMANENT_STAGES.includes(stage)) { + const missing = [ + ['EMAIL_SEND_URL', url], + ['EMAIL_API_KEY', key], + ['EMAIL_FROM', from] + ] + .filter(([, value]) => !value) + .map(([name]) => name); + if (missing.length > 0) { + throw new Error( + `Stage "${stage}" serves sign-in, so it needs mail delivery configured. ` + + `Missing: ${missing.join(', ')}.` + ); + } + } + + return { + ...(url ? { EMAIL_SEND_URL: url } : {}), + ...(key ? { EMAIL_API_KEY: Redacted.make(key) } : {}), + ...(from ? { EMAIL_FROM: from } : {}), + // Printing a live sign-in code to the log is a thing you ask for by + // name. It is never set on a stage anyone else can reach, and the + // worker refuses to send without either this or real settings, so an + // unconfigured deploy fails loudly instead of quietly logging codes. + ...(PERMANENT_STAGES.includes(stage) ? {} : { EMAIL_DEV_LOG: 'true' }) + }; +} + const AuthStorage = Cloudflare.KV.Namespace('auth-storage'); const Database = Effect.gen(function* () { @@ -37,15 +87,17 @@ const Database = Effect.gen(function* () { export const Auth = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; - const isPermanent = ['production', 'sandbox', 'dev'].includes(stage); + const isPermanent = PERMANENT_STAGES.includes(stage); return yield* Cloudflare.Worker('auth', { main: 'apps/auth/src/index.ts', compatibility: { flags: ['nodejs_compat'] }, + // No Steam or SSH settings: the issuer serves one provider, and it is + // the email one. Linking a Steam account is `apps/api`'s job and its + // key is bound there. env: { AuthStorage, HYPERDRIVE: Database, - STEAM_API_KEY: steamApiKey, - SSH_AUTH_KEY: sshAuthKey + ...mailEnv(stage) }, ...(isPermanent ? { observability: { enabled: true } } : {}) }); @@ -53,7 +105,7 @@ export const Auth = Effect.gen(function* () { export const Api = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; - const isPermanent = ['production', 'sandbox', 'dev'].includes(stage); + const isPermanent = PERMANENT_STAGES.includes(stage); const prefix = stage === 'production' ? '' : `${stage}.`; const authDomain = ['production', 'sandbox'].includes(stage) ? `${prefix}auth.nestri.io` diff --git a/apps/auth/src/email.ts b/apps/auth/src/email.ts new file mode 100644 index 00000000..40a7e640 --- /dev/null +++ b/apps/auth/src/email.ts @@ -0,0 +1,83 @@ +/** + * Getting a pin code to a mailbox. + * + * Deliberately not tied to one mail vendor: it posts a small JSON body to + * whatever endpoint is configured, so swapping providers is configuration and + * not a code change. Three settings — `EMAIL_SEND_URL`, `EMAIL_API_KEY`, + * `EMAIL_FROM` — and a fourth, `EMAIL_DEV_LOG`, that asks for the code to be + * printed instead of sent. + */ +export interface MailerConfig { + EMAIL_SEND_URL?: string; + EMAIL_API_KEY?: string; + EMAIL_FROM?: string; + /** + * Print the code to the log rather than sending it. `'true'` and nothing + * else, so a variable left holding `'false'` or `'0'` cannot switch it on. + */ + EMAIL_DEV_LOG?: string; +} + +/** + * Send the code, or refuse. + * + * The rule is that printing a live sign-in code to a log is something you ask + * for by name, and that anything else is an error. It reads that way round + * because the alternative — treat an unconfigured mailer as "must be a + * developer" — fails *open*: the deployment that forgets its mail settings is + * exactly the deployment with no marker saying it is a real one, so it takes + * the developer branch, logs every recipient and every usable code to a + * retained log, and reports success while nobody receives anything. + * + * Configuration is also all-or-nothing. Two settings out of three is somebody + * halfway through wiring a provider up, and quietly falling back would hide + * the half that is missing. + */ +export async function sendVerificationCode( + config: MailerConfig, + email: string, + code: string +): Promise { + const present = [config.EMAIL_SEND_URL, config.EMAIL_API_KEY, config.EMAIL_FROM].filter(Boolean); + + if (present.length === 0) { + if (config.EMAIL_DEV_LOG === 'true') { + console.log(`[auth] sign-in code for ${email}: ${code}`); + return; + } + throw new Error( + 'Email delivery is not configured, so no sign-in code can be sent. ' + + 'Set EMAIL_SEND_URL, EMAIL_API_KEY and EMAIL_FROM, or set EMAIL_DEV_LOG=true ' + + 'to print codes to the log instead.' + ); + } + + if (present.length < 3) { + throw new Error( + 'Email delivery is half configured: EMAIL_SEND_URL, EMAIL_API_KEY and EMAIL_FROM ' + + 'are needed together.' + ); + } + + const response = await fetch(config.EMAIL_SEND_URL!, { + method: 'POST', + headers: { + authorization: `Bearer ${config.EMAIL_API_KEY}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + from: config.EMAIL_FROM, + to: [email], + subject: `${code} is your Nestri sign-in code`, + text: + `Your Nestri sign-in code is ${code}.\n\n` + + `It expires shortly. If you did not ask to sign in, you can ignore this.` + }) + }); + + if (!response.ok) { + // The body is included because the useful part of a delivery failure is + // always the provider's own message, and it is otherwise lost. + throw new Error(`Sending the sign-in code failed: ${response.status} ${await response.text()}`); + } +} diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 11336900..6a001a7c 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -1,25 +1,61 @@ import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types'; import { issuer } from '@nestri/auth/index'; -import { SshProvider } from '@nestri/auth/provider/ssh'; -import { SteamProvider } from '@nestri/auth/provider/steam'; +import { CodeProvider } from '@nestri/auth/provider/code'; import { CloudflareStorage } from '@nestri/auth/storage/cloudflare'; -import { subjects } from '@nestri/core/auth/subjects'; -import { Database } from '@nestri/core/db/index'; -import { Env } from '@nestri/core/env'; +import { CodeUI } from '@nestri/auth/ui/code'; import { Actor } from '@nestri/core/actor'; -import { Identifier } from '@nestri/core/id'; -import { Steam } from '@nestri/core/steam/index'; +import { PostgresDeviceStore } from '@nestri/core/auth/device-grant'; +import { subjects } from '@nestri/core/auth/subjects'; +import { Env } from '@nestri/core/env'; import { Team } from '@nestri/core/team/index'; -import { User } from '@nestri/core/user/index'; +import { Identity } from '@nestri/core/user/identity'; import { LinkedAccount } from '@nestri/core/user/linked-account'; +import { sendVerificationCode } from './email.js'; + type Env = { AuthStorage: KVNamespace; HYPERDRIVE: Hyperdrive; - STEAM_API_KEY: string; - SSH_AUTH_KEY: string; + EMAIL_SEND_URL?: string; + EMAIL_API_KEY?: string; + EMAIL_FROM?: string; + EMAIL_DEV_LOG?: string; }; +/** + * The programs allowed to start a device authorization grant. + * + * That endpoint takes no secret — a program with no browser has nowhere to keep + * one, which is the whole reason the grant exists — so the identifier is a + * claim and not a proof. What the list buys is that the claim has to be one of + * ours: the identifier ends up on the issued token, and without this anything + * on the internet could mint a grant naming anything at all. + */ +const DEVICE_CLIENTS = new Set(['desktop']); + +/** + * Enough of an address to be worth trying to deliver to. + * + * Deliberately loose: the only test that settles whether an address is real is + * whether the code arrives, and this flow already runs that test. What this + * catches is the empty box and the missing `@` — the cases where nothing could + * possibly be sent — so the screen can say so instead of pretending. + */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Which linked account a token names, for a person who may have none. + * + * An account rooted in an email address starts with nothing attached, so there + * is genuinely no linked account to name and the empty string says so. The + * middleware that reads this already treats an empty value as "no linked + * account", because a server-to-server caller has never had one either. + */ +async function firstSteamLink(userID: string): Promise { + const link = await LinkedAccount.findSteamByUser(userID); + return link?.id ?? ''; +} + export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { Env.init(env as unknown as Record); @@ -28,104 +64,62 @@ export default { storage: CloudflareStorage({ namespace: env.AuthStorage }), + // Not the KV store the rest of this uses, and the difference + // matters. A device grant is answered by a browser and collected by + // a program polling at the same time, so approving it and redeeming + // it each have to be one operation that either happens or does not. + // A store that reads and writes whole records lets those two undo + // each other; a conditional update does not. + deviceStore: PostgresDeviceStore(), + allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID), + // One provider, on purpose. + // + // Verifying an email address is the only thing that brings an + // account into existence. Steam and SSH were sign-ins here as well, + // and both could mint a user from a persona or a key — which makes + // the account only as recoverable as the thing that made it, and + // gives one person as many accounts as they have gaming logins. + // + // They are unwired rather than deleted: the providers still exist + // under `packages/auth/src/provider/`, because connecting a Steam + // account is something this product still does. It does it from + // `apps/api`'s `POST /steam/link`, against a user who already + // exists — which is a connection hanging off an identity, and not + // an identity of its own. ref(d-0048) providers: { - steam: SteamProvider(), - ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY }) + code: CodeProvider({ + // The UI, with delivery replaced. `CodeUI`'s own hook cannot + // report a bad address back to the screen — it returns + // nothing — and a mistyped address that silently succeeds + // leaves someone waiting for mail that went nowhere. + ...CodeUI({ + copy: { code_info: "We'll email you a code to sign in." }, + sendCode: async () => {} + }), + sendCode: async (claims, code) => { + const email = claims.email?.trim().toLowerCase(); + if (!email || !EMAIL_RE.test(email)) { + return { type: 'invalid_claim', key: 'email', value: claims.email ?? '' }; + } + await sendVerificationCode(env, email, code); + } + }) }, async success(context, response) { - if (response.provider === 'steam') { - const { steamid } = response; - const profileUrl = new URL( - 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/' - ); - profileUrl.searchParams.set('key', env.STEAM_API_KEY); - profileUrl.searchParams.set('steamids', steamid); + if (response.provider === 'code') { + const email = (response.claims as Record).email!.trim().toLowerCase(); + const { userID } = await Identity.fromVerifiedEmail({ email }); - const profileRes = await fetch(profileUrl.toString()); - const profileData = (await profileRes.json()) as { - response?: { players?: Array> }; - }; - - const player = profileData?.response?.players?.[0] as any; - const personaname: string = player?.personaname ?? 'Player'; - const avatarfull: string = player?.avatarfull; - - const { userID, linkedAccountID } = await Database.transaction(async () => { - const existing = await LinkedAccount.findByProvider({ - provider: 'steam', - providerAccountId: steamid - }); - - if (existing) { - const user = await User.fromID(existing.userId); - if (!user) throw new Error('User not found for linked account'); - return { userID: user.id, linkedAccountID: existing.id }; - } - - const newUserID = Identifier.ascending('user'); - await User.create({ - id: newUserID, - name: personaname, - email: undefined, - emailVerified: false, - image: avatarfull ?? null - }); - - const newLinkedAccountID = Identifier.ascending('linkedAccount'); - await LinkedAccount.create({ - id: newLinkedAccountID, - userId: newUserID, - provider: 'steam', - providerAccountId: steamid, - profile: player ?? {} - }); - - return { userID: newUserID, linkedAccountID: newLinkedAccountID }; - }); - - // Every user needs a personal team, because `machine.teamId` is - // notNull and registering a host has nowhere to put it - // otherwise. `packages/core/CLAUDE.md` documented this call as - // part of the login flow and it was never actually made, so no - // user in the database has one. ref(d-0048) - // - // Run on every login rather than only on creation: that is what - // backfills the accounts made before this existed, and - // `ensurePersonal` is idempotent precisely so it can be. + // Every user needs a personal team, because `machine.teamId` + // is notNull and registering a host has nowhere to put it + // otherwise. Idempotent, so running it on every sign-in is + // also what backfills the accounts made before it existed. + const linkedAccountID = await firstSteamLink(userID); await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => - Team.ensurePersonal({ displayName: personaname }) + Team.ensurePersonal({ displayName: email.split('@')[0]! }) ); - return context.subject('user', { - userID, - linkedAccountID - }); - } - - if (response.provider === 'ssh') { - const { fingerprint, steamId, username, profile } = response; - const { userID, linkedAccountID } = await Steam.resolveSshIdentity({ - fingerprint, - steamId, - username, - profile - }); - - // Same reason as the Steam branch above. The SSH path creates - // users too, so leaving it out would give a host registered - // from `nessh` nowhere to live. - await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => - // `username` is optional on the SSH path — a key can arrive - // before a persona does. The slug only has to be derivable, - // not pretty, and a rename is a later problem. - Team.ensurePersonal({ displayName: username ?? 'Player' }) - ); - - return context.subject('user', { - userID, - linkedAccountID, - fingerprint - }); + return context.subject('user', { userID, linkedAccountID }); } throw new Error('Unknown provider'); diff --git a/apps/auth/test/email.test.ts b/apps/auth/test/email.test.ts new file mode 100644 index 00000000..2df7df16 --- /dev/null +++ b/apps/auth/test/email.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'bun:test'; + +import { sendVerificationCode } from '../src/email.js'; + +describe('sending a sign-in code', () => { + test('printing the code to the log has to be asked for by name', async () => { + await sendVerificationCode({ EMAIL_DEV_LOG: 'true' }, 'ada@example.com', '123456'); + }); + + // The regression this holds: the previous rule was "throw only when the + // environment says production", which meant a deployment that set no + // marker at all — which is what the real one did — took the developer + // branch and logged live codes. Absence is now a refusal. + test('nothing configured and nothing asked for is a refusal, not a log', async () => { + await expect(sendVerificationCode({}, 'ada@example.com', '123456')).rejects.toThrow( + /not configured/ + ); + }); + + test('a variable left holding something other than true does not switch logging on', async () => { + await expect( + sendVerificationCode({ EMAIL_DEV_LOG: 'false' }, 'ada@example.com', '123456') + ).rejects.toThrow(/not configured/); + }); + + test('half a mailer is an error rather than a fallback', async () => { + await expect( + sendVerificationCode( + { EMAIL_SEND_URL: 'https://mail.example.com/send', EMAIL_DEV_LOG: 'true' }, + 'ada@example.com', + '123456' + ) + ).rejects.toThrow(/half configured/); + }); + + test('a configured mailer is called with the address and the code', async () => { + let seen: { url: string; body: any; auth: string | null } | null = null; + const original = globalThis.fetch; + globalThis.fetch = (async (url: any, init: any) => { + seen = { + url: String(url), + body: JSON.parse(init.body), + auth: new Headers(init.headers).get('authorization') + }; + return new Response('{}', { status: 200 }); + }) as unknown as typeof fetch; + + try { + await sendVerificationCode( + { + EMAIL_SEND_URL: 'https://mail.example.com/send', + EMAIL_API_KEY: 'key', + EMAIL_FROM: 'hello@nestri.io' + }, + 'ada@example.com', + '123456' + ); + } finally { + globalThis.fetch = original; + } + + expect(seen!.url).toBe('https://mail.example.com/send'); + expect(seen!.auth).toBe('Bearer key'); + expect(seen!.body.to).toEqual(['ada@example.com']); + expect(seen!.body.from).toBe('hello@nestri.io'); + expect(seen!.body.text).toContain('123456'); + }); + + test('a configured mailer sends even when dev logging is on', async () => { + let called = false; + const original = globalThis.fetch; + globalThis.fetch = (async () => { + called = true; + return new Response('{}', { status: 200 }); + }) as unknown as typeof fetch; + + try { + await sendVerificationCode( + { + EMAIL_SEND_URL: 'https://mail.example.com/send', + EMAIL_API_KEY: 'key', + EMAIL_FROM: 'hello@nestri.io', + EMAIL_DEV_LOG: 'true' + }, + 'ada@example.com', + '123456' + ); + } finally { + globalThis.fetch = original; + } + + expect(called).toBe(true); + }); + + test('a refusal from the mailer is not swallowed', async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => + new Response('over quota', { status: 429 })) as unknown as typeof fetch; + try { + await expect( + sendVerificationCode( + { + EMAIL_SEND_URL: 'https://mail.example.com/send', + EMAIL_API_KEY: 'key', + EMAIL_FROM: 'hello@nestri.io' + }, + 'ada@example.com', + '123456' + ) + ).rejects.toThrow(/over quota/); + } finally { + globalThis.fetch = original; + } + }); +}); + +describe('the worker itself', () => { + // Cheap, and it catches the thing a type check cannot: the sign-in screen + // lives in a `.tsx` file, and whether that file can be imported across a + // package boundary at run time is decided by the package's export map + // rather than by the compiler. + test('loads, with every provider it wires resolvable', async () => { + const worker = await import('../src/index.js'); + expect(typeof worker.default.fetch).toBe('function'); + }); +}); diff --git a/apps/auth/test/worker.test.ts b/apps/auth/test/worker.test.ts index 9e0de1e1..2707745f 100644 --- a/apps/auth/test/worker.test.ts +++ b/apps/auth/test/worker.test.ts @@ -1,125 +1,154 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { createClient } from '@nestri/auth/client'; import { issuer } from '@nestri/auth/index'; -import { SshProvider } from '@nestri/auth/provider/ssh'; -import { SteamProvider } from '@nestri/auth/provider/steam'; +import { CodeProvider } from '@nestri/auth/provider/code'; import { MemoryStorage } from '@nestri/auth/storage/memory'; +import { CodeUI } from '@nestri/auth/ui/code'; import { subjects } from '@nestri/core/auth/subjects'; +/** + * The issuer the worker builds, with the database taken out. + * + * The provider list is the load-bearing part and is the same one + * `apps/auth/src/index.ts` passes: one entry, `code`. `success` is a stub + * because what the real one does — resolve an address to a user and give it a + * team — is core's behaviour and is held by core's own tests. What this file + * holds is the shape of the issuer around it. + */ +let lastCode = ''; const storage = MemoryStorage(); - const auth = issuer({ subjects, storage, allow: async () => true, providers: { - steam: SteamProvider(), - ssh: SshProvider({ sshAuthKey: 'test-ssh-key' }) + code: CodeProvider({ + ...CodeUI({ copy: { code_info: 'test' }, sendCode: async () => {} }), + sendCode: async (_claims, code) => { + lastCode = code; + } + }) }, async success(context, response) { - if (response.provider === 'steam') { + if (response.provider === 'code') { return context.subject('user', { userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' }); } - if (response.provider === 'ssh') { - return context.subject('user', { - userID: 'usr_test123', - linkedAccountID: 'lac_test456', - fingerprint: response.fingerprint - }); - } - throw new Error('unknown provider'); + throw new Error('Unknown provider'); } }); -beforeEach(() => { - globalThis.fetch = mock(async (input: string | URL | Request, _init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - - if (url.includes('steamcommunity.com/openid/login')) { - return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 }); - } - - if (url.includes('api.steampowered.com')) { - return new Response( - JSON.stringify({ - response: { - players: [ - { - personaname: 'TestPlayer', - avatarfull: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg', - steamid: '76561197960287956' - } - ] - } - }), - { status: 200 } - ); - } - - return new Response('not found', { status: 404 }); - }) as unknown as typeof fetch; -}); - -afterEach(() => { - globalThis.fetch = fetch; -}); - -describe('Steam auth flow', () => { - test('authorize redirects to Steam OpenID', async () => { +/** + * Signing in with a gaming account or a key is gone, and this is the assertion + * that keeps it gone. + * + * Both used to be providers here and both could bring a user into existence + * from something that is not an address, which is the shape the account model + * no longer has. The provider implementations still exist and can be wired + * back; what must not happen quietly is them becoming reachable again. + */ +describe('what the issuer serves', () => { + test('there is no sign-in with a gaming account', async () => { const response = await auth.request('https://auth.internal/steam/authorize'); - expect(response.status).toBe(302); - expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/); + expect(response.status).toBe(404); }); - test('full code flow and token verification', async () => { - const client = createClient({ - issuer: 'https://auth.internal', - clientID: 'api', - fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) + test('there is no sign-in with a key', async () => { + const response = await auth.request('https://auth.internal/ssh/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fingerprint: 'SHA256:abc123', steamId: '76561198012345678' }) }); + expect(response.status).toBe(404); + }); - const { challenge, url } = await client.authorize( - 'https://client.example.com/callback', - 'code', - { pkce: true, provider: 'steam' } - ); + test('asking for a code is where a sign-in starts', async () => { + const response = await auth.request('https://auth.internal/code/authorize'); + expect(response.status).toBe(200); + }); +}); - // Step 1: hit the authorize URL → redirects to Steam OpenID - const authResponse = await auth.request(url); - expect(authResponse.status).toBe(302); - const cookie = authResponse.headers.get('set-cookie')!; - expect(cookie).toBeDefined(); +/** + * A cookie jar, because this flow needs two cookies at once. + * + * `/authorize` sets the one holding the authorization, the code provider sets + * the one holding its own state, and both have to be presented at the verify + * step. `Headers.get('set-cookie')` returns only the first of several, which + * silently drops one of them. + */ +function jar() { + const cookies = new Map(); + return { + absorb(response: Response) { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const index = pair!.indexOf('='); + cookies.set(pair!.slice(0, index), pair!.slice(index + 1)); + } + }, + header() { + return [...cookies].map(([name, value]) => `${name}=${value}`).join('; '); + } + }; +} - // Step 2: simulate Steam redirecting back to our callback with valid OpenID params - const callbackUrl = - 'https://auth.internal/steam/callback?' + - 'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' + - 'openid.mode=id_res&' + - 'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' + - 'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' + - 'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956'; +/** + * Ask for a code, redeem it, and come back holding tokens. + * + * The address is a parameter because codes to one mailbox are rate limited, and + * two sign-ins in the same second are exactly what that limit is for. Each + * caller uses its own. + */ +async function signIn(email: string) { + const client = createClient({ + issuer: 'https://auth.internal', + clientID: 'api', + fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) + }); - const callbackResponse = await auth.request(callbackUrl, { - headers: { cookie } - }); - expect(callbackResponse.status).toBe(302); + const { challenge, url } = await client.authorize('https://client.example.com/callback', 'code', { + pkce: true, + provider: 'code' + }); - const location = new URL(callbackResponse.headers.get('location')!); - const code = location.searchParams.get('code'); - expect(code).not.toBeNull(); + const cookies = jar(); + cookies.absorb(await auth.request(url)); + expect(cookies.header()).not.toBe(''); - const exchanged = await client.exchange( - code!, - 'https://client.example.com/callback', - challenge.verifier - ); - if (exchanged.err) throw exchanged.err; - const tokens = exchanged.tokens!; + const requested = await auth.request('https://auth.internal/code/authorize', { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'request', email }) + }); + cookies.absorb(requested); + expect(lastCode).not.toBe(''); + + const verified = await auth.request('https://auth.internal/code/authorize', { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'verify', code: lastCode }) + }); + expect(verified.status).toBe(302); + + const location = new URL(verified.headers.get('location')!); + const code = location.searchParams.get('code'); + expect(code).not.toBeNull(); + + const exchanged = await client.exchange( + code!, + 'https://client.example.com/callback', + challenge.verifier + ); + if (exchanged.err) throw exchanged.err; + return { client, tokens: exchanged.tokens! }; +} + +describe('signing in with an email address', () => { + test('a redeemed code becomes tokens that verify', async () => { + const { client, tokens } = await signIn('ada@example.com'); expect(tokens.access).toBeString(); expect(tokens.refresh).toBeString(); @@ -130,88 +159,15 @@ describe('Steam auth flow', () => { type: 'user', properties: { userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' } }); }); }); -describe('SSH login', () => { - test('valid login returns tokens', async () => { - const loginResponse = await auth.request('https://auth.internal/ssh/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer test-ssh-key' - }, - body: JSON.stringify({ - fingerprint: 'SHA256:abc123', - steamId: '76561198012345678' - }) - }); - - expect(loginResponse.status).toBe(200); - const body: any = await loginResponse.json(); - expect(body.accessToken).toBeString(); - expect(body.refreshToken).toBeString(); - }); - - test('invalid auth key returns 401', async () => { - const response = await auth.request('https://auth.internal/ssh/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer wrong-key' - }, - body: JSON.stringify({ - fingerprint: 'SHA256:abc123', - steamId: '76561198012345678' - }) - }); - - expect(response.status).toBe(401); - }); -}); - describe('User info', () => { - async function getTokens() { - const client = createClient({ - issuer: 'https://auth.internal', - clientID: 'api', - fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) - }); - - const { challenge, url } = await client.authorize( - 'https://client.example.com/callback', - 'code', - { pkce: true, provider: 'steam' } - ); - - const authResponse = await auth.request(url); - const cookie = authResponse.headers.get('set-cookie')!; - - const callbackUrl = - 'https://auth.internal/steam/callback?' + - 'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' + - 'openid.mode=id_res&' + - 'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' + - 'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' + - 'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956'; - - const callbackResponse = await auth.request(callbackUrl, { headers: { cookie } }); - const location = new URL(callbackResponse.headers.get('location')!); - const code = location.searchParams.get('code'); - const exchanged = await client.exchange( - code!, - 'https://client.example.com/callback', - challenge.verifier - ); - if (exchanged.err) throw exchanged.err; - return { client, tokens: exchanged.tokens! }; - } - test('returns subject properties for valid access token', async () => { - const { tokens } = await getTokens(); + const { tokens } = await signIn('grace@example.com'); const infoRes = await auth.request('https://auth.internal/userinfo', { headers: { Authorization: `Bearer ${tokens.access}` } @@ -221,7 +177,7 @@ describe('User info', () => { const userinfo = await infoRes.json(); expect(userinfo).toMatchObject({ userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' }); }); }); diff --git a/packages/auth/package.json b/packages/auth/package.json index a7627a76..70729b14 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -7,6 +7,10 @@ "type": "module", "sideEffects": false, "exports": { + "./ui/code": { + "types": "./src/ui/code.tsx", + "import": "./src/ui/code.tsx" + }, "./*": { "types": "./src/*.ts", "import": "./src/*.ts" diff --git a/packages/auth/src/device.ts b/packages/auth/src/device.ts new file mode 100644 index 00000000..65c3b0c3 --- /dev/null +++ b/packages/auth/src/device.ts @@ -0,0 +1,165 @@ +/** + * Where a device authorization grant lives while nobody has answered for it. + * + * This is an interface and not an implementation because the guarantees it + * asks for are the whole point. A grant moves between states that must each + * happen once — pending to approved, approved to redeemed — while two parties + * are touching it at the same time: a browser somebody is clicking through, + * and a program on another machine polling every few seconds. Held in a store + * that can only get and put whole records, those two overlap and undo each + * other. Every method below is written so that the store can make it one + * operation, and the issuer never reads a record, decides, and writes it back. + * + * @packageDocumentation + */ + +/** How far a grant has got. Terminal in both directions once it leaves pending. */ +export type DeviceGrantStatus = 'pending' | 'approved' | 'denied'; + +/** + * Who the grant turned out to be for, recorded when it is approved. + * + * The tokens themselves are deliberately not here. They are minted when the + * waiting program redeems the code, so their lifetime starts when they are + * handed over rather than whenever the person happened to finish clicking — + * and so a grant nobody collects leaves no usable credential behind. + */ +export interface DeviceGrantSubject { + subject: string; + type: string; + properties: unknown; + ttl: { access: number; refresh: number }; +} + +export interface DeviceGrant { + /** The hash of the device code, never the code itself. */ + deviceCodeHash: string; + userCode: string; + clientID: string; + status: DeviceGrantStatus; + /** Seconds the client is being told to wait between polls. Only grows. */ + interval: number; + /** Epoch ms of the last poll that got a real answer; `0` if there has been none. */ + lastPolled: number; + /** Epoch ms at which the grant stops being usable. */ + expires: number; + subject?: DeviceGrantSubject; +} + +export interface DeviceStore { + create(grant: DeviceGrant): Promise; + byDeviceCode(deviceCodeHash: string): Promise; + byUserCode(userCode: string): Promise; + + /** + * Pending to approved, in one operation. + * + * Returns false when the grant was not pending any more, which is how a + * refusal that arrived first survives an approval that arrives second, and + * the other way round. The caller must not decide this by reading first. + */ + approve(deviceCodeHash: string, subject: DeviceGrantSubject): Promise; + + /** Pending to denied, in one operation. Same rule as {@link approve}. */ + deny(deviceCodeHash: string): Promise; + + /** + * Take an approved grant away and return it, or return null. + * + * This is what makes a device code redeemable once. Two polls arriving + * together must not both be served, so removal and reading have to be the + * same operation — a read, a decision and a delete would serve both. + */ + consume(deviceCodeHash: string, clientID: string): Promise; + + /** + * Record that a poll happened, and what interval it was told to use. + * + * Touches those two fields and nothing else, on purpose. Writing the whole + * record back here is what lets a poll that read a pending grant undo an + * approval that landed while it was thinking. + */ + recordPoll(deviceCodeHash: string, at: number, interval: number): Promise; + + remove(deviceCodeHash: string): Promise; +} + +/** + * The hash a device code is stored under. + * + * A device code is a bearer credential: whoever holds it collects the tokens. + * Storing it as written means anything that can read the table can finish + * somebody else's sign-in, so what is kept is enough to recognise the code and + * not enough to present it. + */ +export async function hashDeviceCode(deviceCode: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(deviceCode)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * A store in a single process's memory, for tests and local runs. + * + * Single-threaded JavaScript gives the atomicity the interface asks for for + * free: nothing suspends between the check and the write in any method here, + * so no two callers can interleave inside one. That is a property of this + * implementation and not something a caller may assume about the interface. + */ +export function MemoryDeviceStore(): DeviceStore { + const byHash = new Map(); + const byCode = new Map(); + + function live(grant: DeviceGrant | undefined): DeviceGrant | null { + if (!grant) return null; + if (grant.expires <= Date.now()) return null; + return grant; + } + + return { + async create(grant) { + byHash.set(grant.deviceCodeHash, { ...grant }); + byCode.set(grant.userCode, grant.deviceCodeHash); + }, + async byDeviceCode(hash) { + const found = byHash.get(hash); + return found ? { ...found } : null; + }, + async byUserCode(userCode) { + const hash = byCode.get(userCode); + const found = hash ? byHash.get(hash) : undefined; + return found ? { ...found } : null; + }, + async approve(hash, subject) { + const grant = live(byHash.get(hash)); + if (!grant || grant.status !== 'pending') return false; + grant.status = 'approved'; + grant.subject = subject; + return true; + }, + async deny(hash) { + const grant = live(byHash.get(hash)); + if (!grant || grant.status !== 'pending') return false; + grant.status = 'denied'; + return true; + }, + async consume(hash, clientID) { + const grant = live(byHash.get(hash)); + if (!grant || grant.status !== 'approved' || grant.clientID !== clientID) return null; + byHash.delete(hash); + byCode.delete(grant.userCode); + return { ...grant }; + }, + async recordPoll(hash, at, interval) { + const grant = byHash.get(hash); + if (!grant) return; + grant.lastPolled = at; + grant.interval = interval; + }, + async remove(hash) { + const grant = byHash.get(hash); + if (!grant) return; + byHash.delete(hash); + byCode.delete(grant.userCode); + } + }; +} diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index ebc421ea..48def0e5 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -175,6 +175,17 @@ export interface AuthorizationState { challenge: string; method: 'S256'; }; + /** + * Set when the browser half of a device authorization grant is running. + * There is no `redirect_uri` in that case: the thing waiting for the answer + * is a program on another machine polling the token endpoint, so the + * result is recorded against the grant instead of into a redirect. + * + * This is the *hash* of the device code. The browser half never sees the + * code itself — it arrives holding a user code, and the code that redeems + * tokens stays with the program that asked for it. + */ + device_code?: string; } /** @@ -195,7 +206,15 @@ import { UnknownStateError } from './error.js'; import { encryptionKeys, legacySigningKeys, signingKeys } from './keys.js'; +import { + type DeviceGrant, + type DeviceGrantSubject, + type DeviceStore, + hashDeviceCode, + MemoryDeviceStore +} from './device.js'; import { validatePKCE } from './pkce.js'; +import { generateUnbiasedString, timingSafeCompare } from './random.js'; import { DynamoStorage } from './storage/dynamo.js'; import { MemoryStorage } from './storage/memory.js'; import { Storage, StorageAdapter } from './storage/storage.js'; @@ -206,6 +225,12 @@ import { getRelativeUrl, isDomainMatch, lazy } from './util.js'; /** @internal */ export const aws = awsHandle; +/** RFC 8628's grant type, spelled out because it is a URN and not a word. */ +const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +/** The longest a device is ever told to wait between polls, in seconds. */ +const DEVICE_MAX_INTERVAL = 60; + export interface IssuerInput< Providers extends Record>, Subjects extends SubjectSchema, @@ -348,7 +373,67 @@ export interface IssuerInput< * @default 0s */ retention?: number; + /** + * Interval in seconds a device code stays usable before the user has to + * start again. + * @default 600s + */ + device?: number; + /** + * Slowest a device may poll the token endpoint without being told to + * slow down, in seconds. + * @default 5s + */ + deviceInterval?: number; }; + /** + * Where device authorization grants are kept. + * + * Defaults to one held in this process's memory, which is right for tests + * and for a single local process and wrong for anything else — a grant + * created by one instance has to be findable by whichever instance the + * browser and the polling client happen to reach. A real deployment passes + * a store backed by something shared, and the interface is written so that + * store can make each transition a single operation. + */ + deviceStore?: DeviceStore; + /** + * How hard a caller may guess at user codes before `/device` stops + * answering them. + * + * A user code is short so that a person can read it off one screen and type + * it into another, and short means guessable given enough tries. RFC 8628 + * §5.2 asks for a limit on the verification endpoint for exactly this + * reason. Counted per caller address over a rolling window; a caller who + * gets one right is not charged for it. + */ + deviceVerification?: { + /** Wrong codes allowed per window. @default 10 */ + guessLimit?: number; + /** Length of the window, in seconds. @default 600 */ + guessWindow?: number; + /** + * Which caller a guess is charged to. + * + * Defaults to the usual forwarded-address headers. Returning undefined + * puts the request in one shared bucket, which is the right answer for + * a caller whose address cannot be established: it means stripping the + * headers buys a smaller budget rather than an unlimited one. + */ + address?(req: Request): string | undefined; + }; + /** + * Whether a client may start a device authorization grant. + * + * `/device/authorize` takes no secret — that is what the grant is for — so + * without this any caller can mint a grant naming any client identifier, + * and that identifier is what the issued token ends up carrying. Returning + * false refuses the request. + * + * Defaults to allowing everything, which preserves the behaviour of an + * issuer that has not thought about it, and is worth thinking about. + */ + allowDeviceClient?(clientID: string, req: Request): Promise; /** * Optionally, configure the UI that's displayed when the user visits the root URL of the * of the OpenAuth server. @@ -466,6 +551,18 @@ export function issuer< const ttlRefresh = input.ttl?.refresh ?? 60 * 60 * 24 * 365; const ttlRefreshReuse = input.ttl?.reuse ?? 60; const ttlRefreshRetention = input.ttl?.retention ?? 0; + const ttlDevice = input.ttl?.device ?? 60 * 10; + const deviceInterval = input.ttl?.deviceInterval ?? 5; + const deviceStore = input.deviceStore ?? MemoryDeviceStore(); + const deviceGuessLimit = input.deviceVerification?.guessLimit ?? 10; + const deviceGuessWindow = input.deviceVerification?.guessWindow ?? 600; + const deviceAddress = + input.deviceVerification?.address ?? + ((req: Request) => + req.headers.get('cf-connecting-ip') ?? + req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + req.headers.get('x-real-ip') ?? + undefined); if (input.theme) { setTheme(input.theme); } @@ -525,6 +622,45 @@ export function issuer< ? subjectOpts.subject : await resolveSubject(type, properties); await successOpts?.invalidate?.(await resolveSubject(type, properties)); + if (authorization?.device_code) { + // A device grant has nowhere to redirect to, and it is + // also not finished. Signing in says who this browser + // is; it does not say that the person meant to hand an + // account to whatever program is holding the other half + // of this code. Those are two different questions and + // only the second one authorizes anything, so what + // happens here is a page that asks it. + await auth.unset(ctx, 'authorization'); + const grant = await deviceStore.byDeviceCode(authorization.device_code); + if (!grant || grant.status !== 'pending' || grant.expires <= Date.now()) { + return ctx.text( + 'That sign-in request has expired. Start it again from the app.', + 400 + ); + } + + // Carried in an encrypted cookie rather than written to + // the grant, so that a request nobody has confirmed + // leaves nothing on the record a later poll could + // mistake for an answer. + const confirmation: DeviceConfirmation = { + deviceCode: authorization.device_code, + userCode: grant.userCode, + clientID: grant.clientID, + csrf: generateUnbiasedString(CSRF_ALPHABET, 32), + subject: { + subject, + type: type as string, + properties, + ttl: { + access: subjectOpts?.ttl?.access ?? ttlAccess, + refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh + } + } + }; + await auth.set(ctx, 'device_confirm', ttlDevice, confirmation); + return ctx.html(deviceConfirmPage(confirmation)); + } if (authorization) { if (authorization.response_type === 'token') { const location = new URL(authorization.redirect_uri); @@ -635,7 +771,127 @@ export function issuer< storage }; - async function getAuthorization(ctx: Context) { + /** + * The alphabet a user code is drawn from, which is not the whole one. + * + * Someone reads this off one screen and types it into another, so every + * pair that looks or sounds alike is a support ticket: no vowels, so no + * accidental words; no `0`/`O`, `1`/`I`, `5`/`S`, `2`/`Z`. What is left is + * unambiguous read aloud over a phone. RFC 8628 §6.1 asks for exactly this + * trade and the entropy lost is bought back by the length. + */ + const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRTVWXY346789'; + const USER_CODE_LENGTH = 8; + + /** Nothing a person reads, so the whole alphabet is available. */ + const CSRF_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + /** + * What is known after signing in and before confirming. + * + * This is the half of the flow that has no answer yet: a browser that has + * proved who it belongs to, holding a code it has not said yes to. It is + * kept in an encrypted cookie rather than on the grant so that a person who + * closes the tab at this point has authorized nothing. + */ + interface DeviceConfirmation { + /** The hash, which is all this side of the flow ever sees. */ + deviceCode: string; + userCode: string; + clientID: string; + csrf: string; + subject: DeviceGrantSubject; + } + + /** + * How many user codes this caller has got wrong lately. + * + * Kept in the general-purpose store rather than with the grants, because it + * is a counter and not a grant, and because being approximate is fine here: + * the number that matters is whether somebody is working through the code + * space, and a handful either way does not change the answer. A caller + * spread across several addresses gets a budget per address, which is what + * makes the limit worth having rather than a way to lock one person out. + */ + async function chargeGuess(req: Request): Promise { + const who = deviceAddress(req) ?? 'unknown'; + const key = ['oauth:device:guess', who]; + const now = Date.now(); + const bucket = await Storage.get<{ count: number; resetAt: number }>(storage!, key); + const next = + bucket && bucket.resetAt > now + ? { count: bucket.count + 1, resetAt: bucket.resetAt } + : { count: 1, resetAt: now + deviceGuessWindow * 1000 }; + await Storage.set( + storage!, + key, + next, + Math.max(1, Math.ceil((next.resetAt - now) / 1000)) + ); + return next.count <= deviceGuessLimit; + } + + async function guessesLeft(req: Request): Promise { + const who = deviceAddress(req) ?? 'unknown'; + const bucket = await Storage.get<{ count: number; resetAt: number }>(storage!, [ + 'oauth:device:guess', + who + ]); + if (!bucket || bucket.resetAt <= Date.now()) return true; + return bucket.count < deviceGuessLimit; + } + + /** + * The code as stored, from the code as a person typed it. + * + * People retype what they see, which includes the separator that made it + * readable and whatever case their keyboard was in. Neither carries + * meaning, so neither is allowed to make a valid code fail. + */ + function canonicalUserCode(raw: string) { + return raw.replace(/[^0-9a-zA-Z]/g, '').toUpperCase(); + } + + /** Enough escaping to put an attacker-chosen client name on a page safely. */ + function escapeHtml(raw: string) { + return raw + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + /** + * The page that asks the only question that authorizes anything. + * + * It shows the code back, because that is the check a person can actually + * perform: the code here and the code on the device in front of them either + * match or they do not, and if they do not then somebody else sent this + * link. Approving is a POST carrying a value that was put in the cookie + * alongside it, so a page on another site cannot submit it on their behalf. + */ + function deviceConfirmPage(confirmation: DeviceConfirmation) { + const code = escapeHtml(confirmation.userCode); + const client = escapeHtml(confirmation.clientID); + return ( + `` + + `Confirm sign-in` + + `

Is this you?

` + + `

${client} is asking to sign in to your account.

` + + `

The code it is showing you should be:

` + + `

${code.slice(0, 4)}-${code.slice(4)}

` + + `

If those do not match, or you did not start this on a device of your own, ` + + `choose Deny. Nobody can sign in as you unless you approve here.

` + + `
` + + `` + + ` ` + + `` + + `
` + ); + } + + async function getAuthorization(ctx: Context) { const match = (await auth.get(ctx, 'authorization')) || ctx.get('authorization'); if (!match) throw new UnknownStateError(); return match as AuthorizationState; @@ -786,8 +1042,15 @@ export function issuer< issuer: iss, authorization_endpoint: `${iss}/authorize`, token_endpoint: `${iss}/token`, + device_authorization_endpoint: `${iss}/device/authorize`, jwks_uri: `${iss}/.well-known/jwks.json`, - response_types_supported: ['code', 'token'] + response_types_supported: ['code', 'token'], + grant_types_supported: [ + 'authorization_code', + 'refresh_token', + 'client_credentials', + DEVICE_GRANT + ] }); } ); @@ -948,6 +1211,122 @@ export function issuer< }); } + if (grantType === DEVICE_GRANT) { + const deviceCode = form.get('device_code')?.toString(); + const clientID = form.get('client_id')?.toString(); + if (!deviceCode) + return c.json( + { error: 'invalid_request', error_description: 'Missing device_code' }, + 400 + ); + if (!clientID) + return c.json( + { error: 'invalid_request', error_description: 'Missing client_id' }, + 400 + ); + + const hash = await hashDeviceCode(deviceCode); + const grant = await deviceStore.byDeviceCode(hash); + + // A code nobody issued and a code that has aged out are the + // same answer on purpose: telling the two apart would let a + // caller learn which random strings were once real. + if (!grant || grant.expires <= Date.now()) { + if (grant) await deviceStore.remove(hash); + return c.json( + { error: 'expired_token', error_description: 'The device code has expired' }, + 400 + ); + } + + // The code belongs to the program that asked for it. Without + // this, a code leaked to anybody at all is redeemable by + // anybody at all, and the client identifier the token ends up + // carrying is whatever the last caller claimed. + if (grant.clientID !== clientID) { + return c.json( + { error: 'invalid_grant', error_description: 'That device code belongs to another client' }, + 400 + ); + } + + // Terminal answers come before the rate limit. Slowing down a + // client that has already been refused just means it takes + // longer to find out, and it has no reason to poll again. + if (grant.status === 'denied') { + await deviceStore.remove(hash); + return c.json( + { error: 'access_denied', error_description: 'The request was denied' }, + 400 + ); + } + + const now = Date.now(); + if (now - grant.lastPolled < grant.interval * 1000) { + // RFC 8628 §3.5: every warning widens the interval for this + // and every later poll, so a client that ignores the answer + // is not simply told the same thing again. `lastPolled` is + // deliberately not moved — the window is measured from the + // last poll that got a real answer, so a burst of impatient + // polls costs one wait rather than compounding into one the + // client can never satisfy. + // Capped, because the interval only ever grows and a code + // that lives ten minutes must stay pollable for all of it. + // Uncapped, enough impatience early on makes the code + // unusable for the rest of its life. + await deviceStore.recordPoll( + hash, + grant.lastPolled, + Math.min(grant.interval + 5, DEVICE_MAX_INTERVAL) + ); + return c.json({ error: 'slow_down', error_description: 'Polling too frequently' }, 400); + } + + if (grant.status === 'approved') { + // One redemption, and the store is what enforces it: taking + // the grant away and reading it are the same operation, so + // two polls arriving together cannot both be served. A + // device code that keeps working after it has produced + // tokens is a bearer token with none of a bearer token's + // expiry. + const claimed = await deviceStore.consume(hash, clientID); + if (!claimed?.subject) { + return c.json( + { error: 'expired_token', error_description: 'The device code has expired' }, + 400 + ); + } + + // Minted now rather than at approval, so the lifetime the + // client is told about starts when it receives them. Tokens + // made when the person clicked would already have been + // ageing for however long the next poll took, and a grant + // nobody ever collects would have left a usable refresh + // token lying in the store. + const tokens = await generateTokens(c, { + subject: claimed.subject.subject, + type: claimed.subject.type, + properties: claimed.subject.properties, + clientID: claimed.clientID, + ttl: claimed.subject.ttl + }); + return c.json({ + access_token: tokens.access, + refresh_token: tokens.refresh, + expires_in: tokens.expiresIn + }); + } + + await deviceStore.recordPoll(hash, now, grant.interval); + return c.json( + { + error: 'authorization_pending', + error_description: 'The user has not finished signing in' + }, + 400 + ); + } + if (grantType === 'client_credentials') { const provider = form.get('provider'); if (!provider) return c.json({ error: 'missing `provider` form value' }, 400); @@ -995,7 +1374,169 @@ export function issuer< } ); - app.get('/authorize', async (c) => { + // The machine half of RFC 8628. A program with no browser asks for a code + // here, shows it to whoever is sitting in front of it, and polls `/token` + // until somebody has answered for it on a device that does have one. + app.post( + '/device/authorize', + cors({ + origin: '*', + allowHeaders: ['*'], + allowMethods: ['POST'], + credentials: false + }), + async (c) => { + const form = await c.req.formData().catch(() => null); + const clientID = form?.get('client_id')?.toString(); + if (!clientID) + return c.json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400); + if (input.allowDeviceClient && !(await input.allowDeviceClient(clientID, c.req.raw))) + return c.json( + { error: 'invalid_client', error_description: 'Unknown client_id' }, + 400 + ); + + // Not `randomUUID`: a device code is the credential the tokens are + // handed to, so it gets the same treatment as one — full-width + // randomness, and only its hash is written down. + const deviceCode = generateUnbiasedString(CSRF_ALPHABET, 43); + const deviceCodeHash = await hashDeviceCode(deviceCode); + + // Retried rather than trusted to be unique: the alphabet is small + // on purpose, so a collision is likelier than it would be for the + // device code, and a collision here hands one person's sign-in to + // somebody else's machine. + let userCode = ''; + for (let attempt = 0; attempt < 5; attempt++) { + const candidate = generateUnbiasedString(USER_CODE_ALPHABET, USER_CODE_LENGTH); + if (!(await deviceStore.byUserCode(candidate))) { + userCode = candidate; + break; + } + } + if (!userCode) + return c.json( + { error: 'server_error', error_description: 'Could not allocate a user code' }, + 500 + ); + + await deviceStore.create({ + deviceCodeHash, + userCode, + clientID, + status: 'pending', + interval: deviceInterval, + lastPolled: 0, + expires: Date.now() + ttlDevice * 1000 + }); + + const iss = issuer(c); + return c.json({ + device_code: deviceCode, + user_code: userCode, + verification_uri: `${iss}/device`, + verification_uri_complete: `${iss}/device?user_code=${userCode}`, + expires_in: ttlDevice, + interval: deviceInterval + }); + } + ); + + // The browser half. Entering the code puts the flow into the same + // authorization state a redirect-based client would have set, so the + // providers below are reached by exactly one path either way. + // + // Reaching this page authorizes nothing. It starts a sign-in, and the + // sign-in ends at a confirmation page — see `/device/confirm`. + app.get('/device', async (c) => { + const raw = c.req.query('user_code'); + if (!raw) { + return c.html( + `` + + `Sign in to a device` + + `
` + + `` + + `` + + `` + + `
` + ); + } + + if (!(await guessesLeft(c.req.raw))) { + return c.text('Too many codes tried. Wait a while and start again from the app.', 429); + } + + const found = await deviceStore.byUserCode(canonicalUserCode(raw)); + if (!found || found.status !== 'pending' || found.expires <= Date.now()) { + // Charged only when the code was wrong. Getting one right costs + // nothing, so a person mistyping once and then succeeding is not + // walking towards a lockout. + await chargeGuess(c.req.raw); + return c.text('That code is not valid any more. Ask the app for a new one.', 400); + } + + const authorization: AuthorizationState = { + response_type: 'device_code', + client_id: found.clientID, + device_code: found.deviceCodeHash + } as AuthorizationState; + await auth.set(c, 'authorization', ttlDevice, authorization); + + const provider = c.req.query('provider'); + if (provider) return c.redirect(`/${provider}/authorize`); + const providers = Object.keys(input.providers); + if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`); + return auth.forward( + c, + await select()( + Object.fromEntries( + Object.entries(input.providers).map(([key, value]) => [key, value.type]) + ), + c.req.raw + ) + ); + }); + + // The step that actually authorizes, and the reason there is one. + // + // Anybody at all can ask for a device code and be handed a link with the + // user code already filled in. If following that link and signing in were + // enough, then sending it to somebody would be enough: they would sign in + // to what looks like an ordinary prompt, and whoever kept the device code + // would poll and collect their tokens. What stops that is not the sign-in, + // which the victim performs perfectly well — it is being shown the code and + // the program asking, and having to say yes to *that*. + // + // A POST, because it changes something. Carrying a value from the cookie, + // so another site cannot post it on the person's behalf. + app.post('/device/confirm', async (c) => { + const confirmation = (await auth.get(c, 'device_confirm')) as DeviceConfirmation | undefined; + if (!confirmation) { + return c.text('That sign-in request has expired. Start it again from the app.', 400); + } + await auth.unset(c, 'device_confirm'); + + const form = await c.req.formData().catch(() => null); + const csrf = form?.get('csrf')?.toString() ?? ''; + if (!timingSafeCompare(confirmation.csrf, csrf)) { + return c.text('That form was not the one we sent. Start again from the app.', 400); + } + + if (form?.get('action')?.toString() === 'deny') { + await deviceStore.deny(confirmation.deviceCode); + return c.text('That sign-in request was refused. You can close this page.'); + } + + // The store decides, not this code. If a refusal got here first the + // answer is already given and an approval must not overwrite it. + const approved = await deviceStore.approve(confirmation.deviceCode, confirmation.subject); + if (!approved) { + return c.text('That sign-in request has already been answered.', 400); + } + return c.text('You are signed in. You can close this page and go back to the app.'); + }); + + app.get('/authorize', async (c) => { const provider = c.req.query('provider'); const response_type = c.req.query('response_type'); const redirect_uri = c.req.query('redirect_uri'); @@ -1125,6 +1666,13 @@ export function issuer< return auth.forward(c, await error(err, c.req.raw)); } const authorization = await getAuthorization(c); + // A device grant has no redirect to carry the error back on, so it is + // said here instead. Without this the reporting path throws on a URL + // built from `undefined` and the real failure is never printed. + if (!authorization.redirect_uri) { + const oauth = err instanceof OauthError ? err : new OauthError('server_error', err.message); + return c.text(oauth.description || oauth.error, 400); + } const url = new URL(authorization.redirect_uri); const oauth = err instanceof OauthError ? err : new OauthError('server_error', err.message); url.searchParams.set('error', oauth.error); diff --git a/packages/auth/src/provider/code.ts b/packages/auth/src/provider/code.ts index 9464dc3f..7b3a2dd5 100644 --- a/packages/auth/src/provider/code.ts +++ b/packages/auth/src/provider/code.ts @@ -54,7 +54,8 @@ */ import { Context } from 'hono'; -import { generateUnbiasedDigits, timingSafeCompare } from '../random.js'; +import { generateUnbiasedDigits, generateUnbiasedString, timingSafeCompare } from '../random.js'; +import { Storage } from '../storage/storage.js'; import { Provider } from './provider.js'; export interface CodeProviderConfig< @@ -66,6 +67,58 @@ export interface CodeProviderConfig< * @default 6 */ length?: number; + /** + * How long a code stays usable, in seconds. + * + * A pin is six digits, which is a small space, and the only thing keeping + * it small enough to type is that it does not have to last. A code that is + * still good tomorrow is a password with a million possible values. + * + * @default 600 + */ + ttl?: number; + /** + * How many wrong guesses a code survives. + * + * Counted where the person asking cannot reach it, which is the whole + * point: the code itself travels in an encrypted cookie the caller holds, + * so a counter kept alongside it would be a counter they could reset by + * replaying an older copy. Starting over is allowed and costs them a fresh + * code — sent to the mailbox they are trying to break into, where somebody + * notices. + * + * @default 5 + */ + maxAttempts?: number; + /** + * How many codes may be sent to one claim inside {@link sendWindow}. + * + * Counted against the mailbox and not against the browser asking. A budget + * held per sign-in attempt bounds nothing: the caller chooses how many + * attempts to start, and starting a new one costs them a discarded cookie. + * The thing being protected is the address, so the address is what carries + * the count. + * + * @default 5 + */ + maxSends?: number; + /** + * The window {@link maxSends} is counted over, in seconds. + * + * @default 3600 + */ + sendWindow?: number; + /** + * Seconds between one code and the next for the same claim. + * + * Without this, `resend` is an open relay pointed at anybody's mailbox: the + * address is not the caller's own and nothing asks them to prove otherwise, + * so the send button is a way to mail a stranger as fast as requests go + * out. + * + * @default 30 + */ + resendInterval?: number; /** * The request handler to generate the UI for the code flow. * @@ -116,6 +169,14 @@ export type CodeProviderState = resend?: boolean; code: string; claims: Record; + /** + * Names the server-side record holding this code's remaining + * guesses. Regenerated with every code, so a caller who rolls back + * to an older cookie rolls back to a code that is no longer live. + */ + flow: string; + /** When the code stops being accepted, in ms. */ + expires: number; }; /** @@ -134,16 +195,51 @@ export type CodeProviderError = type: 'invalid_claim'; key: string; value: string; + } + /** Too many guesses, or codes asked for too quickly. */ + | { + type: 'rate_limit'; }; +/** Nothing a person reads, so the whole alphabet is available. */ +const FLOW_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + export function CodeProvider = Record>( config: CodeProviderConfig ): Provider<{ claims: Claims }> { const length = config.length || 6; + const ttl = config.ttl ?? 60 * 10; + const maxAttempts = config.maxAttempts ?? 5; + const maxSends = config.maxSends ?? 5; + const sendWindow = config.sendWindow ?? 60 * 60; + const resendInterval = config.resendInterval ?? 30; + function generate() { return generateUnbiasedDigits(length); } + /** Where a flow's remaining guesses live, on the server. */ + function attemptKey(flow: string) { + return ['oauth:code:flow', flow]; + } + + /** + * Where the last send to one claim is remembered. + * + * Keyed by the claim and not by the caller, because the mailbox is what is + * being protected and the caller is whoever is pointing at it. Two people + * asking for a code for one address in the same minute is the case this is + * for, and it is the same case whether they are the same person or not. + */ + function claimKey(claims: Record) { + const flattened = Object.entries(claims) + .filter(([key]) => key !== 'action') + .map(([key, value]) => `${key}=${String(value).trim().toLowerCase()}`) + .sort() + .join('&'); + return ['oauth:code:claim', flattened]; + } + return { type: 'code', init(routes, ctx) { @@ -153,10 +249,14 @@ export function CodeProvider = Record(c, 'provider', 60 * 60 * 24, next); + // The cookie lives exactly as long as the code inside it. + // Twenty-four hours, which is what this was, made a six-digit + // pin usable for a day. + await ctx.set(c, 'provider', ttl, next); const resp = ctx.forward(c, await config.request(c.req.raw, next, fd, err)); return resp; } + routes.get('/authorize', async (c) => { const resp = await transition(c, { type: 'start' @@ -165,7 +265,6 @@ export function CodeProvider = Record { - const code = generate(); const fd = await c.req.formData(); const state = await ctx.get(c, 'provider'); const action = fd.get('action')?.toString(); @@ -173,22 +272,94 @@ export function CodeProvider = Record( + ctx.storage, + claimKey(claims) + ); + const open = sent && now - sent.since < sendWindow * 1000; + if (sent && now - sent.at < resendInterval * 1000) { + return transition(c, state ?? { type: 'start' }, fd, { type: 'rate_limit' }); + } + if (open && sent.count >= maxSends) { + return transition(c, state ?? { type: 'start' }, fd, { type: 'rate_limit' }); + } + + const code = generate(); const err = await config.sendCode(claims, code); if (err) return transition(c, { type: 'start' }, fd, err); + + // The code that was live until a moment ago stops being + // live now. Leaving it usable would mean each resend added + // a working code and another budget of guesses to spend on + // it, so asking for a new code would be how you bought more + // chances at the old one. + if (state?.type === 'code') { + await Storage.remove(ctx.storage, attemptKey(state.flow)); + } + + // A new code means a new flow, which means a fresh budget + // of guesses — and, more to the point, that the budget + // attached to the previous code is now unreachable rather + // than reset. + const flow = generateUnbiasedString(FLOW_ALPHABET, 32); + await Storage.set(ctx.storage, attemptKey(flow), { attempts: 0 }, ttl); + await Storage.set( + ctx.storage, + claimKey(claims), + { + at: now, + count: open ? sent.count + 1 : 1, + since: open ? sent.since : now + }, + sendWindow + ); + return transition( c, { type: 'code', resend: action === 'resend', claims, - code + code, + flow, + expires: Date.now() + ttl * 1000 }, fd ); } - if (fd.get('action')?.toString() === 'verify' && state.type === 'code') { - const fd = await c.req.formData(); + if (action === 'verify' && state?.type === 'code') { + if (state.expires <= Date.now()) { + await ctx.unset(c, 'provider'); + return transition(c, { type: 'start' }, fd, { type: 'invalid_code' }); + } + + // Counted before the comparison, so a guess costs whether or + // not it is right. Counted on the server, so the caller + // holding the cookie cannot wind it back. + const record = await Storage.get<{ attempts: number }>( + ctx.storage, + attemptKey(state.flow) + ); + if (!record || record.attempts >= maxAttempts) { + await ctx.unset(c, 'provider'); + await Storage.remove(ctx.storage, attemptKey(state.flow)); + return transition(c, { type: 'start' }, fd, { type: 'rate_limit' }); + } + await Storage.set( + ctx.storage, + attemptKey(state.flow), + { ...record, attempts: record.attempts + 1 }, + Math.max(1, Math.ceil((state.expires - Date.now()) / 1000)) + ); + const compare = fd.get('code')?.toString(); if (!state.code || !compare || !timingSafeCompare(state.code, compare)) { return transition( @@ -201,12 +372,18 @@ export function CodeProvider = Record
{error?.type === 'invalid_claim' && } + {error?.type === 'rate_limit' && } {error?.type === 'invalid_code' && } + {error?.type === 'rate_limit' && } {state.type === 'code' && ( true, + providers: { + code: CodeProvider({ + maxAttempts: 3, + maxSends: 2, + sendWindow: 3600, + resendInterval: 0, + request: async (_req, _state, _form, error) => + new Response(JSON.stringify({ error: error?.type ?? null }), { + status: 200, + headers: { 'content-type': 'application/json' } + }), + sendCode: async (claims, code) => { + if (!claims.email?.includes('@')) { + return { type: 'invalid_claim', key: 'email', value: claims.email ?? '' }; + } + sent.push(code); + } + }) + }, + success: async (ctx, value) => ctx.subject('user', { email: (value as any).claims.email }) +}); + +const ORIGIN = 'https://auth.example.com'; + +function jar() { + const cookies = new Map(); + return { + absorb(response: Response) { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const index = pair!.indexOf('='); + cookies.set(pair!.slice(0, index), pair!.slice(index + 1)); + } + }, + header() { + return [...cookies].map(([name, value]) => `${name}=${value}`).join('; '); + } + }; +} + +async function post(cookies: ReturnType, body: Record) { + const response = await auth.request(`${ORIGIN}/code/authorize`, { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body) + }); + cookies.absorb(response); + return response; +} + +/** + * Begin an authorization the way a client does, so success has somewhere to go. + * + * Without this there is no authorization state and a correct code produces + * tokens rather than the redirect a browser flow ends in — which would make + * "did this sign in?" a different question in the test than in the product. + */ +async function begin() { + const cookies = jar(); + const url = new URL(`${ORIGIN}/authorize`); + url.searchParams.set('client_id', 'test'); + url.searchParams.set('redirect_uri', 'https://client.example.com/callback'); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('provider', 'code'); + cookies.absorb(await auth.request(url.toString())); + return cookies; +} + +/** Start a sign-in and ask for a code, coming back with the cookies and the code. */ +async function ask(email: string) { + const cookies = await begin(); + await post(cookies, { action: 'request', email }); + return { cookies, code: sent.at(-1)! }; +} + +/** What the stub UI reported, so a test can name the error rather than a status. */ +async function errorOf(response: Response) { + return ((await response.clone().json()) as { error: string | null }).error; +} + +beforeEach(() => { + sent = []; +}); + +describe('signing in with a code', () => { + test('the right code signs you in', async () => { + const { cookies, code } = await ask('right@example.com'); + const response = await post(cookies, { action: 'verify', code }); + expect(response.status).toBe(302); + }); + + test('a wrong code is refused and says so', async () => { + const { cookies, code } = await ask('wrong@example.com'); + const response = await post(cookies, { action: 'verify', code: code === '000000' ? '111111' : '000000' }); + expect(response.status).toBe(200); + expect(await errorOf(response)).toBe('invalid_code'); + }); +}); + +/** + * The attack a six-digit pin invites, and what stops it. + * + * The code travels in an encrypted cookie the caller holds, and the caller is + * not necessarily the person the code was mailed to — anybody can type somebody + * else's address into the first screen. So the only thing between an attacker + * and an account is how many times they may guess, and that number has to be + * kept somewhere they cannot reach. + */ +describe('guessing the code', () => { + test('runs out of guesses long before it runs out of codes', async () => { + const { cookies, code } = await ask('budget@example.com'); + const wrong = code === '000000' ? '111111' : '000000'; + + expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( + 'invalid_code' + ); + expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( + 'invalid_code' + ); + expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( + 'invalid_code' + ); + + // Out of budget. The next guess is refused whether or not it is right. + expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( + 'rate_limit' + ); + }); + + test('the real code stops working once the guesses are spent', async () => { + const { cookies, code } = await ask('spent@example.com'); + const wrong = code === '000000' ? '111111' : '000000'; + for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong }); + + const response = await post(cookies, { action: 'verify', code }); + expect(response.status).toBe(200); + expect(await errorOf(response)).toBe('rate_limit'); + }); + + // The counter would be worthless if it lived where the guesser does. This + // replays the cookie from before any guess was made, which is the cheapest + // way to wind back anything held in one. + test('replaying an earlier cookie does not hand back the spent guesses', async () => { + const { cookies, code } = await ask('replay@example.com'); + const untouched = cookies.header(); + const wrong = code === '000000' ? '111111' : '000000'; + for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong }); + + const replayed = await auth.request(`${ORIGIN}/code/authorize`, { + method: 'POST', + headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'verify', code: wrong }) + }); + expect(await errorOf(replayed)).toBe('rate_limit'); + }); + + test('a code is spent when it is used, so its guesses do not carry over', async () => { + const { cookies, code } = await ask('once@example.com'); + expect((await post(cookies, { action: 'verify', code })).status).toBe(302); + + const again = await post(cookies, { action: 'verify', code }); + expect(again.status).toBe(200); + }); +}); + +describe('asking for codes', () => { + test('a fresh code comes with a fresh budget of guesses', async () => { + const first = await ask('fresh-a@example.com'); + const wrong = '000000' === first.code ? '111111' : '000000'; + for (let i = 0; i < 3; i++) await post(first.cookies, { action: 'verify', code: wrong }); + expect(await errorOf(await post(first.cookies, { action: 'verify', code: wrong }))).toBe( + 'rate_limit' + ); + + // Starting over is allowed. It costs a code sent to the mailbox being + // aimed at, which is where somebody would notice. + const second = await ask('fresh-b@example.com'); + expect(second.code).not.toBe(first.code); + expect((await post(second.cookies, { action: 'verify', code: second.code })).status).toBe(302); + }); + + test('a mailbox cannot be sent codes forever', async () => { + const email = 'flood@example.com'; + const { cookies } = await ask(email); + expect(await errorOf(await post(cookies, { action: 'resend', email }))).toBe(null); + expect(await errorOf(await post(cookies, { action: 'resend', email }))).toBe('rate_limit'); + expect(sent).toHaveLength(2); + }); + + // The budget was once held per sign-in attempt, which bounded nothing: the + // caller decides how many attempts to start, and starting one costs a + // discarded cookie. Both of these walk around a per-attempt budget and land + // on the mailbox anyway, which is why the count lives there. + test('replaying an earlier cookie does not buy more codes', async () => { + const email = 'replay-send@example.com'; + const { cookies } = await ask(email); + const untouched = cookies.header(); + await post(cookies, { action: 'resend', email }); + expect(sent).toHaveLength(2); + + const replayed = await auth.request(`${ORIGIN}/code/authorize`, { + method: 'POST', + headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'resend', email }) + }); + expect(await errorOf(replayed)).toBe('rate_limit'); + expect(sent).toHaveLength(2); + }); + + test('starting over does not buy more codes either', async () => { + const email = 'restart-send@example.com'; + await ask(email); + await ask(email); + expect(sent).toHaveLength(2); + + const third = await begin(); + expect(await errorOf(await post(third, { action: 'request', email }))).toBe('rate_limit'); + expect(sent).toHaveLength(2); + }); + + // Each resend used to leave the code before it live, with a budget of + // guesses of its own. Five resends meant five working codes and five times + // the chances, so asking for a new code was how you bought more tries at + // the old one. + test('a resend retires the code before it', async () => { + const email = 'retire@example.com'; + const { cookies, code: first } = await ask(email); + const untouched = cookies.header(); + await post(cookies, { action: 'resend', email }); + expect(sent.at(-1)).not.toBe(first); + + const withOldCode = await auth.request(`${ORIGIN}/code/authorize`, { + method: 'POST', + headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'verify', code: first }) + }); + expect(withOldCode.status).toBe(200); + expect(await errorOf(withOldCode)).toBe('rate_limit'); + }); + + test('a bad address still gets told it is a bad address', async () => { + const cookies = await begin(); + const response = await post(cookies, { action: 'request', email: 'not-an-address' }); + expect(await errorOf(response)).toBe('invalid_claim'); + expect(sent).toHaveLength(0); + }); +}); diff --git a/packages/auth/test/device.test.ts b/packages/auth/test/device.test.ts new file mode 100644 index 00000000..b559672f --- /dev/null +++ b/packages/auth/test/device.test.ts @@ -0,0 +1,488 @@ +import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; + +import { object, string } from 'valibot'; + +import { hashDeviceCode, MemoryDeviceStore } from '../src/device.js'; +import { issuer } from '../src/issuer.js'; +import { MemoryStorage } from '../src/storage/memory.js'; +import { createSubjects } from '../src/subject.js'; + +const subjects = createSubjects({ + user: object({ + userID: string() + }) +}); + +const deviceStore = MemoryDeviceStore(); + +const auth = issuer({ + storage: MemoryStorage(), + deviceStore, + subjects, + allow: async () => true, + allowDeviceClient: async (clientID) => clientID !== 'banned', + deviceVerification: { guessLimit: 3, guessWindow: 60 }, + providers: { + dummy: { + type: 'dummy', + init(route, ctx) { + route.get('/authorize', async (c) => { + return ctx.success(c, { email: 'foo@bar.com' }); + }); + } + } + }, + success: async (ctx) => ctx.subject('user', { userID: '123' }) +}); + +const ORIGIN = 'https://auth.example.com'; + +/** Two cookies are in play across this flow, and `get` returns only the first. */ +function jar() { + const cookies = new Map(); + return { + absorb(response: Response) { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const index = pair!.indexOf('='); + cookies.set(pair!.slice(0, index), pair!.slice(index + 1)); + } + }, + header() { + return [...cookies].map(([name, value]) => `${name}=${value}`).join('; '); + } + }; +} + +async function begin(clientID = 'desktop') { + const response = await auth.request(`${ORIGIN}/device/authorize`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: clientID }) + }); + return { + status: response.status, + body: (await response.json()) as any + }; +} + +async function started(clientID = 'desktop') { + const response = await begin(clientID); + expect(response.status).toBe(200); + return response.body as { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; + }; +} + +async function poll(deviceCode: string, clientID = 'desktop') { + const response = await auth.request(`${ORIGIN}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: clientID + }) + }); + return { status: response.status, body: (await response.json()) as any }; +} + +/** + * Walk the browser half as far as the question, and stop there. + * + * Returns the confirmation page and the cookies that go with it, so a test can + * assert what has and has not happened at the moment somebody has signed in + * but not yet said yes. + */ +async function signInAndReachConfirmation(userCode: string) { + const cookies = jar(); + const entered = await auth.request(`${ORIGIN}/device?user_code=${encodeURIComponent(userCode)}`); + expect(entered.status).toBe(302); + cookies.absorb(entered); + + const asked = await auth.request(new URL(entered.headers.get('location')!, ORIGIN).toString(), { + headers: { cookie: cookies.header() } + }); + cookies.absorb(asked); + const html = await asked.text(); + return { status: asked.status, html, cookies }; +} + +/** The whole browser half, ending in an answer. */ +async function answer(userCode: string, action: 'approve' | 'deny') { + const { html, cookies, status } = await signInAndReachConfirmation(userCode); + expect(status).toBe(200); + const csrf = /name="csrf" value="([^"]+)"/.exec(html)?.[1]; + expect(csrf).toBeTruthy(); + + return auth.request(`${ORIGIN}/device/confirm`, { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ csrf: csrf!, action }) + }); +} + +beforeEach(() => setSystemTime(new Date('2026-01-01T00:00:00Z'))); +afterEach(() => setSystemTime()); + +describe('device authorization request', () => { + test('answers with everything the polling client needs', async () => { + const grant = await started(); + + expect(grant.device_code).toMatch(/.+/); + // Eight characters, so the client's four-and-four chunking reads + // evenly when a person says it out loud. + expect(grant.user_code).toMatch(/^[A-Z0-9]{8}$/); + expect(grant.verification_uri).toBe(`${ORIGIN}/device`); + expect(grant.verification_uri_complete).toContain(grant.user_code); + expect(grant.interval).toBeGreaterThanOrEqual(1); + expect(grant.expires_in).toBeGreaterThan(grant.interval); + }); + + test('two requests do not collide', async () => { + const a = await started(); + const b = await started(); + expect(a.device_code).not.toBe(b.device_code); + expect(a.user_code).not.toBe(b.user_code); + }); + + test('the metadata document advertises the endpoint and the grant', async () => { + const response = await auth.request(`${ORIGIN}/.well-known/oauth-authorization-server`); + const body: any = await response.json(); + expect(body.device_authorization_endpoint).toBe(`${ORIGIN}/device/authorize`); + expect(body.grant_types_supported).toContain('urn:ietf:params:oauth:grant-type:device_code'); + }); + + test('a client the issuer does not know is refused a grant', async () => { + const refused = await begin('banned'); + expect(refused.status).toBe(400); + expect(refused.body.error).toBe('invalid_client'); + }); + + // The endpoint hands the code back exactly once, in its answer. What is + // kept is a hash, so reading the store is not enough to redeem anything. + test('the code the client is given is not the value that is stored', async () => { + const grant = await started(); + expect(await deviceStore.byDeviceCode(grant.device_code)).toBeNull(); + expect(await deviceStore.byDeviceCode(await hashDeviceCode(grant.device_code))).not.toBeNull(); + }); +}); + +describe('polling', () => { + test('an unapproved code is pending', async () => { + const grant = await started(); + const first = await poll(grant.device_code); + expect(first.status).toBe(400); + expect(first.body.error).toBe('authorization_pending'); + }); + + test('polling faster than the interval earns slow_down, and widens it', async () => { + const grant = await started(); + await poll(grant.device_code); + + const tooSoon = await poll(grant.device_code); + expect(tooSoon.body.error).toBe('slow_down'); + + // The interval the client is told to use grows, per RFC 8628 §3.5, so + // a client that ignores the first warning is not merely told again. + setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000)); + const stillTooSoon = await poll(grant.device_code); + expect(stillTooSoon.body.error).toBe('slow_down'); + + setSystemTime(new Date(Date.now() + (grant.interval + 6) * 1000)); + const patient = await poll(grant.device_code); + expect(patient.body.error).toBe('authorization_pending'); + }); + + test('an unknown device code is not treated as pending', async () => { + const response = await poll('not-a-device-code'); + expect(response.status).toBe(400); + expect(response.body.error).toBe('expired_token'); + }); + + test('an expired code says so instead of pending forever', async () => { + const grant = await started(); + setSystemTime(new Date(Date.now() + (grant.expires_in + 60) * 1000)); + const response = await poll(grant.device_code); + expect(response.body.error).toBe('expired_token'); + }); + + test('a code belongs to the client that asked for it', async () => { + const grant = await started(); + const response = await poll(grant.device_code, 'somebody-else'); + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_grant'); + }); + + test('a poll with no client_id is not a poll', async () => { + const grant = await started(); + const response = await auth.request(`${ORIGIN}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: grant.device_code + }) + }); + expect(response.status).toBe(400); + expect(((await response.json()) as any).error).toBe('invalid_request'); + }); +}); + +describe('approval', () => { + test('approving hands the next poll a token', async () => { + const grant = await started(); + const confirmed = await answer(grant.user_code, 'approve'); + expect(confirmed.status).toBe(200); + + setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000)); + const response = await poll(grant.device_code); + expect(response.status).toBe(200); + expect(response.body.access_token).toMatch(/.+/); + expect(response.body.refresh_token).toMatch(/.+/); + }); + + test('a device code is redeemable once', async () => { + const grant = await started(); + await answer(grant.user_code, 'approve'); + + setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000)); + expect((await poll(grant.device_code)).status).toBe(200); + setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000)); + expect((await poll(grant.device_code)).body.error).toBe('expired_token'); + }); + + test('the user code is accepted in the form a person reads aloud', async () => { + const grant = await started(); + const chunked = `${grant.user_code.slice(0, 4)}-${grant.user_code.slice(4)}`; + await answer(chunked.toLowerCase(), 'approve'); + + setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000)); + expect((await poll(grant.device_code)).status).toBe(200); + }); + + test('an unknown user code does not start a provider flow', async () => { + // Its own address, so the budget it spends is its own — the shared + // bucket for callers with no address is asserted on further down. + const response = await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZZ`, { + headers: { 'cf-connecting-ip': '198.51.100.9' } + }); + expect(response.status).toBe(400); + }); + + test('a refusal is final, and says so', async () => { + const grant = await started(); + const denied = await answer(grant.user_code, 'deny'); + expect(denied.status).toBe(200); + + const response = await poll(grant.device_code); + expect(response.body.error).toBe('access_denied'); + }); +}); + +/** + * The attack this flow exists to stop, and the properties that stop it. + * + * Anyone can ask for a device code and be handed a link with the user code + * already in it. Send that link to somebody, keep the device code, and if + * their signing in were enough you would be holding their tokens. It is not + * enough, and these say why. + */ +describe('a code somebody else started', () => { + test('following the link and signing in approves nothing', async () => { + const grant = await started(); + + const reached = await signInAndReachConfirmation(grant.user_code); + expect(reached.status).toBe(200); + + // The victim has signed in. The attacker polls. There is still no + // answer, because being signed in is not the same as having agreed. + const response = await poll(grant.device_code); + expect(response.status).toBe(400); + expect(response.body.error).toBe('authorization_pending'); + }); + + test('the page shows the code, so it can be compared with the device', async () => { + const grant = await started(); + const reached = await signInAndReachConfirmation(grant.user_code); + + expect(reached.html).toContain(grant.user_code.slice(0, 4)); + expect(reached.html).toContain(grant.user_code.slice(4)); + expect(reached.html).toContain('desktop'); + }); + + test('a confirmation posted without the value from the cookie is refused', async () => { + const grant = await started(); + const { cookies } = await signInAndReachConfirmation(grant.user_code); + + const forged = await auth.request(`${ORIGIN}/device/confirm`, { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ csrf: 'guessed', action: 'approve' }) + }); + expect(forged.status).toBe(400); + expect((await poll(grant.device_code)).body.error).toBe('authorization_pending'); + }); + + test('confirming with no cookie at all authorizes nothing', async () => { + const grant = await started(); + await signInAndReachConfirmation(grant.user_code); + + const bare = await auth.request(`${ORIGIN}/device/confirm`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ csrf: 'anything', action: 'approve' }) + }); + expect(bare.status).toBe(400); + expect((await poll(grant.device_code)).body.error).toBe('authorization_pending'); + }); +}); + +/** + * Two things touching one grant at the same time. + * + * The browser and the polling client are always racing; the question is only + * whether the loser can undo the winner. Held here against the in-memory + * store, whose methods do not suspend part way through — a store that talks to + * a database has to give the same guarantees for itself. + */ +describe('when both halves move at once', () => { + test('a poll cannot undo an approval that landed while it was in flight', async () => { + const grant = await started(); + const hash = await hashDeviceCode(grant.device_code); + + // A poll reads a pending grant, the browser approves, and then the + // poll writes its bookkeeping. What it writes must not include the + // status it read. + const stale = await deviceStore.byDeviceCode(hash); + expect(stale!.status).toBe('pending'); + await answer(grant.user_code, 'approve'); + await deviceStore.recordPoll(hash, Date.now(), stale!.interval); + + expect((await deviceStore.byDeviceCode(hash))!.status).toBe('approved'); + setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000)); + expect((await poll(grant.device_code)).status).toBe(200); + }); + + test('an approval cannot overwrite a refusal that got there first', async () => { + const grant = await started(); + const hash = await hashDeviceCode(grant.device_code); + + // Both halves reach the question; one presses Deny and one presses + // Approve. Whichever arrives second is answering something that has + // already been answered. + const first = await signInAndReachConfirmation(grant.user_code); + const second = await signInAndReachConfirmation(grant.user_code); + const csrfOf = (html: string) => /name="csrf" value="([^"]+)"/.exec(html)![1]!; + + const denied = await auth.request(`${ORIGIN}/device/confirm`, { + method: 'POST', + headers: { + cookie: first.cookies.header(), + 'content-type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ csrf: csrfOf(first.html), action: 'deny' }) + }); + expect(denied.status).toBe(200); + + const late = await auth.request(`${ORIGIN}/device/confirm`, { + method: 'POST', + headers: { + cookie: second.cookies.header(), + 'content-type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ csrf: csrfOf(second.html), action: 'approve' }) + }); + expect(late.status).toBe(400); + + expect((await deviceStore.byDeviceCode(hash))!.status).toBe('denied'); + expect((await poll(grant.device_code)).body.error).toBe('access_denied'); + }); + + test('two polls racing one approved grant serve one of them', async () => { + const grant = await started(); + await answer(grant.user_code, 'approve'); + const hash = await hashDeviceCode(grant.device_code); + + const [a, b] = await Promise.all([ + deviceStore.consume(hash, 'desktop'), + deviceStore.consume(hash, 'desktop') + ]); + expect([a, b].filter(Boolean)).toHaveLength(1); + }); +}); + +/** + * Working through the code space, and what stops it. + * + * A user code is eight characters from an alphabet of twenty-five, so guessing + * one is not cheap — but it is a fixed cost, and the endpoint that checks them + * had no opinion about how often you asked. RFC 8628 §5.2 asks for one. + */ +describe('guessing at user codes', () => { + /** A caller with an address of its own, so budgets do not run together. */ + function from(address: string) { + return (userCode: string) => + auth.request(`${ORIGIN}/device?user_code=${encodeURIComponent(userCode)}`, { + headers: { 'cf-connecting-ip': address } + }); + } + + test('a caller runs out of tries', async () => { + const tries = from('198.51.100.1'); + + expect((await tries('ZZZZZZZZ')).status).toBe(400); + expect((await tries('ZZZZZZZY')).status).toBe(400); + expect((await tries('ZZZZZZZX')).status).toBe(400); + expect((await tries('ZZZZZZZW')).status).toBe(429); + }); + + test('one caller running out does not lock out another', async () => { + const noisy = from('198.51.100.2'); + for (let i = 0; i < 4; i++) await noisy(`ZZZZZZZ${'ABCD'[i]}`); + expect((await noisy('ZZZZZZZZ')).status).toBe(429); + + const grant = await started(); + const quiet = await auth.request( + `${ORIGIN}/device?user_code=${encodeURIComponent(grant.user_code)}`, + { headers: { 'cf-connecting-ip': '198.51.100.3' } } + ); + expect(quiet.status).toBe(302); + }); + + test('getting one right is not charged for', async () => { + const address = '198.51.100.4'; + const tries = from(address); + expect((await tries('ZZZZZZZZ')).status).toBe(400); + expect((await tries('ZZZZZZZY')).status).toBe(400); + + // Two wrong out of a budget of three. A correct code in between must + // not be what tips the next wrong one over. + const grant = await started(); + const right = await auth.request( + `${ORIGIN}/device?user_code=${encodeURIComponent(grant.user_code)}`, + { headers: { 'cf-connecting-ip': address } } + ); + expect(right.status).toBe(302); + + expect((await tries('ZZZZZZZX')).status).toBe(400); + expect((await tries('ZZZZZZZW')).status).toBe(429); + }); + + // A caller who strips the headers that say where they are lands in one + // shared bucket. That is deliberate: it makes hiding cost a smaller budget + // rather than buying an unlimited one. + test('a caller with no address still has a budget', async () => { + for (let i = 0; i < 3; i++) { + expect((await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZ${'ABC'[i]}`)).status).toBe( + 400 + ); + } + expect((await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZD`)).status).toBe(429); + }); +}); diff --git a/packages/core/migrations/0009_email_is_the_root_identity.sql b/packages/core/migrations/0009_email_is_the_root_identity.sql new file mode 100644 index 00000000..dbffc35c --- /dev/null +++ b/packages/core/migrations/0009_email_is_the_root_identity.sql @@ -0,0 +1,66 @@ +-- One address, one account. ref(d-0048) +-- +-- Runs against a database in which every user was created by signing in with a +-- gaming account, which means most rows have no email at all and nothing has +-- ever stopped two rows from sharing one. Three consequences, in order: +-- +-- 1. The column is normalized first. The address is about to become an +-- identity, so `Ada@Example.com ` and `ada@example.com` have to stop +-- being two of them. Trimming and lower-casing happens here once; the +-- code that writes the column does the same thing on the way in. +-- 2. Duplicates are separated before the index exists, because +-- `CREATE UNIQUE INDEX` fails outright on the first pair it meets, and a +-- migration that dies half way through is worse than one that decides. +-- 3. The index is partial. A null email is not a value, so the accounts that +-- have none do not collide with each other — which is the only reason a +-- unique index can land on these rows at all. + +UPDATE "user" +SET "email" = lower(btrim("email")) +WHERE "email" IS NOT NULL + AND "email" <> lower(btrim("email"));--> statement-breakpoint + +-- Where two accounts claim one address, the older keeps it. +-- +-- Nothing is deleted: both accounts survive, with their games, their hardware +-- and their team. What the newer one loses is the address, and `email_verified` +-- goes back to false to say so — the next sign-in asks for an address and the +-- person supplies one, which is a prompt rather than a loss. +-- +-- The older row wins because its address has been in use longest, so it is the +-- one a receipt or a reset was most likely sent to. `id` breaks a tie on +-- `time_created`, so the choice is total and re-running this changes nothing. +UPDATE "user" u +SET "email" = NULL, "email_verified" = false +WHERE u."email" IS NOT NULL + AND u."time_deleted" IS NULL + AND EXISTS ( + SELECT 1 FROM "user" older + WHERE older."email" = u."email" + AND older."time_deleted" IS NULL + AND (older."time_created", older."id") < (u."time_created", u."id") + );--> statement-breakpoint + +CREATE UNIQUE INDEX "user_email_unique" ON "user" USING btree ("email") WHERE email is not null and time_deleted is null;--> statement-breakpoint + +-- There is no constraint here for the cap on how many gaming accounts one +-- person may connect, and there cannot be one. +-- +-- A unique index makes a value unique; it cannot count the rows that share a +-- foreign key, so no index shape says "at most four of these". The cap is +-- enforced in application code, and a direct write to `linked_account` can +-- exceed it. This is written where the schema is read so that nobody looks for +-- the rule here, fails to find it, and concludes there is not one. Rows +-- already over the cap are left alone: the limit governs connecting another, +-- not keeping what is already connected. + +-- Below is not part of the above, and carries no reason of its own. +-- +-- It records which attempt holds a run: the agent generates an opaque value +-- per claim, the row remembers the first one to arrive, and every later write +-- has to present it. Nullable and unbackfilled, because a run nobody has +-- claimed genuinely has no holder, and never cleared, because a finished run +-- still has to say which attempt ran it. The endpoint that reads and writes it +-- arrives separately; it is here because a schema change has one owner at a +-- time. +ALTER TABLE "session" ADD COLUMN "claim_token" text; diff --git a/packages/core/migrations/0010_device_authorization_grant.sql b/packages/core/migrations/0010_device_authorization_grant.sql new file mode 100644 index 00000000..94d4020f --- /dev/null +++ b/packages/core/migrations/0010_device_authorization_grant.sql @@ -0,0 +1,39 @@ +-- A device authorization grant, while it is still in flight. +-- +-- Short-lived state that would sit happily in a cache, in a table anyway. The +-- reason is not durability. Each transition here has to happen exactly once +-- while two parties are touching the same row — a browser somebody is clicking +-- through, and a program on another machine polling every few seconds — and a +-- store that can only read and write whole records cannot promise that: the +-- poll reads, the browser approves, the poll writes back what it read, and the +-- approval is gone. Here, approving is one conditional update and redeeming is +-- one delete that returns what it deleted, so neither can undo the other. +-- +-- `device_code_hash` and not the code. The device code is the credential the +-- tokens are handed to, so what is kept is enough to recognise it and not +-- enough to present it. `user_code` is stored as written, because it is read +-- off one screen and typed into another by the person looking at both, and it +-- lives for minutes. +-- +-- Rows are swept when a new grant is created rather than on a schedule. A grant +-- lives ten minutes and that is the only statement that adds one, so the table +-- stays bounded by how many sign-ins are in flight. + +CREATE TYPE "public"."device_grant_status" AS ENUM('pending', 'approved', 'denied');--> statement-breakpoint +CREATE TABLE "device_grant" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "device_code_hash" text NOT NULL, + "user_code" text NOT NULL, + "client_id" text NOT NULL, + "status" "device_grant_status" DEFAULT 'pending' NOT NULL, + "poll_interval" integer NOT NULL, + "last_polled_at" timestamp with time zone, + "expires_at" timestamp with time zone NOT NULL, + "subject" jsonb +); +--> statement-breakpoint +CREATE UNIQUE INDEX "device_grant_device_code_unique" ON "device_grant" USING btree ("device_code_hash");--> statement-breakpoint +CREATE UNIQUE INDEX "device_grant_user_code_unique" ON "device_grant" USING btree ("user_code"); \ No newline at end of file diff --git a/packages/core/migrations/meta/0009_snapshot.json b/packages/core/migrations/meta/0009_snapshot.json new file mode 100644 index 00000000..9c9a5fac --- /dev/null +++ b/packages/core/migrations/meta/0009_snapshot.json @@ -0,0 +1,2322 @@ +{ + "id": "b26664d8-eebf-4563-9c3c-7e00e41b646b", + "prevId": "2c16a831-745e-4e1d-ad95-11aaa30e31f0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sm'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/migrations/meta/0010_snapshot.json b/packages/core/migrations/meta/0010_snapshot.json new file mode 100644 index 00000000..3699756f --- /dev/null +++ b/packages/core/migrations/meta/0010_snapshot.json @@ -0,0 +1,2451 @@ +{ + "id": "8915fb5b-8f0d-4f6f-a808-27b19bb4604a", + "prevId": "b26664d8-eebf-4563-9c3c-7e00e41b646b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sm'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index ffa36f48..57035969 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -64,6 +64,20 @@ "when": 1788547836146, "tag": "0008_session_one_active_run_per_box", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1788555252186, + "tag": "0009_email_is_the_root_identity", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1788590292860, + "tag": "0010_device_authorization_grant", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/script/verify-migration-0009.sh b/packages/core/script/verify-migration-0009.sh new file mode 100755 index 00000000..50f72669 --- /dev/null +++ b/packages/core/script/verify-migration-0009.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# +# Prove this migration against a database built to look like the live one, +# rather than against an empty schema. +# +# A migration that only ever runs on a database with no rows in it has +# demonstrated nothing: every statement here that could go wrong goes wrong +# because of what is already in the table. So this builds the awkward rows by +# hand — an account with no address, two accounts sharing one address in +# different cases, an account already over the connection cap, a soft-deleted +# row holding an address a live row also holds — applies every migration +# before this one, then applies this one and checks each of them individually. +# +# Usage: PGHOST=localhost PGPORT=5434 ./verify-migration-0009.sh +set -euo pipefail + +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5434}" +PGUSER="${PGUSER:-postgres}" +export PGPASSWORD="${PGPASSWORD:-postgres}" +DB="${DB:-nestri_mig_0009}" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATIONS="$HERE/../migrations" + +psql_admin() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -qtA "$@"; } +psql_db() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$DB" -qtA -v ON_ERROR_STOP=1 "$@"; } + +failures=0 +check() { # check + local got + got="$(psql_db -c "$3" | tr -d '[:space:]')" + if [ "$got" = "$2" ]; then + printf 'ok %s\n' "$1" + else + printf 'FAIL %s — expected %s, got %s\n' "$1" "$2" "$got" + failures=$((failures + 1)) + fi +} + +echo "== rebuilding $DB ==" +psql_admin -c "drop database if exists $DB" >/dev/null +psql_admin -c "create database $DB" >/dev/null + +echo "== applying everything before it ==" +for f in "$MIGRATIONS"/000[0-8]_*.sql; do + psql_db -f "$f" >/dev/null + printf ' %s\n' "$(basename "$f")" +done + +echo "== seeding rows the way the live database actually looks ==" +psql_db >/dev/null <<'SQL' +-- ids are char(30): a four-character prefix and 26 more. +-- A: made by a gaming sign-in, no address at all. The ordinary case today. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'no-email', null, false, now() - interval '10 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'steam', '76561100000000001'); + +-- B: has both, and the address is stored with the case and spacing a person typed. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'both', ' Ada@Example.COM ', true, now() - interval '9 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'steam', '76561100000000002'); + +-- C: one person, two gaming accounts. Nothing may touch either. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_cccccccccccccccccccccccccc', 'two-links', null, false, now() - interval '8 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_cc1ccccccccccccccccccccccc', 'usr_cccccccccccccccccccccccccc', 'steam', '76561100000000003'), + ('lac_cc2ccccccccccccccccccccccc', 'usr_cccccccccccccccccccccccccc', 'steam', '76561100000000004'); + +-- D and E: two accounts on one address, spelled differently. Nothing ever +-- stopped this, so a live database is entitled to contain it. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_dddddddddddddddddddddddddd', 'older-dup', 'grace@example.com', true, now() - interval '7 days'), + ('usr_eeeeeeeeeeeeeeeeeeeeeeeeee', 'newer-dup', 'GRACE@example.com', true, now() - interval '6 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_eeeeeeeeeeeeeeeeeeeeeeeeee', 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee', 'steam', '76561100000000005'); + +-- F: already over the cap the application is about to start enforcing. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_ffffffffffffffffffffffffff', 'over-cap', null, false, now() - interval '5 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_ff1fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000006'), + ('lac_ff2fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000007'), + ('lac_ff3fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000008'), + ('lac_ff4fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000009'), + ('lac_ff5fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000010'); + +-- G: a deleted account still holding an address a live account also holds. The +-- index has to tolerate this or the migration fails on a row nobody can see. +insert into "user" (id, name, email, email_verified, time_created, time_deleted) values + ('usr_gggggggggggggggggggggggggg', 'deleted-dup', 'grace@example.com', true, now() - interval '4 days', now()); + +-- A run in flight, so the new column lands on a row that already exists. +insert into team (id, name, slug, owner_id) values + ('tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'T', 't', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa'); +insert into team_member (id, team_id, user_id, role) values + ('mem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'owner'); +insert into machine (id, owner_user_id, team_id, label, secret_hash) values + ('mch_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'host', 'hash'); +insert into game (id, steam_app_id, name, slug) values + ('gam_aaaaaaaaaaaaaaaaaaaaaaaaaa', 730, 'G', 'g'); +insert into box (id, user_id, machine_id, label) values + ('box_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'mch_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'b'); +insert into "session" (id, box_id, game_id, linked_account_id, state) values + ('ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'box_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'gam_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'lac_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'live'); +SQL + +before_users="$(psql_db -c 'select count(*) from "user"')" +before_links="$(psql_db -c 'select count(*) from linked_account')" +echo " $before_users users, $before_links connected accounts" + +echo "== applying the migration under test ==" +psql_db -f "$MIGRATIONS/0009_email_is_the_root_identity.sql" >/dev/null +echo " 0009_email_is_the_root_identity.sql" + +echo "== checking ==" +check "no account was deleted" "$before_users" 'select count(*) from "user"' +check "no connection was deleted" "$before_links" 'select count(*) from linked_account' + +check "A: an account with no address is untouched" "t" \ + "select email is null and email_verified = false from \"user\" where id = 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa'" +check "A: its connected account survives" "1" \ + "select count(*) from linked_account where user_id = 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa'" + +check "B: the address is normalized in place" "ada@example.com" \ + "select email from \"user\" where id = 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb'" +check "B: it stays verified" "t" \ + "select email_verified from \"user\" where id = 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb'" + +check "C: two connected accounts are still two" "2" \ + "select count(*) from linked_account where user_id = 'usr_cccccccccccccccccccccccccc'" + +check "D: the older of the pair keeps the address" "grace@example.com" \ + "select email from \"user\" where id = 'usr_dddddddddddddddddddddddddd'" +check "D: and stays verified" "t" \ + "select email_verified from \"user\" where id = 'usr_dddddddddddddddddddddddddd'" +check "E: the newer one loses it and is asked again" "t" \ + "select email is null and email_verified = false from \"user\" where id = 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee'" +check "E: but keeps its account and its connection" "1" \ + "select count(*) from linked_account where user_id = 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee'" + +check "F: an account already over the cap is left alone" "5" \ + "select count(*) from linked_account where user_id = 'usr_ffffffffffffffffffffffffff'" + +check "G: a deleted row may keep a live row's address" "grace@example.com" \ + "select email from \"user\" where id = 'usr_gggggggggggggggggggggggggg'" + +check "the index exists" "1" \ + "select count(*) from pg_indexes where indexname = 'user_email_unique'" +# If the insert is allowed, the raise below is not a unique_violation, so it is +# not caught, and psql stops on it — which reads as a failure rather than a pass. +check "and a second live account cannot take a taken address" "refused" \ + "do \$\$ begin + insert into \"user\" (id, name, email) values ('usr_zzzzzzzzzzzzzzzzzzzzzzzzzz', 'z', 'grace@example.com'); + raise exception 'the index allowed a duplicate address'; + exception when unique_violation then null; + end \$\$; select 'refused'" + +check "the claim column is there" "1" \ + "select count(*) from information_schema.columns where table_name = 'session' and column_name = 'claim_token'" +check "the claim column is nullable" "YES" \ + "select is_nullable from information_schema.columns where table_name = 'session' and column_name = 'claim_token'" +check "the claim column has no default" "1" \ + "select count(*) from information_schema.columns where table_name = 'session' and column_name = 'claim_token' and column_default is null" +check "and nothing was backfilled into it" "1" \ + "select count(*) from \"session\" where claim_token is null" + +echo +if [ "$failures" -eq 0 ]; then + echo "all checks passed" +else + echo "$failures check(s) failed" + exit 1 +fi diff --git a/packages/core/src/auth/device-grant.sql.ts b/packages/core/src/auth/device-grant.sql.ts new file mode 100644 index 00000000..e534ed22 --- /dev/null +++ b/packages/core/src/auth/device-grant.sql.ts @@ -0,0 +1,62 @@ +import { integer, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; + +import { id, timestamps, utc } from '../db/types.js'; + +export const DeviceGrantStatusEnum = pgEnum('device_grant_status', [ + 'pending', + 'approved', + 'denied' +]); + +/** + * A device authorization grant, while it is still in flight. + * + * This is short-lived state that would sit happily in a cache, and it is in a + * table anyway. The reason is that every transition here has to happen exactly + * once while two parties are touching the row — a browser somebody is clicking + * through, and a program polling every few seconds — and a store that can only + * read and write whole records cannot promise that. Here, approving is one + * conditional update and redeeming is one delete that returns what it deleted, + * so the two cannot interleave into each other. + * + * `device_code_hash` and not the code: the code is the credential the tokens + * are handed to, so what is kept is enough to recognise it and not enough to + * present it. `user_code` is stored as written, because it is read off a screen + * by the person who is looking at it and lives for minutes. + */ +export const DeviceGrantTable = pgTable( + 'device_grant', + { + ...id, + ...timestamps, + + deviceCodeHash: text('device_code_hash').notNull(), + userCode: text('user_code').notNull(), + clientId: text('client_id').notNull(), + status: DeviceGrantStatusEnum('status').notNull().default('pending'), + + /** Seconds the client is currently being told to wait between polls. */ + pollInterval: integer('poll_interval').notNull(), + /** Null until a poll has been given a real answer. */ + lastPolledAt: utc('last_polled_at'), + expiresAt: utc('expires_at').notNull(), + + /** + * Who the grant turned out to be for, written when it is approved. + * + * Not the tokens. Those are minted when the waiting program redeems the + * code, so their lifetime starts when they are handed over and a grant + * nobody collects leaves no usable credential behind. + */ + subject: jsonb('subject').$type<{ + subject: string; + type: string; + properties: unknown; + ttl: { access: number; refresh: number }; + }>() + }, + (t) => [ + uniqueIndex('device_grant_device_code_unique').on(t.deviceCodeHash), + uniqueIndex('device_grant_user_code_unique').on(t.userCode) + ] +); diff --git a/packages/core/src/auth/device-grant.test.ts b/packages/core/src/auth/device-grant.test.ts new file mode 100644 index 00000000..e057585f --- /dev/null +++ b/packages/core/src/auth/device-grant.test.ts @@ -0,0 +1,201 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; + +import type { DeviceGrant, DeviceGrantSubject } from '@nestri/auth/device'; + +import { testDb } from '../db/test.js'; +import { PostgresDeviceStore } from './device-grant.js'; + +const sql = testDb(); +const store = PostgresDeviceStore(); + +const SUBJECT: DeviceGrantSubject = { + subject: 'user:usr_fixture', + type: 'user', + properties: { userID: 'usr_fixture' }, + ttl: { access: 60, refresh: 600 } +}; + +let counter = 0; +function hash(): string { + counter += 1; + return `device-grant-fixture-${counter}`.padEnd(64, '0'); +} + +function pending(overrides: Partial = {}): DeviceGrant { + const deviceCodeHash = overrides.deviceCodeHash ?? hash(); + return { + deviceCodeHash, + userCode: `UC${deviceCodeHash.slice(-6)}`, + clientID: 'desktop', + status: 'pending', + interval: 5, + lastPolled: 0, + expires: Date.now() + 600_000, + ...overrides + }; +} + +async function cleanup() { + await sql`delete from device_grant where device_code_hash like 'device-grant-fixture-%'`; +} + +beforeEach(cleanup); +afterAll(async () => { + await cleanup(); + await sql.end(); +}); + +describe('what the store remembers', () => { + test('a grant is findable by either code, and comes back as it went in', async () => { + const grant = pending(); + await store.create(grant); + + const byDevice = await store.byDeviceCode(grant.deviceCodeHash); + expect(byDevice).toMatchObject({ + deviceCodeHash: grant.deviceCodeHash, + userCode: grant.userCode, + clientID: 'desktop', + status: 'pending', + interval: 5, + lastPolled: 0 + }); + expect((await store.byUserCode(grant.userCode))?.deviceCodeHash).toBe(grant.deviceCodeHash); + }); + + test('creating a grant clears out the ones that aged out', async () => { + const stale = pending({ expires: Date.now() - 1000 }); + await store.create(stale); + await store.create(pending()); + + const rows = await sql` + select count(*)::int as n from device_grant where device_code_hash = ${stale.deviceCodeHash} + `; + expect(rows[0]!.n).toBe(0); + }); +}); + +/** + * The properties the flow is built on, asserted against a real database. + * + * Each of these is a claim that a transition happens once even though two + * parties are racing for it, and each is enforced by a `where` clause rather + * than by application code. That is exactly the sort of claim that reads as + * obviously true and is obviously false the moment the condition is dropped, so + * it is worth a test that would notice. + */ +describe('transitions that must happen once', () => { + test('a grant is approved once', async () => { + const grant = pending(); + await store.create(grant); + + expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(true); + expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(false); + }); + + test('an approval cannot overwrite a refusal', async () => { + const grant = pending(); + await store.create(grant); + + expect(await store.deny(grant.deviceCodeHash)).toBe(true); + expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(false); + expect((await store.byDeviceCode(grant.deviceCodeHash))?.status).toBe('denied'); + }); + + test('a refusal cannot overwrite an approval', async () => { + const grant = pending(); + await store.create(grant); + + expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(true); + expect(await store.deny(grant.deviceCodeHash)).toBe(false); + expect((await store.byDeviceCode(grant.deviceCodeHash))?.status).toBe('approved'); + }); + + test('several approvals arriving together settle on one', async () => { + const grant = pending(); + await store.create(grant); + + const results = await Promise.all( + Array.from({ length: 5 }, () => store.approve(grant.deviceCodeHash, SUBJECT)) + ); + expect(results.filter(Boolean)).toHaveLength(1); + }); + + test('a grant that has aged out can no longer be answered', async () => { + const grant = pending({ expires: Date.now() - 1000 }); + // Inserted directly, because creating one sweeps it. + await sql` + insert into device_grant (id, device_code_hash, user_code, client_id, status, poll_interval, expires_at) + values ('dvg_expired_fixture0000000000', ${grant.deviceCodeHash}, ${grant.userCode}, + 'desktop', 'pending', 5, now() - interval '1 second') + `; + + expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(false); + expect(await store.deny(grant.deviceCodeHash)).toBe(false); + }); +}); + +describe('redeeming', () => { + test('an approved grant is redeemed once, and carries who it was for', async () => { + const grant = pending(); + await store.create(grant); + await store.approve(grant.deviceCodeHash, SUBJECT); + + const claimed = await store.consume(grant.deviceCodeHash, 'desktop'); + expect(claimed?.subject).toEqual(SUBJECT); + expect(await store.consume(grant.deviceCodeHash, 'desktop')).toBeNull(); + }); + + test('several polls arriving together are served once', async () => { + const grant = pending(); + await store.create(grant); + await store.approve(grant.deviceCodeHash, SUBJECT); + + const results = await Promise.all( + Array.from({ length: 5 }, () => store.consume(grant.deviceCodeHash, 'desktop')) + ); + expect(results.filter(Boolean)).toHaveLength(1); + }); + + test('another client cannot redeem the code', async () => { + const grant = pending(); + await store.create(grant); + await store.approve(grant.deviceCodeHash, SUBJECT); + + expect(await store.consume(grant.deviceCodeHash, 'somebody-else')).toBeNull(); + // And the real client is not robbed of it in the attempt. + expect(await store.consume(grant.deviceCodeHash, 'desktop')).not.toBeNull(); + }); + + test('a grant nobody approved is not redeemable', async () => { + const grant = pending(); + await store.create(grant); + expect(await store.consume(grant.deviceCodeHash, 'desktop')).toBeNull(); + }); +}); + +/** + * The bug this store exists to make impossible. + * + * A poll reads a pending grant, the browser approves while the poll is in + * flight, and then the poll writes down that it happened. If writing that down + * means writing the whole record back, the approval is gone and the client + * polls a dead grant until it expires. + */ +describe('recording a poll', () => { + test('touches the bookkeeping and nothing else', async () => { + const grant = pending(); + await store.create(grant); + + const stale = await store.byDeviceCode(grant.deviceCodeHash); + expect(stale!.status).toBe('pending'); + + await store.approve(grant.deviceCodeHash, SUBJECT); + await store.recordPoll(grant.deviceCodeHash, Date.now(), stale!.interval + 5); + + const after = await store.byDeviceCode(grant.deviceCodeHash); + expect(after!.status).toBe('approved'); + expect(after!.subject).toEqual(SUBJECT); + expect(after!.interval).toBe(10); + expect(after!.lastPolled).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/auth/device-grant.ts b/packages/core/src/auth/device-grant.ts new file mode 100644 index 00000000..81153881 --- /dev/null +++ b/packages/core/src/auth/device-grant.ts @@ -0,0 +1,153 @@ +import type { DeviceGrant, DeviceGrantSubject, DeviceStore } from '@nestri/auth/device'; +import { and, eq, lt, sql } from 'drizzle-orm'; + +import { Database } from '../db/index.js'; +import { Identifier } from '../id.js'; +import { DeviceGrantTable } from './device-grant.sql.js'; + +type Row = typeof DeviceGrantTable.$inferSelect; + +function toGrant(row: Row): DeviceGrant { + return { + deviceCodeHash: row.deviceCodeHash, + userCode: row.userCode, + clientID: row.clientId, + status: row.status, + interval: row.pollInterval, + lastPolled: row.lastPolledAt?.getTime() ?? 0, + expires: row.expiresAt.getTime(), + subject: row.subject ?? undefined + }; +} + +/** + * Device authorization grants, kept where a conditional write is possible. + * + * Each method below is one statement on purpose. The interface asks for + * transitions that happen exactly once while a browser and a polling client are + * both touching the same grant, and the only way to promise that is to let the + * database decide: `update ... where status = 'pending'` either changes a row + * or does not, and `delete ... returning` hands the row to exactly one caller. + * Read it, decide in application code, and write it back, and the two callers + * undo each other — which is the bug this shape exists to make impossible. + */ +export function PostgresDeviceStore(): DeviceStore { + return { + async create(grant) { + await Database.use(async (tx) => { + // Swept here rather than on a schedule. A grant lives ten + // minutes and this is the only statement that adds one, so the + // table is bounded by how many sign-ins are in flight without + // anything else having to run. + await tx.delete(DeviceGrantTable).where(lt(DeviceGrantTable.expiresAt, new Date())); + + await tx.insert(DeviceGrantTable).values({ + id: Identifier.ascending('deviceGrant'), + deviceCodeHash: grant.deviceCodeHash, + userCode: grant.userCode, + clientId: grant.clientID, + status: grant.status, + pollInterval: grant.interval, + lastPolledAt: grant.lastPolled ? new Date(grant.lastPolled) : null, + expiresAt: new Date(grant.expires), + subject: grant.subject ?? null + }); + }); + }, + + async byDeviceCode(deviceCodeHash) { + return Database.use(async (tx) => + tx + .select() + .from(DeviceGrantTable) + .where(eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash)) + .then((rows) => (rows[0] ? toGrant(rows[0]) : null)) + ); + }, + + async byUserCode(userCode) { + return Database.use(async (tx) => + tx + .select() + .from(DeviceGrantTable) + .where(eq(DeviceGrantTable.userCode, userCode)) + .then((rows) => (rows[0] ? toGrant(rows[0]) : null)) + ); + }, + + async approve(deviceCodeHash, subject: DeviceGrantSubject) { + return Database.use(async (tx) => + tx + .update(DeviceGrantTable) + .set({ status: 'approved', subject }) + .where( + and( + eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash), + eq(DeviceGrantTable.status, 'pending'), + sql`${DeviceGrantTable.expiresAt} > now()` + ) + ) + .returning({ id: DeviceGrantTable.id }) + .then((rows) => rows.length > 0) + ); + }, + + async deny(deviceCodeHash) { + return Database.use(async (tx) => + tx + .update(DeviceGrantTable) + .set({ status: 'denied' }) + .where( + and( + eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash), + eq(DeviceGrantTable.status, 'pending'), + sql`${DeviceGrantTable.expiresAt} > now()` + ) + ) + .returning({ id: DeviceGrantTable.id }) + .then((rows) => rows.length > 0) + ); + }, + + async consume(deviceCodeHash, clientID) { + // Deleting and reading are the same statement, so two polls + // arriving together cannot both be served: one deletes the row and + // gets it, the other deletes nothing and gets nothing. + return Database.use(async (tx) => + tx + .delete(DeviceGrantTable) + .where( + and( + eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash), + eq(DeviceGrantTable.clientId, clientID), + eq(DeviceGrantTable.status, 'approved'), + sql`${DeviceGrantTable.expiresAt} > now()` + ) + ) + .returning() + .then((rows) => (rows[0] ? toGrant(rows[0]) : null)) + ); + }, + + async recordPoll(deviceCodeHash, at, interval) { + // Two columns, and deliberately not the rest of the row. Writing + // the whole grant back here is what would let a poll that read a + // pending record undo an approval that landed while it was in + // flight. + await Database.use(async (tx) => { + await tx + .update(DeviceGrantTable) + .set({ lastPolledAt: new Date(at), pollInterval: interval }) + .where(eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash)); + }); + }, + + async remove(deviceCodeHash) { + await Database.use(async (tx) => { + await tx + .delete(DeviceGrantTable) + .where(eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash)); + }); + } + }; +} diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index d52b5771..35fd625c 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -19,7 +19,8 @@ export namespace Identifier { userLibrary: 'ulb', gameDepot: 'gdp', gameDownload: 'gdl', - waitlistEntry: 'wle' + waitlistEntry: 'wle', + deviceGrant: 'dvg' } as const; export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/core/src/session/session.sql.ts b/packages/core/src/session/session.sql.ts index 9c4c31fc..8017f80f 100644 --- a/packages/core/src/session/session.sql.ts +++ b/packages/core/src/session/session.sql.ts @@ -62,7 +62,17 @@ export const SessionTable = pgTable( timeStarted: utc('time_started'), timeStopped: utc('time_stopped'), /** Why it ended badly, when it did. */ - errorMessage: text('error_message') + errorMessage: text('error_message'), + /** + * Which attempt holds this run. Null until an agent claims it, and never + * cleared afterwards — a terminal row still records who ran it, and a + * token that goes back to null lets a dead claim be replayed. + * + * Nothing writes it yet. It is declared here so the schema, the snapshot + * and the database agree; without it the next generated migration adds a + * column that already exists and fails wherever it has run once. + */ + claimToken: text('claim_token') }, (t) => [ index('session_box_idx').on(t.boxId), diff --git a/packages/core/src/steam/index.ts b/packages/core/src/steam/index.ts index fc580407..811cdf47 100644 --- a/packages/core/src/steam/index.ts +++ b/packages/core/src/steam/index.ts @@ -6,6 +6,7 @@ import { ErrorCodes, VisibleError } from '../error.js'; import { fn } from '../fn.js'; import { Identifier } from '../id.js'; import { Fingerprint } from '../user/fingerprint.js'; +import { Identity } from '../user/identity.js'; import { User } from '../user/index.js'; import { LinkedAccount } from '../user/linked-account.js'; @@ -163,6 +164,15 @@ async function resolveSshIdentityOnce( } export namespace Steam { + /** + * Connect a Steam account to whoever is asking. + * + * Works out who that is and then hands over to the one place the rules + * live. It used to write the row itself, which meant the cap on how many + * accounts one person may connect held on the sign-in path and not on + * this one — and this is the path a settings screen uses, so it is the + * one that would have been over the limit. + */ export const link = fn( z.object({ steamId: z.string(), @@ -170,34 +180,21 @@ export namespace Steam { userId: z.string().optional() }), async (input) => { - return Database.transaction(async () => { - const existing = await LinkedAccount.findByProvider({ - provider: 'steam', - providerAccountId: input.steamId - }); - if (existing) { - return existing.id; - } - const actor = Actor.use(); - const uid = - input.userId ?? - (actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined); - if (!uid) { - throw new VisibleError( - 'forbidden', - ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, - 'Cannot link Steam account without a user ID' - ); - } - const id = Identifier.ascending('linkedAccount'); - await LinkedAccount.create({ - id, - userId: uid, - provider: 'steam', - providerAccountId: input.steamId, - profile: input.profile ?? null - }); - return id; + const actor = Actor.use(); + const uid = + input.userId ?? + (actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined); + if (!uid) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Cannot link Steam account without a user ID' + ); + } + return Identity.linkSteam({ + userId: uid, + steamId: input.steamId, + profile: input.profile }); } ); diff --git a/packages/core/src/user/identity.test.ts b/packages/core/src/user/identity.test.ts new file mode 100644 index 00000000..3aa6ab73 --- /dev/null +++ b/packages/core/src/user/identity.test.ts @@ -0,0 +1,293 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; + +import { Actor } from '../actor.js'; +import { testDb } from '../db/test.js'; +import { Identifier } from '../id.js'; +import { Steam } from '../steam/index.js'; +import { Identity } from './identity.js'; +import { User } from './index.js'; +import { LinkedAccount } from './linked-account.js'; + +const sql = testDb(); + +const createdUserIDs: string[] = []; + +function steamID(n: number): string { + return String(76561197960299000n + BigInt(n)); +} + +function email(n: number): string { + return `identity-fixture-${n}@example.test`; +} + +function track(userID: string) { + createdUserIDs.push(userID); + return userID; +} + +async function cleanup() { + createdUserIDs.length = 0; + // By fixture shape rather than by tracked id: a test that throws before it + // records the row it made would otherwise leave one behind, and the next + // run would read it as an account that already existed. + await sql`delete from "user" where email like 'identity-fixture-%@example.test'`; + await sql` + delete from "user" u + where exists ( + select 1 from linked_account l + where l.user_id = u.id + and l.provider = 'steam' + and l.provider_account_id like '765611979602990%' + ) + `; +} + +async function countUsers(): Promise { + const rows = await sql`select count(*)::int as n from "user"`; + return rows[0]!.n as number; +} + +/** A user as the database holds them today: made by Steam, with no email. */ +async function legacySteamUser(n: number) { + const userID = track(Identifier.ascending('user')); + await User.create({ + id: userID, + name: `legacy-${n}`, + email: undefined, + emailVerified: false, + image: null + }); + const linkID = Identifier.ascending('linkedAccount'); + await LinkedAccount.create({ + id: linkID, + userId: userID, + provider: 'steam', + providerAccountId: steamID(n), + profile: null + }); + return { userID, linkID, steamId: steamID(n) }; +} + +afterAll(async () => { + await cleanup(); + await sql.end(); +}); + +describe('Identity.resolveSteamLogin', () => { + beforeEach(cleanup); + + test('a Steam account made before email existed still signs in to the same user', async () => { + const legacy = await legacySteamUser(1); + + const resolved = await Identity.resolveSteamLogin({ steamId: legacy.steamId }); + + expect(resolved.userID).toBe(legacy.userID); + expect(resolved.linkedAccountID).toBe(legacy.linkID); + }); + + test('an unknown Steam account is refused and creates no user', async () => { + const before = await countUsers(); + + let thrown: any = null; + try { + await Identity.resolveSteamLogin({ steamId: steamID(99) }); + } catch (err) { + thrown = err; + } + + expect(thrown).not.toBeNull(); + expect(thrown.type).toBe('not_found'); + expect(await countUsers()).toBe(before); + }); +}); + +describe('Identity.fromVerifiedEmail', () => { + beforeEach(cleanup); + + test('a verified email creates the user, and nothing else is needed', async () => { + const result = await Identity.fromVerifiedEmail({ email: email(1), name: 'Player One' }); + track(result.userID); + + expect(result.created).toBe(true); + const user = await User.fromID(result.userID); + expect(user?.email).toBe(email(1)); + expect(user?.emailVerified).toBe(true); + }); + + test('the same address resolves to the same user rather than a second one', async () => { + const first = await Identity.fromVerifiedEmail({ email: email(2) }); + track(first.userID); + const before = await countUsers(); + + const second = await Identity.fromVerifiedEmail({ email: ` ${email(2).toUpperCase()} ` }); + + expect(second.userID).toBe(first.userID); + expect(second.created).toBe(false); + expect(await countUsers()).toBe(before); + }); +}); + +describe('input the flow refuses before it reaches the database', () => { + // `fn()` parses before it calls, so a rejected input throws where it is + // written rather than resolving to a rejected promise later on. + test('an address that is not one never becomes an account', () => { + expect(() => Identity.fromVerifiedEmail({ email: 'not-an-address' })).toThrow(); + }); + + test('a Steam id of the wrong shape is not looked up', () => { + expect(() => Identity.resolveSteamLogin({ steamId: '123' })).toThrow(); + }); +}); + +describe('Identity.linkSteam', () => { + beforeEach(cleanup); + + test('a fifth Steam account is refused and the fourth still stands', async () => { + const { userID } = await Identity.fromVerifiedEmail({ email: email(3) }); + track(userID); + + for (let n = 10; n < 14; n++) { + await Identity.linkSteam({ userId: userID, steamId: steamID(n) }); + } + + let thrown: any = null; + try { + await Identity.linkSteam({ userId: userID, steamId: steamID(14) }); + } catch (err) { + thrown = err; + } + + expect(thrown).not.toBeNull(); + expect(thrown.code).toBe('invalid_state'); + const links = await LinkedAccount.listByUser(userID); + expect(links.filter((l) => l.provider === 'steam')).toHaveLength(Identity.MAX_STEAM_ACCOUNTS); + }); + + test('relinking the same Steam account is not a fifth account', async () => { + const { userID } = await Identity.fromVerifiedEmail({ email: email(4) }); + track(userID); + + const first = await Identity.linkSteam({ userId: userID, steamId: steamID(20) }); + const again = await Identity.linkSteam({ userId: userID, steamId: steamID(20) }); + + expect(again).toBe(first); + }); + + test('a Steam account already held by somebody else is a conflict', async () => { + const legacy = await legacySteamUser(30); + const { userID } = await Identity.fromVerifiedEmail({ email: email(5) }); + track(userID); + + let thrown: any = null; + try { + await Identity.linkSteam({ userId: userID, steamId: legacy.steamId }); + } catch (err) { + thrown = err; + } + + expect(thrown).not.toBeNull(); + expect(thrown.type).toBe('already_exists'); + }); + + test('the cap holds on the path the settings screen uses', async () => { + const { userID } = await Identity.fromVerifiedEmail({ email: email(7) }); + track(userID); + for (let n = 50; n < 54; n++) { + await Identity.linkSteam({ userId: userID, steamId: steamID(n) }); + } + + let thrown: any = null; + await Actor.with({ type: 'user', properties: { userID, linkedAccountID: '' } }, async () => { + try { + await Steam.link({ steamId: steamID(54) }); + } catch (err) { + thrown = err; + } + }); + + expect(thrown).not.toBeNull(); + expect(thrown.code).toBe('invalid_state'); + expect(await Identity.listSteam(userID)).toHaveLength(Identity.MAX_STEAM_ACCOUNTS); + }); + + test('a legacy user is claimed by attaching an email, and keeps its Steam link', async () => { + const legacy = await legacySteamUser(40); + + const claimed = await Identity.claimWithEmail({ userId: legacy.userID, email: email(6) }); + + expect(claimed.email).toBe(email(6)); + expect(claimed.emailVerified).toBe(true); + const resolved = await Identity.resolveSteamLogin({ steamId: legacy.steamId }); + expect(resolved.userID).toBe(legacy.userID); + }); +}); + +/** + * The same call, several times at once, against a real database. + * + * Every one of these holds a rule that is enforced across two statements — a + * lookup and then a write — which means the rule is only as good as whatever + * stops the two from interleaving. Run one at a time they all pass whether or + * not that protection exists, which is exactly why they are written this way. + */ +describe('the same thing happening twice at once', () => { + beforeEach(cleanup); + + test('the cap holds when the links arrive together', async () => { + const { userID } = await Identity.fromVerifiedEmail({ email: email(8) }); + track(userID); + + const wanted = Identity.MAX_STEAM_ACCOUNTS + 2; + const results = await Promise.allSettled( + Array.from({ length: wanted }, (_, i) => + Identity.linkSteam({ userId: userID, steamId: steamID(60 + i) }) + ) + ); + + expect(await Identity.listSteam(userID)).toHaveLength(Identity.MAX_STEAM_ACCOUNTS); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength( + Identity.MAX_STEAM_ACCOUNTS + ); + for (const rejected of results.filter((r) => r.status === 'rejected')) { + expect((rejected as PromiseRejectedResult).reason.code).toBe('invalid_state'); + } + }); + + test('several sign-ins for one new address make one account', async () => { + const address = email(9); + + const results = await Promise.all([ + Identity.fromVerifiedEmail({ email: address }), + Identity.fromVerifiedEmail({ email: address }), + Identity.fromVerifiedEmail({ email: address }) + ]); + results.forEach((r) => track(r.userID)); + + expect(new Set(results.map((r) => r.userID)).size).toBe(1); + expect(results.filter((r) => r.created)).toHaveLength(1); + + const rows = await sql` + select count(*)::int as n from "user" + where email = ${address} and time_deleted is null + `; + expect(rows[0]!.n).toBe(1); + }); + + test('two accounts claiming one address get an answer rather than a driver error', async () => { + const first = await legacySteamUser(70); + const second = await legacySteamUser(71); + const address = email(10); + + const results = await Promise.allSettled([ + Identity.claimWithEmail({ userId: first.userID, email: address }), + Identity.claimWithEmail({ userId: second.userID, email: address }) + ]); + + const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; + expect(rejected).toHaveLength(1); + // The point of the assertion: a sentence a screen can render, and not + // whatever text the driver puts on a constraint violation. + expect(rejected[0]!.reason.type).toBe('already_exists'); + expect(rejected[0]!.reason.message).toMatch(/another account/); + }); +}); diff --git a/packages/core/src/user/identity.ts b/packages/core/src/user/identity.ts new file mode 100644 index 00000000..15b94be2 --- /dev/null +++ b/packages/core/src/user/identity.ts @@ -0,0 +1,300 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import z from 'zod'; + +import { Database } from '../db/index.js'; +import { ErrorCodes, VisibleError } from '../error.js'; +import { fn } from '../fn.js'; +import { Identifier } from '../id.js'; +import { User } from './index.js'; +import { UserTable } from './user.sql.js'; +import { LinkedAccount } from './linked-account.js'; +import { LinkedAccountTable } from './linked-account.sql.js'; + +const STEAM_ID_RE = /^\d{17}$/; + +/** The partial unique index on a live account's address, named by the migration. */ +const EMAIL_UNIQUE = 'user_email_unique'; + +/** + * Whether a failure is the database refusing a duplicate. + * + * Every read-then-write below has a window between the read and the write, and + * the index is what actually closes it. Recognising the refusal is how the + * loser of a race turns a raw driver error into the answer it was asking for — + * so the constraint is the mechanism and this is how the code hears from it. + */ +function isUniqueViolation(err: unknown, constraint: string): boolean { + // Walked rather than read off the top, because the query builder wraps what + // the driver threw: the outer error carries the SQL and the parameters, and + // the code and the constraint name are on the cause underneath it. + for (let e: unknown = err, depth = 0; e && depth < 8; depth++) { + if (typeof e !== 'object') break; + const candidate = e as { code?: unknown; constraint_name?: unknown; cause?: unknown }; + if (String(candidate.code) === '23505' && candidate.constraint_name === constraint) { + return true; + } + e = candidate.cause; + } + return false; +} + +/** + * Email is the root of an account, so two spellings of one address must not be + * two accounts. Case and surrounding whitespace are the two ways the same + * address arrives looking different; both are removed at the edge, before + * anything is stored or compared, and the unique index in the database + * assumes it has been. + */ +const Email = z.string().trim().toLowerCase().pipe(z.email()); + +export namespace Identity { + /** + * How many Steam accounts one person may hang off their account. + * + * This is not — and cannot be — a database constraint. A unique index makes a + * value unique; it cannot count the rows that share a foreign key, so there is + * no index shape that says "at most four of these". The number lives here and + * a direct write to the table can still exceed it. Anyone reading the schema + * and looking for the rule will not find one, which is why it is written down + * in the migration as well as here. ref(d-0048) + * + * Four comes from the size of a household that shares a game library and from + * the account switcher needing to fit on one row. It is a product constraint + * and not a measured one. + */ + export const MAX_STEAM_ACCOUNTS = 4; + + /** + * The account behind a verified email address, created if it is new. + * + * This is the only way a user comes into existence. Everything else — a + * Steam account, an SSH key — attaches to a user that already exists, + * which is what makes losing one of them survivable. ref(d-0048) + * + * Idempotent on the address: verifying the same mailbox twice is one + * person signing in twice, not two accounts. + */ + export const fromVerifiedEmail = fn( + z.object({ email: Email, name: z.string().optional() }), + async (input) => { + const email = input.email; + + async function attempt() { + return Database.transaction(async () => { + const existing = await User.fromEmail(email); + if (existing) { + // An address that was attached but never confirmed is + // confirmed now: getting here means a code was redeemed. + if (!existing.emailVerified) { + await User.setEmail({ id: existing.id, email, emailVerified: true }); + } + return { userID: existing.id, created: false }; + } + + const userID = Identifier.ascending('user'); + await User.create({ + id: userID, + name: input.name?.trim() || email.split('@')[0]!, + email, + emailVerified: true, + image: null + }); + return { userID, created: true }; + }); + } + + try { + return await attempt(); + } catch (err) { + if (!isUniqueViolation(err, EMAIL_UNIQUE)) throw err; + + // Somebody else finished the same sign-in first. + // + // Two people redeeming a code for one address is one person + // with two tabs, and the answer they both want is the account + // that now exists. The lookup and the insert cannot be made one + // statement here — the row is built from an id this process + // generates — so the index arbitrates and the loser reads back + // what the winner wrote. Retried once and not in a loop: a + // second refusal means the row is gone again, which is a + // deletion racing a sign-in and not something to spin on. + return await attempt(); + } + } + ); + + /** + * Attach a verified email to an account that never had one. + * + * Accounts predate the rule that email is the root, so a live database + * holds users with no address at all. Each one is claimed exactly once, + * here, and afterwards it is an ordinary account. + */ + export const claimWithEmail = fn( + z.object({ userId: z.string(), email: Email }), + async (input) => { + const email = input.email; + try { + return await Database.transaction(async () => { + const holder = await User.fromEmail(email); + if (holder && holder.id !== input.userId) { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That email address already belongs to another account' + ); + } + const updated = await User.setEmail({ + id: input.userId, + email, + emailVerified: true + }); + if (!updated) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such account' + ); + } + return updated; + }); + } catch (err) { + // The check above and the update below are two statements, so + // two accounts claiming one address can both find it free. The + // index refuses the second, and the person deserves the same + // sentence they would have got a moment earlier rather than a + // driver's error text. + if (!isUniqueViolation(err, EMAIL_UNIQUE)) throw err; + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That email address already belongs to another account' + ); + } + } + ); + + /** + * The account a Steam sign-in belongs to, or an error. + * + * Signing in with Steam never creates anything. A Steam account is a link + * on a user, so one that names no user is a person who has not signed up — + * an answer the interface has to render, not a reason to mint a row. The + * accounts that predate this are exactly the ones a link already exists + * for, so they keep working without a special case. ref(d-0048) + */ + export const resolveSteamLogin = fn( + z.object({ steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID') }), + async (input) => { + const link = await LinkedAccount.findByProvider({ + provider: 'steam', + providerAccountId: input.steamId + }); + if (!link) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'This Steam account is not connected to an account. Sign in with your email address first, then connect Steam from your settings.' + ); + } + return { userID: link.userId, linkedAccountID: link.id }; + } + ); + + /** + * Hang a Steam account off a user, up to {@link MAX_STEAM_ACCOUNTS}. + * + * The count and the insert are one transaction because they are one + * decision, and the transaction takes the account's own row first so that + * two callers cannot each count four and each write a fifth. + */ + export const linkSteam = fn( + z.object({ + userId: z.string(), + steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID'), + profile: z.record(z.string(), z.unknown()).nullable().optional() + }), + async (input) => { + return Database.transaction(async (tx) => { + // Take the account's own row first, and hold it. + // + // The cap is a count, and a count only means something if it + // is taken while nothing can change it. Locking the + // connections instead locks nothing at all when there are + // none: there are no gap locks under read committed, so + // `for update` over an empty result set is an empty set of + // locks, and several simultaneous first-time links all read + // zero and all insert. The account's own row is the one thing + // every caller for it is guaranteed to contend on, so it is + // what serializes them. + const [owner] = await tx + .select({ id: UserTable.id }) + .from(UserTable) + .where(and(eq(UserTable.id, input.userId), isNull(UserTable.timeDeleted))) + .for('update'); + if (!owner) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such account' + ); + } + + const existing = await LinkedAccount.findByProvider({ + provider: 'steam', + providerAccountId: input.steamId + }); + if (existing) { + if (existing.userId !== input.userId) { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That Steam account is already connected to a different account' + ); + } + if (input.profile) { + await LinkedAccount.updateProfile({ id: existing.id, profile: input.profile }); + } + return existing.id; + } + + // Counted under the lock taken above, so what is counted is + // what is still there at the insert. + const held = await tx + .select({ id: LinkedAccountTable.id }) + .from(LinkedAccountTable) + .where( + and( + eq(LinkedAccountTable.userId, input.userId), + eq(LinkedAccountTable.provider, 'steam'), + isNull(LinkedAccountTable.timeDeleted) + ) + ); + + if (held.length >= MAX_STEAM_ACCOUNTS) { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.INVALID_STATE, + `An account can have at most ${MAX_STEAM_ACCOUNTS} Steam accounts connected. Disconnect one before adding another.` + ); + } + + const id = Identifier.ascending('linkedAccount'); + await LinkedAccount.create({ + id, + userId: input.userId, + provider: 'steam', + providerAccountId: input.steamId, + profile: input.profile ?? null + }); + return id; + }); + } + ); + + /** Every Steam account connected to this user, oldest first. */ + export const listSteam = fn(z.string(), async (userId) => { + const links = await LinkedAccount.listByUser(userId); + return links.filter((link) => link.provider === 'steam'); + }); +} diff --git a/packages/core/src/user/user.sql.ts b/packages/core/src/user/user.sql.ts index b21ba1eb..40499edb 100644 --- a/packages/core/src/user/user.sql.ts +++ b/packages/core/src/user/user.sql.ts @@ -1,12 +1,36 @@ -import { boolean, pgTable, text } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { boolean, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { id, timestamps } from '../db/types.js'; -export const UserTable = pgTable('user', { - ...id, - ...timestamps, - name: text('name').notNull(), - email: text('email'), - emailVerified: boolean('email_verified').notNull().default(false), - image: text('image') -}); +export const UserTable = pgTable( + 'user', + { + ...id, + ...timestamps, + name: text('name').notNull(), + /** + * The address the account is rooted in. + * + * Nullable only because accounts exist that predate the rule — every + * one of those was made by signing in with a gaming account and was + * never asked for an address. A new account cannot be created without + * one. ref(d-0048) + */ + email: text('email'), + emailVerified: boolean('email_verified').notNull().default(false), + image: text('image') + }, + (t) => [ + // One address, one account, which is what makes it a root identity + // rather than a contact detail. Partial because the accounts made + // before this rule have no address at all, and "no address" is not a + // value two of them can collide on. + // + // The column holds a trimmed, lower-cased address; nothing here + // enforces that, so anything writing it has to normalize first. + uniqueIndex('user_email_unique') + .on(t.email) + .where(sql`email is not null and time_deleted is null`) + ] +);