feat(auth): add lastSeenAt field to user schema and tests
This commit is contained in:
@@ -152,3 +152,5 @@ docs/ai/context/verifications/
|
|||||||
# Generated from packages/i18n/glossary/terms.yaml at upload time. Never committed, so it
|
# Generated from packages/i18n/glossary/terms.yaml at upload time. Never committed, so it
|
||||||
# cannot drift from its source.
|
# cannot drift from its source.
|
||||||
packages/i18n/glossary/glossary.tbx
|
packages/i18n/glossary/glossary.tbx
|
||||||
|
|
||||||
|
/airi-docs/
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { user } from '@proj-airi/auth-shared'
|
||||||
|
import { getTableColumns } from 'drizzle-orm'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('better Auth schema contract', () => {
|
||||||
|
it('keeps the application-owned last_seen_at user column', () => {
|
||||||
|
const columns = getTableColumns(user)
|
||||||
|
|
||||||
|
expect(columns.lastSeenAt).toBeDefined()
|
||||||
|
expect(columns.lastSeenAt.name).toBe('last_seen_at')
|
||||||
|
expect(columns.lastSeenAt.notNull).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -542,6 +542,17 @@ export function createAuth(
|
|||||||
},
|
},
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
|
// Keep this server-managed activity field in Better Auth's declared
|
||||||
|
// schema so `better-auth generate` preserves it in auth-shared.
|
||||||
|
// Session creation updates it below; clients must never supply it.
|
||||||
|
additionalFields: {
|
||||||
|
lastSeenAt: {
|
||||||
|
type: 'date',
|
||||||
|
required: false,
|
||||||
|
input: false,
|
||||||
|
returned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
changeEmail: {
|
changeEmail: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
// NOTICE:
|
// NOTICE:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AuthEnv } from '../env'
|
|||||||
|
|
||||||
import { generateKeyPairSync } from 'node:crypto'
|
import { generateKeyPairSync } from 'node:crypto'
|
||||||
|
|
||||||
|
import { getAuthTables } from 'better-auth/db'
|
||||||
import { decodeJwt, decodeProtectedHeader, importSPKI, jwtVerify } from 'jose'
|
import { decodeJwt, decodeProtectedHeader, importSPKI, jwtVerify } from 'jose'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -59,6 +60,31 @@ describe('createAuth', () => {
|
|||||||
expect(auth.options.account?.accountLinking?.allowDifferentEmails).toBe(true)
|
expect(auth.options.account?.accountLinking?.allowDifferentEmails).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('registers lastSeenAt as a server-managed Better Auth user field', () => {
|
||||||
|
const auth = createAuth({} as unknown as AuthDatabase, {
|
||||||
|
PUBLIC_URL: 'http://localhost:3000',
|
||||||
|
AUTH_GOOGLE_CLIENT_ID: 'google-client',
|
||||||
|
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
|
||||||
|
AUTH_GITHUB_CLIENT_ID: 'github-client',
|
||||||
|
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
|
||||||
|
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
|
||||||
|
ADDITIONAL_TRUSTED_ORIGINS: [],
|
||||||
|
} as unknown as AuthEnv)
|
||||||
|
|
||||||
|
expect(auth.options.user?.additionalFields?.lastSeenAt).toMatchObject({
|
||||||
|
type: 'date',
|
||||||
|
required: false,
|
||||||
|
input: false,
|
||||||
|
returned: true,
|
||||||
|
})
|
||||||
|
expect(getAuthTables(auth.options).user.fields.lastSeenAt).toMatchObject({
|
||||||
|
type: 'date',
|
||||||
|
required: false,
|
||||||
|
input: false,
|
||||||
|
returned: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('asks social providers to show the account picker during OAuth authorization', () => {
|
it('asks social providers to show the account picker during OAuth authorization', () => {
|
||||||
const auth = createAuth({} as unknown as AuthDatabase, {
|
const auth = createAuth({} as unknown as AuthDatabase, {
|
||||||
PUBLIC_URL: 'http://localhost:3000',
|
PUBLIC_URL: 'http://localhost:3000',
|
||||||
|
|||||||
@@ -13,14 +13,6 @@ export const user = pgTable('user', {
|
|||||||
banned: boolean('banned').default(false),
|
banned: boolean('banned').default(false),
|
||||||
banReason: text('ban_reason'),
|
banReason: text('ban_reason'),
|
||||||
banExpires: timestamp('ban_expires'),
|
banExpires: timestamp('ban_expires'),
|
||||||
// NOTICE:
|
|
||||||
// Touched in `databaseHooks.session.create.after` (see auth.ts) which
|
|
||||||
// fires on sign-in AND on every OIDC access-token refresh (~hourly), so
|
|
||||||
// this is effectively "last activity" for any user with a live client.
|
|
||||||
// Better Auth has `session.updatedAt` but that's per-session-row; we
|
|
||||||
// want one stable per-user timestamp for DAU-style queries without
|
|
||||||
// joining/aggregating session rows. Nullable so existing rows backfill
|
|
||||||
// lazily on next login instead of needing a migration-time seed.
|
|
||||||
lastSeenAt: timestamp('last_seen_at'),
|
lastSeenAt: timestamp('last_seen_at'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at')
|
updatedAt: timestamp('updated_at')
|
||||||
|
|||||||
Reference in New Issue
Block a user