diff --git a/README.md b/README.md index d12193fe..c70a548b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The defaults are for local use. Before anyone else can reach your instance, copy | File storage | `STORAGE=local` (default, the `files` volume; needs a filesystem with hard links, otherwise writes fall back to a non-atomic create and the store logs a warning) or `STORAGE=s3` with `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE` (AWS S3, MinIO, Cloudflare R2, GCS with HMAC keys). With S3 the `api` and `worker` roles need no shared volume. The credentials need `s3:GetObject`, `s3:PutObject`, `s3:DeleteObject` and `s3:ListBucket` (without ListBucket a missing file reads as 403, not 404) | | File parsing sandbox | `EXTRACT_URL` on the kernel (Compose sets it). `crove-extract` takes no secrets: its image refuses to start with any variable outside its allowlist (its `EXTRACT_*` settings, `HOST`, `PORT`, `NODE_ENV` and what the container runtime sets), whatever `NODE_ENV` says. On Kubernetes set `enableServiceLinks: false` on its pod, or the injected `*_SERVICE_HOST` and `*_PORT` variables refuse the start | | Database pooling | `DB_RESET_ON_RELEASE` (default `true`: each pooled connection's session is reset before reuse); set `false` only behind a transaction-mode pooler such as PgBouncer or Supavisor, whose own reset then governs (plan §3.3) | -| Sessions and MFA | `AUTH_ISSUER` (required), `AUTH_SESSION_CHECK`, `AUTH_STEP_UP`; without Compose, run the image's `auth-grants` role after Supabase Auth's first start and after each Supabase Auth upgrade. With a remote Supabase Auth (`AUTH_SESSION_CHECK=jwt`, the default without `AUTH_UPSTREAM_URL`), set `PUBLIC_AUTH_URL` (and `PUBLIC_AUTH_ANON_KEY` for hosted Supabase): R3 actions are confirmed live there, and a sign-out outside Crove takes effect when the access token expires (ADR-0028) | +| Sessions and MFA | `AUTH_ISSUER` (required), `AUTH_SESSION_CHECK`, `AUTH_STEP_UP` (default `required`: approving and membership changes ask for a two-factor code from the last 5 minutes; users enable two-factor under Settings); without Compose, run the image's `auth-grants` role after Supabase Auth's first start and after each Supabase Auth upgrade. With a remote Supabase Auth (`AUTH_SESSION_CHECK=jwt`, the default without `AUTH_UPSTREAM_URL`), set `PUBLIC_AUTH_URL` (and `PUBLIC_AUTH_ANON_KEY` for hosted Supabase): R3 actions are confirmed live there, and a sign-out outside Crove takes effect when the access token expires (ADR-0028) | See [`.env.example`](./.env.example) for the full list. diff --git a/apps/app/src/components/two-factor.tsx b/apps/app/src/components/two-factor.tsx new file mode 100644 index 00000000..4e8a4319 --- /dev/null +++ b/apps/app/src/components/two-factor.tsx @@ -0,0 +1,258 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState, type FormEvent } from 'react'; +import { useTranslations } from 'use-intl'; +import { auth, authErrorMessage } from '../lib/auth.ts'; +import { Badge } from '@crove/ui/components/badge'; +import { Button } from '@crove/ui/components/button'; +import { Card } from '@crove/ui/components/card'; +import { Input } from '@crove/ui/components/input'; +import { Label } from '@crove/ui/components/label'; +import { ErrorText, Notice, Spinner } from './ui.tsx'; + +interface Enrollment { + factorId: string; + qrCode: string; + secret: string; +} + +// The signed-in user's Supabase Auth MFA factors; `totp` lists the verified ones. +export function useFactors() { + return useQuery({ + queryKey: ['auth', 'factors'], + queryFn: async () => { + const { data, error } = await auth().mfa.listFactors(); + if (error) throw error; + return data; + }, + }); +} + +// Verifies a code from the user's authenticator app, which raises the session to aal2. +export function MfaChallengeForm({ onVerified }: { onVerified: () => Promise | void }) { + const root = useTranslations(); + const t = useTranslations('twoFactor'); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + try { + const { data: factors, error: listError } = await auth().mfa.listFactors(); + if (listError) return setError(authErrorMessage(listError, root)); + const factor = factors.totp[0]; + if (!factor) return setError(t('noFactor')); + const { error: verifyError } = await auth().mfa.challengeAndVerify({ factorId: factor.id, code: code.trim() }); + if (verifyError) { + setCode(''); + return setError(authErrorMessage(verifyError, root)); + } + await onVerified(); + } finally { + setBusy(false); + } + } + + return ( +
+ + {error && {error}} + +
+ ); +} + +// TOTP enrollment for the signed-in user (Supabase Auth MFA). A verified factor makes the next sign-in aal2. +export function TwoFactorCard({ onEnabled }: { onEnabled?: () => void }) { + const root = useTranslations(); + const t = useTranslations('twoFactor'); + const factors = useFactors(); + const [enrollment, setEnrollment] = useState(null); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const verified = factors.data?.totp ?? []; + + async function run(action: () => Promise) { + setBusy(true); + setError(null); + setNotice(null); + try { + setError(await action()); + } finally { + setBusy(false); + await factors.refetch(); + } + } + + const start = () => + run(async () => { + // An abandoned setup leaves an unverified factor behind; remove it so they do not pile up. + for (const f of factors.data?.all ?? []) { + if (f.factor_type === 'totp' && f.status !== 'verified') await auth().mfa.unenroll({ factorId: f.id }); + } + const { data, error: e } = await auth().mfa.enroll({ factorType: 'totp', issuer: 'Crove' }); + if (e) return authErrorMessage(e, root); + setCode(''); + setEnrollment({ factorId: data.id, qrCode: data.totp.qr_code, secret: data.totp.secret }); + return null; + }); + + const confirm = (event: FormEvent) => { + event.preventDefault(); + if (!enrollment) return; + void run(async () => { + const { error: e } = await auth().mfa.challengeAndVerify({ factorId: enrollment.factorId, code: code.trim() }); + if (e) return authErrorMessage(e, root); + setEnrollment(null); + setNotice(t('enabled')); + onEnabled?.(); + return null; + }); + }; + + const cancel = () => + run(async () => { + if (enrollment) await auth().mfa.unenroll({ factorId: enrollment.factorId }); + setEnrollment(null); + return null; + }); + + const disable = (factorId: string) => { + if (!window.confirm(t('confirmDisable'))) return; + void run(async () => { + const { error: e } = await auth().mfa.unenroll({ factorId }); + if (e) return authErrorMessage(e, root); + setNotice(t('disabled')); + return null; + }); + }; + + return ( + +
+

{t('title')}

+ {factors.isSuccess && (verified.length > 0 ? {t('on')} : {t('off')})} +
+

{t('intro')}

+
+ {factors.isLoading && } + {factors.error && {authErrorMessage(factors.error as { code?: string; message: string }, root)}} + {enrollment ? ( +
+

{t('scan')}

+ {/* The QR code draws black modules on a transparent background: it needs a light backdrop in both themes. */} + {t('qrAlt')} +
+ {t('manualKey')} + + {enrollment.secret} + +
+ +
+ + +
+
+ ) : factors.isSuccess && verified.length > 0 ? ( + verified.map((f) => ( + + )) + ) : ( + factors.isSuccess && ( + + ) + )} + {error && {error}} + {notice && {notice}} +
+
+ ); +} + +// Shown when the kernel answers STEP_UP_REQUIRED (policy P11): approving and other R3 work need a second factor +// verified in the last 5 minutes. With a factor, the user enters a fresh code; without one, they enable it here +// (or, where `enroll` is false, in the two-factor card on the same page). Either way `onVerified` repeats the request +// that was refused, which `action` names; `onCancel` drops it. +export function StepUpPrompt({ + action, + onVerified, + onCancel, + enroll = true, +}: { + action: string; + onVerified: () => void; + onCancel?: () => void; + enroll?: boolean; +}) { + const root = useTranslations(); + const t = useTranslations('twoFactor.stepUp'); + const factors = useFactors(); + if (factors.isLoading) return ; + if (factors.error) return {authErrorMessage(factors.error as { code?: string; message: string }, root)}; + const pending =

{t('pending', { action })}

; + const cancel = onCancel && ( + + ); + if ((factors.data?.totp.length ?? 0) > 0) { + return ( + +

{t('title')}

+

{t('recent')}

+ {pending} + + {cancel} +
+ ); + } + return ( +
+

{enroll ? t('enrollHere') : t('enrollBeside')}

+ {pending} + {enroll && } + {cancel} +
+ ); +} diff --git a/apps/app/src/components/ui.tsx b/apps/app/src/components/ui.tsx index aadc4c1c..eb06bb68 100644 --- a/apps/app/src/components/ui.tsx +++ b/apps/app/src/components/ui.tsx @@ -39,6 +39,15 @@ export function ErrorText({ children }: { children: ReactNode }) { ); } +// Something that worked and the user should read (an email link confirmed, a setting saved); a status, not an alert. +export function Notice({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + export function Spinner({ className }: { className?: string }) { const t = useTranslations('common'); return ; diff --git a/apps/app/src/i18n/messages/en.ts b/apps/app/src/i18n/messages/en.ts index 80d59a2d..1ca23bb7 100644 --- a/apps/app/src/i18n/messages/en.ts +++ b/apps/app/src/i18n/messages/en.ts @@ -39,8 +39,11 @@ export const en: Messages = { tagline: 'Document inbox: forward invoices, AI proposes the entries, you approve.', email: 'Email', password: 'Password', - signupSent: 'We sent a confirmation email. Open it, then come back to sign in.', - resetSent: 'If this email has an account, a password reset email is on its way.', + signupSent: 'We sent a confirmation email. Open the link in it in this browser to get into Crove.', + resetSent: 'If this email has an account, a password reset email is on its way. Open the link in it in this browser.', + linkInvalid: 'This link is invalid or has expired. Ask for a new one.', + linkVerified: + 'The link was verified. If you just confirmed your email, sign in. If you are resetting your password, open the link in the browser you asked for it from.', forgotLink: 'Forgot password?', haveAccount: 'Already have an account? Sign in', errors: { @@ -49,6 +52,14 @@ export const en: Messages = { email_not_confirmed: 'Email not confirmed yet, open the confirmation email', weak_password: 'Password too weak (at least 8 characters)', over_email_send_rate_limit: 'Too many emails sent, try again later', + same_password: 'The new password must differ from the old one', + otp_expired: 'This link has expired or was already used. Ask for a new one.', + flow_state_expired: 'This link has expired. Ask for a new one.', + flow_state_not_found: 'This link is no longer valid. Ask for a new one.', + bad_code_verifier: 'Open the link in the browser you asked for it from.', + mfa_verification_failed: 'The code is wrong or has expired', + mfa_challenge_expired: 'The code has expired, enter a new one', + insufficient_aal: 'Enter your two-factor code first', }, }, onboarding: { @@ -188,6 +199,7 @@ export const en: Messages = { reject: 'Reject', saveEdit: 'Save changes', approve: 'Approve and post', + stepUpApprove: 'approve and post this document', executedAt: 'Posted {at}', rejected: 'Rejected', reading: @@ -304,6 +316,10 @@ export const en: Messages = { 'The current address stops receiving mail at once and is never issued again. Mail sent to the old address is dropped without telling the sender, so send the new address to your vendors.', rotateMfa: 'This needs two-factor authentication (MFA) in the last 5 minutes. Each organization can change it only a few times a day.', rotateConfirm: 'Confirm address change', + stepUpRemove: 'remove {name} from the organization', + stepUpRole: 'change the role of {name} to {role}', + stepUpRotate: 'change the invoice email address', + stepUpInvite: 'invite {email} as {role}', inviteCreated: 'Invitation created for {email}.', inviteMailSent: 'Crove emails the invitation to this address. If the invitee does not receive it, send them the link below.', inviteMailOff: 'This server cannot send email, so Crove did not email the invitation. Send the link below to the invitee.', @@ -320,6 +336,45 @@ export const en: Messages = { invitedBy: 'invited by {name}', revoke: 'Revoke', }, + twoFactor: { + title: 'Two-factor authentication', + signInIntro: 'Enter the 6-digit code from your authenticator app to finish signing in.', + code: 'Authentication code', + codePlaceholder: '6 digits', + checking: 'Checking…', + confirm: 'Confirm', + noFactor: 'This account has no authenticator app', + on: 'On', + off: 'Off', + intro: + 'Each sign-in also asks for a 6-digit code from an authenticator app (Google Authenticator, Microsoft Authenticator, 1Password...). Recommended for everyone who approves postings.', + scan: 'Scan the QR code with your authenticator app, then enter the code it shows.', + qrAlt: 'QR code to add Crove to your authenticator app', + manualKey: 'Cannot scan it? Enter this key in the app:', + setupKey: 'Setup key', + enable: 'Turn on two-factor authentication', + disable: 'Turn off two-factor authentication', + confirmDisable: 'Turn off two-factor authentication? The account will be protected by the password alone.', + enabled: 'Two-factor authentication is on. Your next sign-in will ask for a code from the app.', + disabled: 'Two-factor authentication is off.', + stepUp: { + title: 'Verify again to continue', + recent: 'This needs a two-factor code entered in the last 5 minutes.', + pending: 'Waiting to {action}.', + cancel: 'Cancel', + enrollHere: 'This needs two-factor authentication. Turn it on for your account and Crove will retry right after.', + enrollBeside: 'This needs two-factor authentication. Turn it on in the card next to this one, then try again.', + }, + }, + resetPassword: { + title: 'Set a new password', + needsCode: 'This account uses two-factor authentication. Enter the code before setting a new password.', + newPassword: 'New password', + repeat: 'Repeat the new password', + mismatch: 'The passwords do not match', + saving: 'Saving…', + save: 'Save password', + }, sessions: { title: 'Signed-in devices', intro: 'The Crove sessions seen from your account. Revoking a session signs that device out of every organization from its next request.', diff --git a/apps/app/src/i18n/messages/vi.ts b/apps/app/src/i18n/messages/vi.ts index 049da44e..cf3609ac 100644 --- a/apps/app/src/i18n/messages/vi.ts +++ b/apps/app/src/i18n/messages/vi.ts @@ -38,8 +38,11 @@ export const vi = { tagline: 'Hộp thư chứng từ: forward hóa đơn, AI đề xuất hạch toán, bạn duyệt.', email: 'Email', password: 'Mật khẩu', - signupSent: 'Đã gửi email xác nhận. Mở thư rồi quay lại đăng nhập.', - resetSent: 'Nếu email có tài khoản, thư đặt lại mật khẩu đã được gửi.', + signupSent: 'Đã gửi email xác nhận. Mở liên kết trong thư trên trình duyệt này để vào Crove.', + resetSent: 'Nếu email có tài khoản, thư đặt lại mật khẩu đã được gửi. Mở liên kết trong thư trên trình duyệt này.', + linkInvalid: 'Liên kết không hợp lệ hoặc đã hết hạn. Hãy yêu cầu liên kết mới.', + linkVerified: + 'Liên kết đã được xác minh. Nếu bạn vừa xác nhận email, hãy đăng nhập. Nếu bạn đặt lại mật khẩu, hãy mở liên kết trên đúng trình duyệt đã gửi yêu cầu.', forgotLink: 'Quên mật khẩu?', haveAccount: 'Đã có tài khoản? Đăng nhập', errors: { @@ -48,6 +51,14 @@ export const vi = { email_not_confirmed: 'Email chưa được xác nhận, hãy mở thư xác nhận', weak_password: 'Mật khẩu quá yếu (tối thiểu 8 ký tự)', over_email_send_rate_limit: 'Gửi email quá nhanh, hãy thử lại sau', + same_password: 'Mật khẩu mới phải khác mật khẩu cũ', + otp_expired: 'Liên kết đã hết hạn hoặc đã được dùng. Hãy yêu cầu liên kết mới.', + flow_state_expired: 'Liên kết đã hết hạn. Hãy yêu cầu liên kết mới.', + flow_state_not_found: 'Liên kết không còn hiệu lực. Hãy yêu cầu liên kết mới.', + bad_code_verifier: 'Hãy mở liên kết trên đúng trình duyệt đã gửi yêu cầu.', + mfa_verification_failed: 'Mã xác thực không đúng hoặc đã hết hạn', + mfa_challenge_expired: 'Mã xác thực đã hết hạn, hãy nhập mã mới', + insufficient_aal: 'Hãy nhập mã xác thực hai lớp trước', }, }, onboarding: { @@ -187,6 +198,7 @@ export const vi = { reject: 'Từ chối', saveEdit: 'Lưu chỉnh sửa', approve: 'Duyệt và ghi sổ', + stepUpApprove: 'duyệt và ghi sổ chứng từ này', executedAt: 'Đã ghi sổ {at}', rejected: 'Đã từ chối', reading: @@ -303,6 +315,10 @@ export const vi = { 'Địa chỉ hiện tại sẽ ngừng nhận thư ngay lập tức và không bao giờ được cấp lại. Thư gửi tới địa chỉ cũ sẽ bị bỏ qua mà không báo lỗi cho người gửi, vì vậy hãy gửi địa chỉ mới cho các nhà cung cấp.', rotateMfa: 'Việc này cần xác thực hai lớp (MFA) trong 5 phút gần nhất. Mỗi tổ chức chỉ đổi được vài lần mỗi ngày.', rotateConfirm: 'Xác nhận đổi địa chỉ', + stepUpRemove: 'xóa {name} khỏi tổ chức', + stepUpRole: 'đổi vai trò của {name} thành {role}', + stepUpRotate: 'đổi địa chỉ email nhận hóa đơn', + stepUpInvite: 'mời {email} với vai trò {role}', inviteCreated: 'Đã tạo lời mời cho {email}.', inviteMailSent: 'Crove gửi email mời tới địa chỉ này. Nếu người được mời không nhận được, hãy gửi họ liên kết dưới đây.', inviteMailOff: 'Máy chủ chưa cấu hình gửi email, nên Crove không gửi email mời. Hãy gửi liên kết dưới đây cho người được mời.', @@ -320,6 +336,45 @@ export const vi = { invitedBy: 'mời bởi {name}', revoke: 'Thu hồi', }, + twoFactor: { + title: 'Xác thực hai lớp', + signInIntro: 'Nhập mã 6 chữ số trong ứng dụng xác thực để hoàn tất đăng nhập.', + code: 'Mã xác thực', + codePlaceholder: '6 chữ số', + checking: 'Đang kiểm tra…', + confirm: 'Xác nhận', + noFactor: 'Tài khoản chưa có ứng dụng xác thực', + on: 'Đang bật', + off: 'Chưa bật', + intro: + 'Mỗi lần đăng nhập, nhập thêm mã 6 chữ số từ ứng dụng xác thực (Google Authenticator, Microsoft Authenticator, 1Password...). Nên bật cho người duyệt đề xuất ghi sổ.', + scan: 'Quét mã QR bằng ứng dụng xác thực, rồi nhập mã ứng dụng hiển thị.', + qrAlt: 'Mã QR để thêm Crove vào ứng dụng xác thực', + manualKey: 'Không quét được? Nhập khóa này vào ứng dụng:', + setupKey: 'Khóa thiết lập', + enable: 'Bật xác thực hai lớp', + disable: 'Tắt xác thực hai lớp', + confirmDisable: 'Tắt xác thực hai lớp? Tài khoản sẽ chỉ còn bảo vệ bằng mật khẩu.', + enabled: 'Đã bật xác thực hai lớp. Lần đăng nhập sau sẽ hỏi mã từ ứng dụng.', + disabled: 'Đã tắt xác thực hai lớp.', + stepUp: { + title: 'Xác thực lại để tiếp tục', + recent: 'Việc này cần mã xác thực hai lớp nhập trong 5 phút gần nhất.', + pending: 'Đang chờ: {action}.', + cancel: 'Hủy thao tác', + enrollHere: 'Việc này cần xác thực hai lớp. Bật xác thực hai lớp cho tài khoản, Crove sẽ thực hiện lại ngay sau đó.', + enrollBeside: 'Việc này cần xác thực hai lớp. Bật xác thực hai lớp ở ô bên cạnh rồi thử lại.', + }, + }, + resetPassword: { + title: 'Đặt mật khẩu mới', + needsCode: 'Tài khoản này bật xác thực hai lớp. Nhập mã trước khi đặt mật khẩu mới.', + newPassword: 'Mật khẩu mới', + repeat: 'Nhập lại mật khẩu mới', + mismatch: 'Hai mật khẩu không khớp', + saving: 'Đang lưu…', + save: 'Lưu mật khẩu', + }, sessions: { title: 'Thiết bị đang đăng nhập', intro: diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index d7dbce88..e640785a 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -31,10 +31,15 @@ const link = new RPCLink({ url: `${window.location.origin}/api/rpc`, headers }); export const client: ContractRouterClient = createORPCClient(link); export const orpc = createTanstackQueryUtils(client); -export { ApiError, isMfaRequired, isUnauthenticated } from './errors.ts'; +export { ApiError, isMfaRequired, isStepUpRequired, isUnauthenticated } from './errors.ts'; -// A stale or revoked session signs the user out locally; main.tsx then routes to /login. +// A stale or revoked session signs the user out locally; main.tsx then routes to /login. A session that still owes +// its second factor goes to the challenge instead. function onError(error: unknown): void { + if (isMfaRequired(error)) { + if (window.location.pathname !== '/mfa') window.location.assign('/mfa'); + return; + } if (isUnauthenticated(error)) void auth().signOut({ scope: 'local' }); } diff --git a/apps/app/src/lib/auth.ts b/apps/app/src/lib/auth.ts index cdb62ba9..7505a39f 100644 --- a/apps/app/src/lib/auth.ts +++ b/apps/app/src/lib/auth.ts @@ -38,6 +38,8 @@ export function auth(): Client { url: authUrl, headers: authAnonKey ? { apikey: authAnonKey, Authorization: `Bearer ${authAnonKey}` } : {}, storageKey: 'crove-auth', + // Email links (confirmation, recovery) come back with a one-time code that only this browser can exchange. + flowType: 'pkce', autoRefreshToken: true, persistSession: true, detectSessionInUrl: true, @@ -50,14 +52,81 @@ export async function accessToken(): Promise { return data.session?.access_token ?? null; } +// True when the session is signed in with a password only but the user has a verified second factor. +export async function needsSecondFactor(): Promise { + const { data } = await auth().mfa.getAuthenticatorAssuranceLevel(); + return data?.currentLevel === 'aal1' && data.nextLevel === 'aal2'; +} + export type { Session }; // Supabase Auth error codes with a message of their own; any other error shows the server's message. -const AUTH_ERRORS = ['invalid_credentials', 'user_already_exists', 'email_not_confirmed', 'weak_password', 'over_email_send_rate_limit'] as const; +const AUTH_ERRORS = [ + 'invalid_credentials', + 'user_already_exists', + 'email_not_confirmed', + 'weak_password', + 'same_password', + 'over_email_send_rate_limit', + 'otp_expired', + 'flow_state_expired', + 'flow_state_not_found', + 'bad_code_verifier', + 'mfa_verification_failed', + 'mfa_challenge_expired', + 'insufficient_aal', +] as const; type AuthErrorCode = (typeof AUTH_ERRORS)[number]; +function knownCode(code: string | undefined): AuthErrorCode | null { + return AUTH_ERRORS.find((c) => c === code) ?? null; +} + export function authErrorMessage(error: { code?: string | undefined; message: string } | null, t: Translate): string | null { if (!error) return null; - const code = AUTH_ERRORS.find((c): c is AuthErrorCode => c === error.code); + const code = knownCode(error.code); return code ? t(`auth.errors.${code}`) : error.message; } + +// What happened to an email link (confirmation or recovery) the app was opened with, shown once on the sign-in page. +// Kept as a code, so the page shows it in the language it renders in. +export type CallbackNotice = { error: true; code: AuthErrorCode | null } | { error: false }; +let callbackNotice: CallbackNotice | null = null; + +// Runs once at startup, before the router reads the URL. A failed link lands here with Supabase Auth's error. +// A recovery link always opens the new-password screen, even if Supabase Auth redirected it elsewhere. +export async function settleCallback(): Promise { + let recovery = false; + const { data: listener } = auth().onAuthStateChange((event: string) => { + if (event === 'PASSWORD_RECOVERY') recovery = true; + }); + const { error } = await auth().initialize(); + listener.subscription.unsubscribe(); + const url = new URL(window.location.href); + const hadCode = url.searchParams.has('code'); + // The one-time code and any error leave the address bar and history; a leaked code could still be exchanged. + for (const key of ['code', 'error', 'error_code', 'error_description']) url.searchParams.delete(key); + if (/(^#|&)(error|error_code|error_description)=/.test(url.hash)) url.hash = ''; + const target = recovery ? '/reset-password' : `${url.pathname}${url.search}${url.hash}`; + if (target !== `${window.location.pathname}${window.location.search}${window.location.hash}`) window.history.replaceState(null, '', target); + if (error) { + // The message can come from the link's error_description, which anyone can write: show known codes only. + callbackNotice = { error: true, code: knownCode((error as { details?: { code?: string } }).details?.code ?? error.code) }; + } else if (hadCode && !(await accessToken())) { + // Supabase Auth accepted the link, but the one-time code belongs to another browser (PKCE). + callbackNotice = { error: false }; + } +} + +export function peekCallbackNotice(): CallbackNotice | null { + return callbackNotice; +} + +export function callbackNoticeText(notice: CallbackNotice, t: Translate): string { + if (!notice.error) return t('auth.linkVerified'); + return notice.code ? t(`auth.errors.${notice.code}`) : t('auth.linkInvalid'); +} + +export function clearCallbackNotice(): void { + callbackNotice = null; +} diff --git a/apps/app/src/lib/errors.ts b/apps/app/src/lib/errors.ts index 95bf2b25..4edeea7d 100644 --- a/apps/app/src/lib/errors.ts +++ b/apps/app/src/lib/errors.ts @@ -25,12 +25,16 @@ export async function apiErrorFromResponse(response: Response, t: Translate): Pr } // The session is valid but has not passed the user's second factor yet (issue #126): the kernel refuses every -// call until it has. Not a sign-out: the server's message tells the user to enter their MFA code, and the MFA -// challenge screen (issue #32) takes over once it ships. +// call until it has. Not a sign-out: the SPA opens the MFA challenge (/mfa, issue #32). export function isMfaRequired(error: unknown): boolean { return (error as { code?: string } | null)?.code === 'MFA_REQUIRED'; } +// R3 work (approve, membership changes) needs a second factor verified in the last 5 minutes (policy P11). +export function isStepUpRequired(error: unknown): boolean { + return (error as { code?: string } | null)?.code === 'STEP_UP_REQUIRED'; +} + export function isUnauthenticated(error: unknown): boolean { const e = error as { status?: number; code?: string } | null; if (isMfaRequired(error)) return false; diff --git a/apps/app/src/main.tsx b/apps/app/src/main.tsx index 8c28fa43..50cc6f68 100644 --- a/apps/app/src/main.tsx +++ b/apps/app/src/main.tsx @@ -5,7 +5,7 @@ import { createRoot } from 'react-dom/client'; import { I18nProvider } from './i18n/provider.tsx'; import { activeLocale, translator } from './i18n/runtime.ts'; import { queryClient } from './lib/api.ts'; -import { auth, loadConfig } from './lib/auth.ts'; +import { auth, loadConfig, settleCallback } from './lib/auth.ts'; import './styles.css'; const root = createRoot(document.getElementById('root') as HTMLElement); @@ -18,6 +18,7 @@ async function start() { root.render(

{translator()('common.serverUnreachable')}

); return; } + await settleCallback(); const { router } = await import('./router.tsx'); auth().onAuthStateChange((event: string) => { if (event === 'SIGNED_OUT') { diff --git a/apps/app/src/router.tsx b/apps/app/src/router.tsx index 675f024e..ae4ae37c 100644 --- a/apps/app/src/router.tsx +++ b/apps/app/src/router.tsx @@ -2,24 +2,32 @@ import { Outlet, createRootRoute, createRoute, createRouter, redirect, type Erro import { useTranslations } from 'use-intl'; import { Button } from '@crove/ui/components/button'; import { getCurrentOrg, isUnauthenticated, orpc, queryClient, setCurrentOrg } from './lib/api.ts'; -import { accessToken, auth, getConfig } from './lib/auth.ts'; -import { signOut } from './lib/session-actions.ts'; +import { accessToken, auth, getConfig, needsSecondFactor } from './lib/auth.ts'; import { pendingInvite, rememberInvite, takeTokenFromHash } from './lib/invite.ts'; +import { signOut } from './lib/session-actions.ts'; import { AuthPage } from './routes/auth.tsx'; import { DocumentsPage } from './routes/documents.tsx'; import { InboxDetailPage } from './routes/inbox-detail.tsx'; import { InboxPage } from './routes/inbox.tsx'; import { InvitePage } from './routes/invite.tsx'; import { JournalPage } from './routes/journal.tsx'; +import { MfaPage } from './routes/mfa.tsx'; import { OnboardingPage } from './routes/onboarding.tsx'; import { OrgLayout } from './routes/org-layout.tsx'; +import { ResetPasswordPage } from './routes/reset-password.tsx'; import { SettingsPage } from './routes/settings.tsx'; import { VendorsPage } from './routes/vendors.tsx'; -async function requireAuth() { +async function requireSession() { if (!(await accessToken())) throw redirect({ to: '/login' }); } +// A user with a verified second factor finishes signing in on /mfa before seeing any data. +async function requireAuth() { + await requireSession(); + if (await needsSecondFactor()) throw redirect({ to: '/mfa' }); +} + function ErrorPage({ error }: ErrorComponentProps) { const t = useTranslations('common'); return ( @@ -73,6 +81,24 @@ const inviteRoute = createRoute({ component: InvitePage, }); +const mfaRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/mfa', + beforeLoad: async () => { + await requireSession(); + if (!(await needsSecondFactor())) throw redirect({ to: '/' }); + }, + component: MfaPage, +}); + +// Reached from a recovery email link, which signs the user in for this one purpose. +const resetPasswordRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/reset-password', + beforeLoad: requireSession, + component: ResetPasswordPage, +}); + const onboardingRoute = createRoute({ getParentRoute: () => rootRoute, path: '/onboarding', @@ -117,6 +143,8 @@ const routeTree = rootRoute.addChildren([ indexRoute, loginRoute, inviteRoute, + mfaRoute, + resetPasswordRoute, onboardingRoute, orgRoute.addChildren([orgIndexRoute, inboxRoute, inboxDetailRoute, documentsRoute, vendorsRoute, journalRoute, settingsRoute]), ]); diff --git a/apps/app/src/routes/auth.tsx b/apps/app/src/routes/auth.tsx index 8f78ee43..ab5b037f 100644 --- a/apps/app/src/routes/auth.tsx +++ b/apps/app/src/routes/auth.tsx @@ -1,14 +1,13 @@ import { useNavigate } from '@tanstack/react-router'; -import { useState, type FormEvent } from 'react'; +import { useEffect, useState, type FormEvent } from 'react'; import { useTranslations } from 'use-intl'; -import { Alert } from '@crove/ui/components/alert'; import { Button } from '@crove/ui/components/button'; import { Card } from '@crove/ui/components/card'; import { Input } from '@crove/ui/components/input'; import { Label } from '@crove/ui/components/label'; -import { ErrorText, Logo } from '../components/ui.tsx'; +import { ErrorText, Logo, Notice } from '../components/ui.tsx'; import { LocaleSwitcher } from '../i18n/provider.tsx'; -import { auth, authErrorMessage } from '../lib/auth.ts'; +import { auth, authErrorMessage, callbackNoticeText, clearCallbackNotice, peekCallbackNotice } from '../lib/auth.ts'; type Mode = 'login' | 'signup' | 'reset'; @@ -20,8 +19,16 @@ export function AuthPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); + // The outcome of an email link the app was opened with, if any (see settleCallback). + const [error, setError] = useState(() => { + const callback = peekCallbackNotice(); + return callback?.error ? callbackNoticeText(callback, root) : null; + }); + const [notice, setNotice] = useState(() => { + const callback = peekCallbackNotice(); + return callback && !callback.error ? callbackNoticeText(callback, root) : null; + }); + useEffect(clearCallbackNotice, []); async function submit(event: FormEvent) { event.preventDefault(); @@ -39,7 +46,7 @@ export function AuthPage() { if (data.session) await navigate({ to: '/' }); else setNotice(t('signupSent')); } else { - const { error: e } = await auth().resetPasswordForEmail(email, { redirectTo: `${window.location.origin}/` }); + const { error: e } = await auth().resetPasswordForEmail(email, { redirectTo: `${window.location.origin}/reset-password` }); if (e) return setError(authErrorMessage(e, root)); setNotice(t('resetSent')); } @@ -79,11 +86,7 @@ export function AuthPage() { )} {error && {error}} - {notice && ( - - {notice} - - )} + {notice && {notice}} diff --git a/apps/app/src/routes/inbox-detail.tsx b/apps/app/src/routes/inbox-detail.tsx index 3c30c7b7..3101ac4a 100644 --- a/apps/app/src/routes/inbox-detail.tsx +++ b/apps/app/src/routes/inbox-detail.tsx @@ -9,8 +9,9 @@ import { Card } from '@crove/ui/components/card'; import { Checkbox } from '@crove/ui/components/checkbox'; import { Label } from '@crove/ui/components/label'; import { NativeSelect, NativeSelectOption } from '@crove/ui/components/native-select'; +import { StepUpPrompt } from '../components/two-factor.tsx'; import { ErrorText, PageHeader, Spinner } from '../components/ui.tsx'; -import { errorMessage, fetchFile, orpc, queryClient } from '../lib/api.ts'; +import { errorMessage, fetchFile, isStepUpRequired, orpc, queryClient } from '../lib/api.ts'; import { previewPurchaseJournal } from '../lib/decimal.ts'; import { useFormat } from '../i18n/provider.tsx'; import { reversalErrorMessage } from '../lib/reversal.ts'; @@ -151,6 +152,7 @@ export function InboxDetailPage() { await navigate({ to: '/o/$slug/inbox', params: { slug } }); }; const approve = useMutation(orpc.inbox.approve.mutationOptions({ onSuccess: done })); + const stepUp = isStepUpRequired(approve.error); const reject = useMutation(orpc.inbox.reject.mutationOptions({ onSuccess: done })); // Saving an edit creates a new revision authored by this user; the page then reloads the server's decision. const edit = useMutation( @@ -204,6 +206,20 @@ export function InboxDetailPage() { ) : null; const lastRun = item.extraction; const lastRunFailed = lastRun !== null && lastRun.status === 'failed' && lastRun.version !== lastRun.proposalVersion; + const submitApproval = () => + approve.mutate({ + id, + revision: item.revision, + idempotencyKey, + lineAccounts: lines.map((l) => ({ lineNo: l.lineNo, debitAccount: l.debitAccount })), + ...(failed > 0 ? { acceptFailedChecks: acceptFailed } : {}), + }); + // After a fresh code the step-up prompt repeats the refused request as sent, not the page as it is now: if someone + // saved a new revision meanwhile, the kernel answers STALE_REVISION instead of approving what this user never saw. + // The same idempotency key keeps it one approval. + const retryApproval = () => { + if (approve.variables) approve.mutate(approve.variables); + }; return ( <> @@ -265,19 +281,7 @@ export function InboxDetailPage() { )} {item.canApprove && !reversal && ( - @@ -299,10 +303,17 @@ export function InboxDetailPage() { } /> - {(approve.error || reject.error || edit.error || reextract.error) && ( + {stepUp && ( +
+ approve.reset()} /> +
+ )} + {((approve.error && !stepUp) || reject.error || edit.error || reextract.error) && (
- {reversal && approve.error ? reversalErrorMessage(approve.error, root) : errorMessage(approve.error ?? reject.error ?? edit.error ?? reextract.error)} + {reversal && approve.error && !stepUp + ? reversalErrorMessage(approve.error, root) + : errorMessage((stepUp ? null : approve.error) ?? reject.error ?? edit.error ?? reextract.error)}
)} diff --git a/apps/app/src/routes/mfa.tsx b/apps/app/src/routes/mfa.tsx new file mode 100644 index 00000000..58144e3c --- /dev/null +++ b/apps/app/src/routes/mfa.tsx @@ -0,0 +1,27 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useTranslations } from 'use-intl'; +import { MfaChallengeForm } from '../components/two-factor.tsx'; +import { Card } from '@crove/ui/components/card'; +import { Logo } from '../components/ui.tsx'; +import { signOut } from '../lib/session-actions.ts'; + +export function MfaPage() { + const navigate = useNavigate(); + const root = useTranslations(); + const t = useTranslations('twoFactor'); + return ( +
+ + +

{t('title')}

+

{t('signInIntro')}

+
+ navigate({ to: '/' })} /> +
+ +
+
+ ); +} diff --git a/apps/app/src/routes/reset-password.tsx b/apps/app/src/routes/reset-password.tsx new file mode 100644 index 00000000..2cb3ef8a --- /dev/null +++ b/apps/app/src/routes/reset-password.tsx @@ -0,0 +1,76 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useState, type FormEvent } from 'react'; +import { useTranslations } from 'use-intl'; +import { MfaChallengeForm } from '../components/two-factor.tsx'; +import { Button } from '@crove/ui/components/button'; +import { Card } from '@crove/ui/components/card'; +import { Input } from '@crove/ui/components/input'; +import { Label } from '@crove/ui/components/label'; +import { ErrorText, Logo, Spinner } from '../components/ui.tsx'; +import { auth, authErrorMessage, needsSecondFactor } from '../lib/auth.ts'; + +export function ResetPasswordPage() { + const navigate = useNavigate(); + const root = useTranslations(); + const t = useTranslations('resetPassword'); + // A recovery link alone does not get past a second factor: verify the code first. + const [needsCode, setNeedsCode] = useState(null); + const [password, setPassword] = useState(''); + const [repeat, setRepeat] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + void needsSecondFactor().then(setNeedsCode); + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (password !== repeat) return setError(t('mismatch')); + setBusy(true); + setError(null); + try { + const { error: e } = await auth().updateUser({ password }); + if (e) return setError(authErrorMessage(e, root)); + await navigate({ to: '/' }); + } finally { + setBusy(false); + } + } + + return ( +
+ + +

{t('title')}

+ {needsCode === null ? ( +
+ +
+ ) : needsCode ? ( + <> +

{t('needsCode')}

+
+ setNeedsCode(false)} /> +
+ + ) : ( +
+ + + {error && {error}} + +
+ )} +
+
+ ); +} diff --git a/apps/app/src/routes/settings.tsx b/apps/app/src/routes/settings.tsx index f66dabbf..d974729d 100644 --- a/apps/app/src/routes/settings.tsx +++ b/apps/app/src/routes/settings.tsx @@ -7,8 +7,9 @@ import { Button } from '@crove/ui/components/button'; import { Card } from '@crove/ui/components/card'; import { Input } from '@crove/ui/components/input'; import { NativeSelect, NativeSelectOption } from '@crove/ui/components/native-select'; +import { StepUpPrompt, TwoFactorCard } from '../components/two-factor.tsx'; import { ErrorText, PageHeader, Spinner } from '../components/ui.tsx'; -import { errorMessage, orpc, queryClient } from '../lib/api.ts'; +import { errorMessage, isStepUpRequired, orpc, queryClient } from '../lib/api.ts'; import { getConfig } from '../lib/auth.ts'; import { useFormat } from '../i18n/provider.tsx'; import { ROLES, roleChangeConfirmation, type Role, type RoleChangeConfirmation } from '../lib/members.ts'; @@ -84,7 +85,17 @@ function MemberControls({ member, self }: { member: MemberRow; self: boolean }) {root('common.cancel')} - {error && {errorMessage(error)}} + {error && + (isStepUpRequired(error) ? ( + // R3: after a fresh second factor the confirmed request runs again with its key. + + ) : ( + {errorMessage(error)} + ))} ); } @@ -110,15 +121,25 @@ function MemberControls({ member, self }: { member: MemberRow; self: boolean }) )} - {changeRole.error && {errorMessage(changeRole.error)}} + {changeRole.error && + (isStepUpRequired(changeRole.error) ? ( + // R3: after a fresh second factor the same request runs again (a refused request stores no key). + changeRole.variables && changeRole.mutate(changeRole.variables)} + onCancel={() => changeRole.reset()} + /> + ) : ( + {errorMessage(changeRole.error)} + ))} ); } // Owner only. One idempotency key per confirmation, so a retried request never rotates twice. R3 (#113): without a -// second factor in the last 5 minutes the server answers STEP_UP_REQUIRED, shown below the buttons like any error; -// the key is kept until a rotation succeeds, so a retry after a refusal can still rotate at most once. The fresh -// MFA challenge screen (StepUpPrompt) lands with the SPA MFA flows (#32). +// second factor in the last 5 minutes the server answers STEP_UP_REQUIRED and the step-up prompt asks for one; the +// key is kept until a rotation succeeds, so the retry after the code can still rotate at most once. function RotateInboundAddress() { const t = useTranslations('settings'); const tc = useTranslations('common'); @@ -135,6 +156,7 @@ function RotateInboundAddress() { }, }), ); + const rotateNow = () => rotate.mutate({ idempotencyKey }); if (!confirming) { return ( @@ -163,14 +185,26 @@ function RotateInboundAddress() {

{t('rotateBody')}

{t('rotateMfa')}

- -
- {rotate.error && {errorMessage(rotate.error)}} + {rotate.error && + (isStepUpRequired(rotate.error) ? ( + + ) : ( + {errorMessage(rotate.error)} + ))} ); } @@ -245,8 +279,19 @@ function InviteMember({ isOwner }: { isOwner: boolean }) { {t('invite')} - {invite.error && {errorMessage(invite.error)}} + {invite.error && !isStepUpRequired(invite.error) && {errorMessage(invite.error)}} + {isStepUpRequired(invite.error) && ( + // Inviting is R3. Outside the form above: the prompt has a form of its own. +
+ invite.variables && invite.mutate(invite.variables)} + onCancel={() => invite.reset()} + /> +
+ )} {invite.data && invite.reset()} />} ); @@ -358,6 +403,7 @@ export function SettingsPage() { )} + {me.data && } diff --git a/docker-compose.yml b/docker-compose.yml index 480060cd..aa85cb21 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,11 @@ services: GOTRUE_SMTP_PASS: ${SMTP_PASS:-} GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_FROM:-noreply@crove.local} GOTRUE_SMTP_SENDER_NAME: Crove + # Email links resolve these paths against API_EXTERNAL_URL's origin, so they must include the /auth/v1 proxy prefix. + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify depends_on: migrate: condition: service_completed_successfully @@ -99,9 +104,9 @@ services: AUTH_AUTOCONFIRM: ${AUTH_AUTOCONFIRM:-true} # Revocation: read Supabase Auth's sessions in the shared database (needs the auth-grants step). AUTH_SESSION_CHECK: db - # R3 (approve, membership changes) needs MFA in the last 5 minutes. off is accepted on localhost only, until - # the MFA enrollment screens ship (issue #32). - AUTH_STEP_UP: ${AUTH_STEP_UP:-off} + # R3 (approve, membership changes) needs MFA in the last 5 minutes; the app asks for a code, or to enable + # two-factor first. off is accepted on localhost only. + AUTH_STEP_UP: ${AUTH_STEP_UP:-required} DATABASE_URL: postgres://crove_app:${CROVE_APP_DB_PASSWORD:-local-app-password}@db:5432/postgres WORKER_DATABASE_URL: postgres://crove_worker:${CROVE_WORKER_DB_PASSWORD:-local-worker-password}@db:5432/postgres PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8080} diff --git a/docs/plan/crove-os-kernel.md b/docs/plan/crove-os-kernel.md index d82768ed..471706cb 100644 --- a/docs/plan/crove-os-kernel.md +++ b/docs/plan/crove-os-kernel.md @@ -113,7 +113,7 @@ Crove Cloud accepts its first external tenant only when every row below is green | # | Must be true | Evidence required | On `main` (2026-10-14) | Issue | |---|---|---|---|---| | T1 | Identity per ADR-0028 (accepted 2026-10-14, design C) implemented: issuer, audience and expiry required (boot fails without `AUTH_ISSUER`); `sub`, `session_id` and `role` required; anonymous tokens rejected; on every request the Crove session status record (`auth_session`) and membership checked in the request transaction, fail closed (self-host: `auth.sessions` and the user's ban or deletion read directly, read grant applied by the one-shot `auth-grants` step after Supabase Auth's first start; Cloud (`jwt`, amended 2026-10-15): the verified token plus Crove's records, Supabase Auth's default token lifetime for sign-outs outside Crove, no per-request Auth call); owner revoke immediate; R3 checked live against Supabase Auth (Cloud: `GET /user`; self-host: the per-request `db` read) with `aal2` and MFA in the last 5 minutes; a user with a verified MFA factor needs `aal2` for every request (#126); banned, deleted and disabled users rejected (Cloud: at once for R3, otherwise at token expiry) | The ADR-0028 acceptance tests, green in CI: unit tests for every missing or wrong claim; fresh install (`docker compose up` on empty volumes completes `auth-grants`, and the first sign-up, sign-in and API call succeed with the `db` check active); an old JWT after sign-out is rejected on the next request (self-host); on Cloud valid tokens pass with no Auth call and the R3 live check maps 200, 4xx and outages to pass, 401 and 503; owner revoke is immediate; a removed member gets 403; banned and deleted users are rejected; Auth outage behavior (no new logins, no R3, existing sessions per the ADR); R3 without fresh MFA is refused; an `aal1` token of a user with a verified factor gets 401 `MFA_REQUIRED` on reads and R1 commands while `aal2` and users without factors pass (#126). No DOS-side change needed, except that Cloud enforces #126 only once the identity provider states in the token that the user has a verified factor (ADR-0028 section 3) | Self-host done (#22): required claims, `auth_session` and `session_check` in the request transaction, `auth-grants`, revoke command with Supabase Auth sign-out, R3 needs fresh MFA. Cloud done (#22): `jwt` mode, the default without `AUTH_UPSTREAM_URL`, with the R3 live check. Self-host refuses `aal1` for users with a verified factor (#126, `auth.mfa_factors (user_id, status)` read after `auth-grants`; a missing table fails closed with 503, #180). Open: route enumeration test, job re-checks, #126 on Cloud (needs the factor fact from the identity provider) | #22, #126 | -| T2 | R3 approval restricted to authorized roles: today the roles that may post, `owner` and `admin`; once role assignments exist, the holders of the approval permission (§4.6, P12); `aal2` with MFA in the last 5 minutes and a direct Supabase Auth check (P11, ADR-0028); four-eyes when two eligible approvers exist (P10); the audit row records approver, `aal` and `session_id` | Database tests: `member` and `viewer` get 403 (in #71); an `aal1` token, or an `aal2` token whose MFA is older than 5 minutes, gets 403 `STEP_UP_REQUIRED`; an `aal2` token with fresh MFA executes; P10 and P12 cases; audit row content asserted | Partly done. #71 limits approval to `owner` and `admin` and carries the token's `aal` on the user actor. #75 enforces P12 (an eligible-approver snapshot taken when the proposal is created, plus a live role check with row locks at approval; `NOT_ELIGIBLE`) and P10 (four-eyes counting only humans: the uploader and every editor are authors, an author may not approve while 2 or more eligible approvers remain, `FOUR_EYES_REQUIRED`; a sole eligible approver may self-approve, flagged `self_approved` in the audit row; #88: when every eligible approver is an author, an owner may approve, flagged `owner_override`), with database tests; the audit row records approver, revision, `aal` and the P10 and P12 outcome; `kernel.inbox.get` returns `canApprove` and the SPA hides Approve from users the server would refuse. #22 requires `aal2` with MFA in the last 5 minutes (`STEP_UP_REQUIRED`) and the audit row records `aal`, `session_id` and the MFA time. #126 closes the path around it on self-host: an `aal1` token of a user with a verified factor cannot reach R1 or R2 work or any read either (401 `MFA_REQUIRED`). Open: the direct Supabase Auth check at execution (#22, Cloud work); no MFA screens in the SPA (#32) | #20 (closed by #71), #22, #75, #32 | +| T2 | R3 approval restricted to authorized roles: today the roles that may post, `owner` and `admin`; once role assignments exist, the holders of the approval permission (§4.6, P12); `aal2` with MFA in the last 5 minutes and a direct Supabase Auth check (P11, ADR-0028); four-eyes when two eligible approvers exist (P10); the audit row records approver, `aal` and `session_id` | Database tests: `member` and `viewer` get 403 (in #71); an `aal1` token, or an `aal2` token whose MFA is older than 5 minutes, gets 403 `STEP_UP_REQUIRED`; an `aal2` token with fresh MFA executes; P10 and P12 cases; audit row content asserted | Partly done. #71 limits approval to `owner` and `admin` and carries the token's `aal` on the user actor. #75 enforces P12 (an eligible-approver snapshot taken when the proposal is created, plus a live role check with row locks at approval; `NOT_ELIGIBLE`) and P10 (four-eyes counting only humans: the uploader and every editor are authors, an author may not approve while 2 or more eligible approvers remain, `FOUR_EYES_REQUIRED`; a sole eligible approver may self-approve, flagged `self_approved` in the audit row; #88: when every eligible approver is an author, an owner may approve, flagged `owner_override`), with database tests; the audit row records approver, revision, `aal` and the P10 and P12 outcome; `kernel.inbox.get` returns `canApprove` and the SPA hides Approve from users the server would refuse. #22 requires `aal2` with MFA in the last 5 minutes (`STEP_UP_REQUIRED`) and the audit row records `aal`, `session_id` and the MFA time. #126 closes the path around it on self-host: an `aal1` token of a user with a verified factor cannot reach R1 or R2 work or any read either (401 `MFA_REQUIRED`). Open: the direct Supabase Auth check at execution (#22, Cloud work). The SPA has TOTP enrollment, the sign-in challenge and a step-up prompt that asks for a fresh code (or to enable two-factor) when R3 answers `STEP_UP_REQUIRED`, then retries (#32) | #20 (closed by #71), #22, #75, #32 | | T3 | Ledger immutability: only the posting path (a definer) writes journal entries and lines; a posted entry cannot gain lines; every entry has at least 2 lines and balances at commit | Database tests as the module and login roles: direct INSERT into `journal_entry` or `journal_line` is denied; adding a line to an entry committed earlier fails; an entry with 0 or 1 line, or unbalanced, fails at commit; reversal nets to zero; a fast-check property test of the posting path | Done (#71, #76, #138). In #71, with database tests: `crove_finance.post_journal_entry`, owned by `crove_definer_tenant`, is the only writer (tenant roles lost INSERT on both tables); a `created_xact_id` stamp rejects lines added to an entry from any later transaction; entries with no lines and unbalanced entries are rejected at commit. Done in #76, with database tests (`apps/kernel/test/invariant/ledger-visibility.db.test.ts`, under a test-only RESTRICTIVE policy that hides some lines from every tenant role): the balance and line-count checks (`require_entry_balanced`, called by the commit trigger `check_entry_balanced` and by `assert_entry_balanced`) are definers owned by the migration runner, which owns `journal_line` and is not filtered by its row security (a migration guard fails otherwise), so they count every line of the one entry they check; no runtime role can call them, and `assert_entry_balanced` is callable only by `crove_definer_tenant` and only for the org in the sealed context; an entry that balances only over the visible lines is refused, a balanced entry with hidden lines is accepted, and an entry with exactly 1 line is refused; the refusal reports the difference over every line, which is safe only because a line can join an entry only in the transaction that created it, so the caller supplied every summed line (a future feature that appends lines to an existing entry must drop the amount from the error first); the posted-entry rule also compares `created_xact_start`, so an `xid8` repeated after a logical restore does not reopen an entry (§7.9). Done in #138, with database tests (`apps/kernel/test/invariant/ledger-reversal.db.test.ts`, `ledger-posting-property.db.test.ts`): a reversal is a new entry posted through `post_journal_entry` (an overload that takes `reverses_entry_id`; the original signature calls it), whose lines mirror the original's (same line number, account, party and memo, debit and credit swapped), linked by `reverses_entry_id` (composite FK `(org_id, reverses_entry_id)` and a unique index, so an entry is reversed at most once; of two concurrent reversals the second waits on the index and fails); it never adds lines to an existing entry, so the balance error above stays safe; a reversal cannot itself be reversed (to restore the original effect, post a new entry) and is never dated before the original; the commit trigger on `journal_entry` (once per entry) and `assert_entry_balanced` also run `require_entry_mirrors`, a definer owned by the migration runner that compares every line of both entries and reports no amounts, and each line's trigger checks that line against the original's line with the same number (so running the checks early with `SET CONSTRAINTS ... IMMEDIATE` and adding lines afterwards is refused too); the self-FK is `ON DELETE NO ACTION`, so deleting an original that has a reversal fails; so the rules hold for every writer, superusers included, and nobody can reverse an entry with lines they cannot see; the pair nets to zero per account. A reversal is a posting, so it runs only through an approved proposal under the same rules as a purchase (§4.7: R3, P10, P11, P12, approvers `owner` and `admin`): `kernel.proposal.request` (R1, `owner`, `admin` or `member`) runs `finance.journal.prepare_reversal`, which checks the entry and builds the proposal, and `kernel.proposal.approve` runs `finance.journal.reverse` (R3, proposal only). A fast-check property test (fixed seed, 100 runs) posts random line sets as `crove_app` and commits: a set is accepted exactly when it has at least 2 lines and balances. Since #165: the API route `inbox.request` (an idempotency key) and the screens: "Đảo bút toán" on the journal (date, required reason), a reversal card in the Inbox, the reversal link both ways in the journal, and a plain message when the database refuses a partial mirror; reversing a purchase entry voids Crove's record of its document (Phase 2a, Finance) | #19 (closed by #71), #76, #138, #165 | | T4 | Org creation runs through `execute()` with an audit row | Database test: creating an org writes `tenant`, the owner `tenant_member` and an `audit_event` row in one transaction; login roles cannot execute the raw definer (EXECUTE matrix test); a per-user rate limit | Done in #96 and #107, with database tests (`apps/kernel/test/kernel.db.test.ts`, `apps/kernel/test/org-create-limit.db.test.ts`): `kernel.org.create` is an org-less command run by `Runtime.executeOrgless()`. The definer `create_or_replay_org`, executable by `crove_app` alone, creates the tenant and the owner membership (or returns the org an earlier request with the same idempotency key created, serialized by a per-user advisory lock); the runtime then enters that org as the caller and writes the audit row and the idempotency key in the same transaction, and a replay creates nothing. A deferred constraint trigger refuses to commit a tenant without its `kernel.org.create` audit row written by the tenant's creator (the creator check since #102), even for the database owner; the raw `create_org` definer is dropped; the EXECUTE matrix test covers both functions. Since #107 the definer also refuses a new org, under the same lock and so across API instances, when the caller created 5 in the last 24 hours or 20 in total, closed orgs included (`ORG_CREATE_LIMIT_PER_DAY`, `ORG_CREATE_LIMIT_TOTAL`): 429 `RATE_LIMITED`, nothing written; a replay of an existing key does not count; of concurrent requests at the boundary exactly one wins. A refusal has no org to be audited in, so it writes no audit row until `security_event` exists (§4.4 step 3) | #23, #94 | | T5 | Inbound email processing moved out of the API role's system context: the API verifies the shared secret, limits size and stores the raw message; parsing and `kernel.inbound.receive` run in the worker | EXECUTE matrix test: the API login role cannot call `enter_system_context`; integration test: a forwarded e-invoice still yields a proposal, processed by the worker | Done in #78, with database tests (`apps/kernel/test/kernel.db.test.ts`): the API verifies the secret, limits size, stores the raw MIME as `inbound/` and enqueues it through the narrow definer `inbound_enqueue` (202; 503 past 10000 pending messages); the worker checks the blob's size and hash, parses it, runs `kernel.inbound.receive` and deletes the blob once the message is filed or dropped. Only `crove_worker` may execute `enter_system_context`, which also requires `session_user` to be `crove_worker`; only `crove_app` may execute `enter_context`; startup guards refuse an API role that can enter system context and a worker role that cannot. The EXECUTE matrix test and the forwarded e-invoice test (HTTP app, then the real worker, then a proposal) are the evidence | #24 | @@ -1240,7 +1240,7 @@ Identity comes from Supabase Auth (Decision 5, ADR-0027); how the kernel verifie Supabase Auth issues and owns sessions (Decision 5, ADR-0027). ADR-0028 (accepted 2026-10-14, design C; Decision 16) sets how the kernel verifies them, tracks their status and revokes them. -- **Login (SPA):** `@supabase/auth-js` with PKCE: email + password, magic link, Google/Microsoft/OIDC providers, MFA and password reset. Self-host runs Supabase Auth in the compose stack behind the kernel's `/auth/v1` proxy; Crove Cloud points the same code at DOS's Supabase Auth (accounts shared with DOS ID). Sign-up, email confirmation and SMTP are Supabase Auth settings; the kernel refuses to start on a public URL with auto-confirm on or a sample JWT secret (implemented in `config.ts`). Password recovery, MFA enrollment and callback screens: issue #32. A user with a verified MFA factor finishes signing in with the challenge before any data: on self-host the kernel refuses that user's `aal1` token on every request with 401 `MFA_REQUIRED` (#126), and the SPA answers it with the challenge, not a sign-out, so a password or a recovery link alone opens nothing. +- **Login (SPA):** `@supabase/auth-js` with PKCE: email + password, magic link, Google/Microsoft/OIDC providers, MFA and password reset. Self-host runs Supabase Auth in the compose stack behind the kernel's `/auth/v1` proxy; Crove Cloud points the same code at DOS's Supabase Auth (accounts shared with DOS ID). Sign-up, email confirmation and SMTP are Supabase Auth settings; the kernel refuses to start on a public URL with auto-confirm on or a sample JWT secret (implemented in `config.ts`). On `main` (#32): PKCE callbacks for the confirmation and recovery links (the bundled Supabase Auth builds those links with the `/auth/v1` prefix), a new-password screen, TOTP enrollment under Settings and the sign-in challenge (`/mfa`); Playwright covers these plus refresh and sign-out; magic link and OAuth providers are not built yet. A user with a verified MFA factor finishes signing in with the challenge before any data: on self-host the kernel refuses that user's `aal1` token on every request with 401 `MFA_REQUIRED` (#126), and the SPA answers it with the challenge, not a sign-out, so a password or a recovery link alone opens nothing. - **Kernel, every request** (ADR-0028): - verifies the access token locally with `jose`: JWKS (ES256/RS256) when `AUTH_JWKS_URL` is set, otherwise HS256 with the deployment's JWT secret; issuer, audience and expiry required, plus `sub`, `session_id` and `role = authenticated`; anonymous tokens rejected; - in the request transaction, checks the Crove session status record (`auth_session`, by user and `session_id`), the user's state and membership, fail closed. The record's upstream status comes from one `SessionStatusSource` interface with two adapters: @@ -1250,7 +1250,7 @@ Supabase Auth issues and owns sessions (Decision 5, ADR-0027). ADR-0028 (accepte - maps `(issuer, subject)` to `user_account`; `enter_context` checks membership in every transaction. - **Revoke session or device:** the user, an org owner or admin (for a member of their org) and staff revoke one session or all sessions of a user through an audited command. Crove marks the record `revoked` in the same transaction, so it takes effect immediately in both adapters, then asks Supabase Auth to sign the session out so its refresh tokens are destroyed (self-host: the admin sign-out with a short-lived token for that session; Cloud: Crove-side only, since Crove has no admin access to DOS's Supabase Auth). A refresh keeps the `session_id`, so the revoked record also blocks refreshed tokens. - **On `main`:** the self-host path is implemented (#22): issuer, audience, expiry, `sub`, `session_id` and `role` are required and boot fails without `AUTH_ISSUER`; every request runs `crove_kernel.session_check` in its transaction (Crove record, `disabled_at`, and with `AUTH_SESSION_CHECK=db` the `auth.sessions` and `auth.users` rows, read after `auth-grants`, which also adds a SELECT policy because Supabase Auth enables row-level security on those tables); through `kernel.session.revoke` the user revokes their own sessions globally (the worker then signs them out in Supabase Auth, `local` or `global`), and an org owner revokes a member's session in that org only (`auth_session_org`; no global record, no upstream sign-out, because one org's owner has no say over the member's other orgs); a user with a verified MFA factor gets 401 `MFA_REQUIRED` for an `aal1` token on every request (#126). Crove Cloud runs `AUTH_SESSION_CHECK=jwt` (Crove records only per request, R3 live check with `GET /user`; boot fails on a public URL without `PUBLIC_AUTH_URL` or `AUTH_UPSTREAM_URL`). Not yet: approver re-checks in jobs, the per-org P11 opt-out, `disabled_at` staff commands and the route enumeration test. External-tenant gate T1 tracks the rest. -- **Step-up (R3):** posting, legal or money actions, membership and role changes check Supabase Auth live at execution time (Cloud: `GET /user` with the token, no cache, after the role and MFA checks; 200 for the same user passes, other 4xx is 401, network error, 429 or 5xx is 503; self-host: the per-request `db` read of `auth.sessions` and `auth.users` is that check) and need `aal = aal2` with a second-factor `amr` timestamp in the last 5 minutes (policy P11); otherwise 403 `STEP_UP_REQUIRED` and the SPA runs an MFA challenge. Jobs that execute approved R3 work re-check the approver's session record and permission first. On `main`, every R3 command run by a user requires `aal2` and a second-factor `amr` timestamp in the last 5 minutes, and the audit row records `aal`, `session_id` and that timestamp (#22); `AUTH_STEP_UP=off` is accepted on a local URL only, until the MFA screens ship (#32). The live check is implemented for Cloud (`jwt`, #22). +- **Step-up (R3):** posting, legal or money actions, membership and role changes check Supabase Auth live at execution time (Cloud: `GET /user` with the token, no cache, after the role and MFA checks; 200 for the same user passes, other 4xx is 401, network error, 429 or 5xx is 503; self-host: the per-request `db` read of `auth.sessions` and `auth.users` is that check) and need `aal = aal2` with a second-factor `amr` timestamp in the last 5 minutes (policy P11); otherwise 403 `STEP_UP_REQUIRED` and the SPA runs an MFA challenge. Jobs that execute approved R3 work re-check the approver's session record and permission first. On `main`, every R3 command run by a user requires `aal2` and a second-factor `amr` timestamp in the last 5 minutes, and the audit row records `aal`, `session_id` and that timestamp (#22); Compose defaults to `AUTH_STEP_UP=required` since the MFA screens shipped (#32); `off` is accepted on a local URL only. The live check is implemented for Cloud (`jwt`, #22). - **Supabase Auth down:** no new logins, no token refresh and no R3 (503); existing sessions keep working (self-host: as long as the DB is up; Crove Cloud: until each access token expires); alert (§7.10). - **Access token lifetime:** the self-host compose stack sets 15 minutes (`GOTRUE_JWT_EXP=900`) as defense in depth; revocation does not depend on it. Crove Cloud uses DOS's setting. - **API and MCP clients:** `/v1` and `/mcp` accept only tokens from the OAuth 2.1 authorization server (Decision 14, Phase 3), never a user's browser token. @@ -1668,7 +1668,7 @@ Day 0 is behind us: the repo exists, is public and has the MVP. This list keeps **H. Spikes** (each <=1 week; each ends with "pass" or a fallback recorded in an ADR): - Supabase Auth on the plain `postgres:17` image (Decision 5): the compose stack on `main` runs it; keep it covered by the Playwright end-to-end job (T9). -- Supabase Auth flow in the SPA: PKCE callback, refresh, logout, MFA and `aal2` (Decision 6; issue #32). +- Supabase Auth flow in the SPA: PKCE callback, refresh, logout, MFA and `aal2` (Decision 6): pass in #32, covered by the Playwright `auth` tests against the bundled Supabase Auth. - A2 pg-boss over Supavisor :5432 and :6543 (Crove Cloud). - A3 Kanel 4 (issue #48). - A5 PG17 on Supabase: `GRANT … WITH SET/INHERIT`, definer ownership, the §4.5 re-entry guard behind Supavisor transaction mode, what Supavisor resets on a server connection between transactions (#117; Cloud runs `DB_RESET_ON_RELEASE=false` and relies on it), whether `postgres` can set `session_replication_role`. diff --git a/docs/plan/crove-os-kernel.vi.html b/docs/plan/crove-os-kernel.vi.html index 2a436d30..56cda288 100644 --- a/docs/plan/crove-os-kernel.vi.html +++ b/docs/plan/crove-os-kernel.vi.html @@ -274,7 +274,7 @@

4. Cổng trước khách ngoài

Crove Cloud chỉ nhận tenant bên ngoài đầu tiên (org mà thành viên không phải đội của owner) khi mọi dòng dưới đây xanh và bằng chứng đã được liên kết. Tình trạng tính tới 14/10/2026; số issue là issue trong repo này. Điều kiện chi tiết và bằng chứng yêu cầu: bản tiếng Anh.

- + diff --git a/e2e/compose.e2e.yml b/e2e/compose.e2e.yml new file mode 100644 index 00000000..72083670 --- /dev/null +++ b/e2e/compose.e2e.yml @@ -0,0 +1,18 @@ +# Test-only settings on top of ../docker-compose.yml. Sign-up needs the confirmation email, as in production +# (the tests read it from Mailpit), and orgs get an inbound address (nothing delivers to it) so Settings shows the +# rotation. Every test signs up and recovers from the runner's one address, often for an address just mailed, so +# the email budgets (#190) are raised: Supabase Auth's deployment-wide one and the proxy's per-client and +# per-recipient limits, keeping 10 clients' worth of the per-client limit inside the budget as the kernel requires. +services: + auth: + environment: + GOTRUE_MAILER_AUTOCONFIRM: 'false' + GOTRUE_RATE_LIMIT_EMAIL_SENT: '10000' + crove: + environment: + AUTH_AUTOCONFIRM: 'false' + AUTH_RATE_LIMIT_EMAIL_SENT: '10000' + AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR: '1000' + AUTH_EMAIL_ADDRESS_INTERVAL_SECONDS: '0' + INBOUND_DOMAIN: in.e2e.test + INBOUND_SECRET: e2e-only-inbound-secret-0123456789abcdef diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index d7e3c0b3..74623b38 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,5 +1,7 @@ -// Waits until the stack is ready: every migration applied (/readyz) and Supabase Auth answering through the proxy. +// Waits until the stack is ready: every migration applied (/readyz), Supabase Auth answering through the proxy +// and Mailpit, where the tests read the emails Supabase Auth sends. import type { FullConfig } from '@playwright/test'; +import { MAILPIT_URL } from './lib/mail.ts'; const TIMEOUT_MS = 180_000; @@ -12,7 +14,7 @@ export default async function globalSetup(config: FullConfig): Promise { const baseURL = config.projects[0]?.use.baseURL ?? 'http://localhost:8080'; const deadline = Date.now() + TIMEOUT_MS; while (Date.now() < deadline) { - if ((await ok(`${baseURL}/readyz`)) && (await ok(`${baseURL}/auth/v1/health`))) return; + if ((await ok(`${baseURL}/readyz`)) && (await ok(`${baseURL}/auth/v1/health`)) && (await ok(`${MAILPIT_URL}/api/v1/info`))) return; await new Promise((resolve) => setTimeout(resolve, 2_000)); } throw new Error(`Crove at ${baseURL} is not ready after ${TIMEOUT_MS / 1000} s; start it with: pnpm --filter @crove/e2e stack:up`); diff --git a/e2e/lib/db.ts b/e2e/lib/db.ts index 009fbf5e..381dda35 100644 --- a/e2e/lib/db.ts +++ b/e2e/lib/db.ts @@ -3,12 +3,12 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -const COMPOSE_FILE = fileURLToPath(new URL('../../docker-compose.yml', import.meta.url)); +const COMPOSE_FILES = ['../../docker-compose.yml', '../compose.e2e.yml'].map((f) => fileURLToPath(new URL(f, import.meta.url))); const PROJECT = process.env.E2E_COMPOSE_PROJECT ?? 'crove-e2e'; // `query` uses psql variables (:'name') for every value in `vars`. Returns the rows as JSON objects. export function sql(query: string, vars: Record = {}): T[] { - const args = ['compose', '-p', PROJECT, '-f', COMPOSE_FILE, 'exec', '-T', 'db', 'psql', '-U', 'postgres', '-d', 'postgres', '-X', '-q', '-t', '-A']; + const args = ['compose', '-p', PROJECT, ...COMPOSE_FILES.flatMap((f) => ['-f', f]), 'exec', '-T', 'db', 'psql', '-U', 'postgres', '-d', 'postgres', '-X', '-q', '-t', '-A']; args.push('-v', 'ON_ERROR_STOP=1'); for (const [name, value] of Object.entries(vars)) args.push('-v', `${name}=${value}`); const script = `SELECT coalesce(json_agg(row_to_json(q)), '[]'::json) FROM (${query}) q;\n`; diff --git a/e2e/lib/mail.ts b/e2e/lib/mail.ts new file mode 100644 index 00000000..06723a5d --- /dev/null +++ b/e2e/lib/mail.ts @@ -0,0 +1,34 @@ +// Reads the emails Supabase Auth sent through the stack's Mailpit (http://localhost:8025). +import { expect } from '@playwright/test'; + +export const MAILPIT_URL = process.env.E2E_MAILPIT_URL ?? 'http://localhost:8025'; + +interface Summary { + ID: string; + Subject: string; +} + +async function json(path: string): Promise { + const response = await fetch(`${MAILPIT_URL}${path}`); + if (!response.ok) throw new Error(`Mailpit ${path}: ${response.status}`); + return (await response.json()) as T; +} + +// Waits for the newest email to `to` whose subject matches, and returns the Supabase Auth link in it. +export async function authLink(to: string, subject: RegExp): Promise { + let link = ''; + await expect + .poll( + async () => { + const { messages } = await json<{ messages: Summary[] }>(`/api/v1/search?query=${encodeURIComponent(`to:"${to}"`)}`); + const message = messages.find((m) => subject.test(m.Subject)); + if (!message) return ''; + const { HTML } = await json<{ HTML: string }>(`/api/v1/message/${message.ID}`); + link = /href="([^"]*\/verify\?[^"]*)"/.exec(HTML)?.[1]?.replaceAll('&', '&') ?? ''; + return link; + }, + { message: `email to ${to} matching ${subject}`, timeout: 30_000 }, + ) + .not.toBe(''); + return link; +} diff --git a/e2e/lib/session.ts b/e2e/lib/session.ts new file mode 100644 index 00000000..1d3d2b90 --- /dev/null +++ b/e2e/lib/session.ts @@ -0,0 +1,58 @@ +// Sign-up and sign-in through the SPA, as a user does it. +import { expect, type Page } from '@playwright/test'; +import { authLink } from './mail.ts'; + +export const PASSWORD = 'e2e-password-0123'; + +export function newEmail(tag: string): string { + return `e2e-${tag}-${Date.now()}-${Math.floor(Math.random() * 1e6)}@example.com`; +} + +// Signs up, opens the confirmation link from the email in the same browser (PKCE) and lands on onboarding. +export async function signUp(page: Page, email: string, password = PASSWORD): Promise { + await page.goto('/login'); + await page.getByRole('button', { name: 'Tạo tài khoản' }).click(); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Mật khẩu').fill(password); + await page.getByRole('button', { name: 'Tạo tài khoản' }).click(); + await expect(page.getByText('Đã gửi email xác nhận')).toBeVisible(); + await page.goto(await authLink(email, /confirm/i)); + await expect(page).toHaveURL(/\/onboarding$/); +} + +export async function signIn(page: Page, email: string, password = PASSWORD): Promise { + await page.goto('/login'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Mật khẩu').fill(password); + await page.getByRole('button', { name: 'Đăng nhập' }).click(); +} + +// The app's own sign-out control: the sidebar's inside an org, the only one elsewhere (onboarding, /mfa). The +// sessions card in Settings has a "Đăng xuất" button per device as well. +export async function signOut(page: Page): Promise { + const sidebar = page.getByRole('complementary').getByRole('button', { name: 'Đăng xuất', exact: true }); + await ((await sidebar.count()) > 0 ? sidebar : page.getByRole('button', { name: 'Đăng xuất', exact: true })).click(); +} + +export async function createOrg(page: Page, name: string): Promise { + await page.getByLabel('Tên công ty hoặc hộ kinh doanh').fill(name); + await page.getByRole('button', { name: 'Tạo tổ chức' }).click(); + await expect(page).toHaveURL(/\/o\/[^/]+\/inbox$/); + return new URL(page.url()).pathname.split('/')[2] ?? ''; +} + +export interface StoredSession { + access_token: string; + refresh_token: string; + expires_at: number; +} + +// The session auth-js keeps in localStorage (storage key `crove-auth`). +export async function storedSession(page: Page): Promise { + const raw = await page.evaluate(() => window.localStorage.getItem('crove-auth')); + return raw ? (JSON.parse(raw) as StoredSession) : null; +} + +export function claims(token: string): Record { + return JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')) as Record; +} diff --git a/e2e/lib/totp.ts b/e2e/lib/totp.ts new file mode 100644 index 00000000..e3dda435 --- /dev/null +++ b/e2e/lib/totp.ts @@ -0,0 +1,35 @@ +// RFC 6238 TOTP (SHA-1, 6 digits, 30 s), as authenticator apps compute it for Supabase Auth factors. +import { createHmac } from 'node:crypto'; + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const STEP_MS = 30_000; + +function base32(secret: string): Buffer { + let bits = ''; + for (const char of secret.replace(/=+$/, '').toUpperCase()) { + const value = ALPHABET.indexOf(char); + if (value < 0) throw new Error(`not base32: ${char}`); + bits += value.toString(2).padStart(5, '0'); + } + const bytes: number[] = []; + for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(Number.parseInt(bits.slice(i, i + 8), 2)); + return Buffer.from(bytes); +} + +function code(secret: string, step: number): string { + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(step)); + const mac = createHmac('sha1', base32(secret)).update(counter).digest(); + const offset = (mac[mac.length - 1] ?? 0) & 0x0f; + return ((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).toString().padStart(6, '0'); +} + +// Returns a code from a time step after `usedStep` (waiting if needed), so a code is never submitted twice. +export async function nextTotp(secret: string, usedStep = -1): Promise<{ code: string; step: number }> { + let step = Math.floor(Date.now() / STEP_MS); + while (step <= usedStep) { + await new Promise((resolve) => setTimeout(resolve, (step + 1) * STEP_MS - Date.now() + 50)); + step = Math.floor(Date.now() / STEP_MS); + } + return { code: code(secret, step), step }; +} diff --git a/e2e/package.json b/e2e/package.json index 5aa62eed..7e09e4ed 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -6,9 +6,9 @@ "license": "AGPL-3.0-only", "description": "Playwright end-to-end tests against the docker compose stack (MVP exit criterion 4)", "scripts": { - "stack:up": "docker compose -p crove-e2e -f ../docker-compose.yml up -d --build", - "stack:logs": "docker compose -p crove-e2e -f ../docker-compose.yml logs --no-color", - "stack:down": "docker compose -p crove-e2e -f ../docker-compose.yml down -v", + "stack:up": "docker compose -p crove-e2e -f ../docker-compose.yml -f compose.e2e.yml up -d --build", + "stack:logs": "docker compose -p crove-e2e -f ../docker-compose.yml -f compose.e2e.yml logs --no-color", + "stack:down": "docker compose -p crove-e2e -f ../docker-compose.yml -f compose.e2e.yml down -v", "test": "playwright test", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 00000000..6c6d9f77 --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,219 @@ +// Supabase Auth flows in the SPA (Decision 6, issue #32): email confirmation and recovery callbacks (PKCE), +// TOTP enrollment and the sign-in challenge, token refresh and sign-out. +import { expect, test } from '@playwright/test'; +import { authLink } from '../lib/mail.ts'; +import { PASSWORD, claims, createOrg, newEmail, signIn, signOut, signUp, storedSession } from '../lib/session.ts'; +import { nextTotp } from '../lib/totp.ts'; + +test('the confirmation link signs the new user in, and a used link shows why it failed', async ({ page }) => { + const email = newEmail('confirm'); + await signUp(page, email); + const session = await storedSession(page); + expect(session && claims(session.access_token).email).toBe(email); + + await signOut(page); + await expect(page).toHaveURL(/\/login$/); + await page.goto(await authLink(email, /confirm/i)); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('alert')).toContainText('Liên kết đã hết hạn hoặc đã được dùng'); + + // Anyone can write a link's error text, so only known error codes are explained; the parameters leave the URL. + await page.goto('/login?error=access_denied&error_code=made_up&error_description=Call+0900+000+000+to+unlock'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('alert')).toHaveText('Liên kết không hợp lệ hoặc đã hết hạn. Hãy yêu cầu liên kết mới.'); +}); + +test('password recovery sets a new password through the emailed link', async ({ page }) => { + const email = newEmail('recover'); + const newPassword = 'e2e-new-password-4567'; + await signUp(page, email); + await signOut(page); + + await page.getByRole('button', { name: 'Quên mật khẩu?' }).click(); + await page.getByLabel('Email').fill(email); + await page.getByRole('button', { name: 'Quên mật khẩu' }).click(); + await expect(page.getByText('thư đặt lại mật khẩu đã được gửi')).toBeVisible(); + + await page.goto(await authLink(email, /reset/i)); + await expect(page).toHaveURL(/\/reset-password$/); + await page.getByLabel('Mật khẩu mới', { exact: true }).fill(newPassword); + await page.getByLabel('Nhập lại mật khẩu mới').fill(newPassword); + await page.getByRole('button', { name: 'Lưu mật khẩu' }).click(); + await expect(page).toHaveURL(/\/onboarding$/); + await signOut(page); + + await signIn(page, email, PASSWORD); + await expect(page.getByRole('alert')).toHaveText('Email hoặc mật khẩu không đúng'); + await signIn(page, email, newPassword); + await expect(page).toHaveURL(/\/onboarding$/); +}); + +test('a user enrolls TOTP; sign-in and password recovery then ask for the code (aal2)', async ({ page }) => { + const email = newEmail('mfa'); + await signUp(page, email); + const slug = await createOrg(page, 'Công ty MFA'); + + await page.getByRole('navigation').getByRole('link', { name: 'Cài đặt' }).click(); + await page.getByRole('button', { name: 'Bật xác thực hai lớp' }).click(); + const secret = (await page.getByLabel('Khóa thiết lập').textContent())?.trim() ?? ''; + expect(secret).toMatch(/^[A-Z2-7]+=*$/); + + await page.getByLabel('Mã xác thực').fill('000000'); + await page.getByRole('button', { name: 'Xác nhận' }).click(); + await expect(page.getByRole('alert')).toHaveText('Mã xác thực không đúng hoặc đã hết hạn'); + + const enrolled = await nextTotp(secret); + await page.getByLabel('Mã xác thực').fill(enrolled.code); + await page.getByRole('button', { name: 'Xác nhận' }).click(); + await expect(page.getByText('Đã bật xác thực hai lớp.')).toBeVisible(); + await expect(page.getByText('Đang bật')).toBeVisible(); + const upgraded = await storedSession(page); + expect(upgraded && claims(upgraded.access_token).aal).toBe('aal2'); + + await signOut(page); + await signIn(page, email); + await expect(page).toHaveURL(/\/mfa$/); + // Data routes stay behind the challenge. + await page.goto(`/o/${slug}/inbox`); + await expect(page).toHaveURL(/\/mfa$/); + const signIn2 = await storedSession(page); + expect(signIn2 && claims(signIn2.access_token).aal).toBe('aal1'); + + const login = await nextTotp(secret, enrolled.step); + await page.getByLabel('Mã xác thực').fill(login.code); + await page.getByRole('button', { name: 'Xác nhận' }).click(); + await expect(page).toHaveURL(new RegExp(`/o/${slug}/inbox$`)); + const verified = await storedSession(page); + expect(verified && claims(verified.access_token).aal).toBe('aal2'); + + // A recovery link alone does not get past the second factor. + await signOut(page); + await page.getByRole('button', { name: 'Quên mật khẩu?' }).click(); + await page.getByLabel('Email').fill(email); + await page.getByRole('button', { name: 'Quên mật khẩu' }).click(); + await expect(page.getByText('thư đặt lại mật khẩu đã được gửi')).toBeVisible(); + await page.goto(await authLink(email, /reset/i)); + await expect(page).toHaveURL(/\/reset-password$/); + await expect(page.getByText('Nhập mã trước khi đặt mật khẩu mới')).toBeVisible(); + await expect(page.getByLabel('Mật khẩu mới', { exact: true })).toHaveCount(0); + const recovery = await nextTotp(secret, login.step); + await page.getByLabel('Mã xác thực').fill(recovery.code); + await page.getByRole('button', { name: 'Xác nhận' }).click(); + await page.getByLabel('Mật khẩu mới', { exact: true }).fill('e2e-mfa-new-password-89'); + await page.getByLabel('Nhập lại mật khẩu mới').fill('e2e-mfa-new-password-89'); + await page.getByRole('button', { name: 'Lưu mật khẩu' }).click(); + await expect(page).toHaveURL(new RegExp(`/o/${slug}/inbox$`)); +}); + +test('an expired access token is refreshed, and sign-out revokes the refresh token', async ({ page, request }) => { + const email = newEmail('refresh'); + await signUp(page, email); + const before = await storedSession(page); + if (!before) throw new Error('no session after sign-up'); + + // Pretend the access token expired a minute ago; on reload auth-js must refresh before the app calls the API. + await page.evaluate(() => { + const stored = JSON.parse(window.localStorage.getItem('crove-auth') ?? '{}') as { expires_at?: number }; + stored.expires_at = Math.floor(Date.now() / 1000) - 60; + window.localStorage.setItem('crove-auth', JSON.stringify(stored)); + }); + const refreshed = page.waitForResponse((r) => r.url().includes('/auth/v1/token?grant_type=refresh_token')); + await page.goto('/'); + expect((await refreshed).status()).toBe(200); + // The index route asked the kernel for /me with the refreshed token before sending the user on. + await expect(page).toHaveURL(/\/onboarding$/); + const after = await storedSession(page); + expect(after?.refresh_token).not.toBe(before.refresh_token); + expect(after && after.expires_at > Date.now() / 1000).toBe(true); + + await signOut(page); + await expect(page).toHaveURL(/\/login$/); + expect(await storedSession(page)).toBeNull(); + const reuse = await request.post('/auth/v1/token?grant_type=refresh_token', { data: { refresh_token: after?.refresh_token } }); + expect(reuse.status()).toBe(400); +}); + +test('managing members asks for two-factor first, then works once it is on', async ({ page, browser }) => { + const owner = newEmail('owner'); + await signUp(page, owner); + const slug = await createOrg(page, 'Công ty thành viên'); + await page.getByRole('navigation').getByRole('link', { name: 'Cài đặt' }).click(); + + // Inviting is R3, and the owner has no second factor yet. + const member = newEmail('member'); + await page.getByPlaceholder('email@congty.vn').fill(member); + await page.getByRole('button', { name: 'Mời', exact: true }).click(); + await expect(page.getByText(`Đang chờ: mời ${member} với vai trò Thành viên.`)).toBeVisible(); + await expect(page.getByText('Bật xác thực hai lớp ở ô bên cạnh rồi thử lại.')).toBeVisible(); + + await page.getByRole('button', { name: 'Bật xác thực hai lớp' }).click(); + const secret = (await page.getByLabel('Khóa thiết lập').textContent())?.trim() ?? ''; + await page.getByLabel('Mã xác thực').fill((await nextTotp(secret)).code); + await page.getByRole('button', { name: 'Xác nhận' }).click(); + await expect(page.getByText('Đang bật')).toBeVisible(); + + await page.getByRole('button', { name: 'Mời', exact: true }).click(); + const created = page.getByRole('status').filter({ hasText: `Đã tạo lời mời cho ${member}` }); + const link = (await created.getByText(/\/-\/invite#/).textContent())?.trim() ?? ''; + expect(link).toMatch(/\/-\/invite#/); + + // The member accepts in a browser of their own. + const memberContext = await browser.newContext({ baseURL: new URL(page.url()).origin }); + const memberPage = await memberContext.newPage(); + await signUp(memberPage, member); + await memberPage.goto(link); + await memberPage.getByRole('button', { name: 'Chấp nhận lời mời' }).click(); + await expect(memberPage).toHaveURL(new RegExp(`/o/${slug}/inbox$`)); + + // Changing a role is R3 as well; the owner's code is still fresh. Promotion to owner is confirmed inline (#119). + await page.reload(); + const role = page.getByRole('listitem').filter({ hasText: member }).getByLabel(`Vai trò của ${member}`, { exact: true }); + await expect(role).toHaveValue('member'); + await role.selectOption('owner'); + await page.getByRole('button', { name: 'Xác nhận đặt làm chủ sở hữu' }).click(); + await expect(role).toHaveValue('owner'); + + // The new co-owner has no second factor: changing the first owner's role asks for one first. + await memberPage.goto(`/o/${slug}/settings`); + const first = memberPage.getByRole('listitem').filter({ hasText: owner }); + const firstRole = first.getByLabel(`Vai trò của ${owner}`, { exact: true }); + await firstRole.selectOption('admin'); + await first.getByRole('button', { name: 'Xác nhận đổi vai trò' }).click(); + await expect(first.getByText(`Đang chờ: đổi vai trò của ${owner} thành Quản trị.`)).toBeVisible(); + await memberPage.getByRole('button', { name: 'Bật xác thực hai lớp' }).click(); + const memberSecret = (await memberPage.getByLabel('Khóa thiết lập').textContent())?.trim() ?? ''; + await memberPage.getByLabel('Mã xác thực').fill((await nextTotp(memberSecret)).code); + await memberPage.getByRole('button', { name: 'Xác nhận', exact: true }).click(); + await expect(memberPage.getByText('Đang bật')).toBeVisible(); + + // With the second factor fresh, the confirmed change goes through, then the removal. + await first.getByRole('button', { name: 'Xác nhận đổi vai trò' }).click(); + await expect(firstRole).toHaveValue('admin'); + await first.getByRole('button', { name: 'Xóa', exact: true }).click(); + await first.getByRole('button', { name: 'Xác nhận xóa' }).click(); + await expect(first).toHaveCount(0); + await memberContext.close(); +}); + +test('rotating the inbound address asks for two-factor first', async ({ page }) => { + await signUp(page, newEmail('rotate')); + await createOrg(page, 'Công ty đổi địa chỉ'); + await page.getByRole('navigation').getByRole('link', { name: 'Cài đặt' }).click(); + const before = (await page.getByText(/@in\.e2e\.test$/).first().textContent())?.trim() ?? ''; + expect(before).toMatch(/@in\.e2e\.test$/); + + // Rotation is R3 (#142): without a second factor the confirmation turns into the step-up prompt. + await page.getByRole('button', { name: 'Đổi địa chỉ email nhận hóa đơn' }).click(); + await page.getByRole('button', { name: 'Xác nhận đổi địa chỉ' }).click(); + await expect(page.getByText('Đang chờ: đổi địa chỉ email nhận hóa đơn.')).toBeVisible(); + await page.getByRole('button', { name: 'Bật xác thực hai lớp' }).click(); + const secret = (await page.getByLabel('Khóa thiết lập').textContent())?.trim() ?? ''; + await page.getByLabel('Mã xác thực').fill((await nextTotp(secret)).code); + await page.getByRole('button', { name: 'Xác nhận', exact: true }).click(); + await expect(page.getByText('Đang bật')).toBeVisible(); + + await page.getByRole('button', { name: 'Xác nhận đổi địa chỉ' }).click(); + const rotated = page.getByRole('status').filter({ hasText: 'Đã đổi địa chỉ' }); + await expect(rotated).toContainText(/@in\.e2e\.test/); + await expect(rotated).not.toContainText(before); +}); diff --git a/e2e/tests/inbox.spec.ts b/e2e/tests/inbox.spec.ts index 0bd23a20..105f8295 100644 --- a/e2e/tests/inbox.spec.ts +++ b/e2e/tests/inbox.spec.ts @@ -5,6 +5,8 @@ import { expect, test } from '@playwright/test'; import { Decimal } from 'decimal.js'; import ExcelJS from 'exceljs'; import { sql } from '../lib/db.ts'; +import { createOrg, newEmail, signUp } from '../lib/session.ts'; +import { nextTotp } from '../lib/totp.ts'; const INVOICE = fileURLToPath(new URL('../../apps/extract/test/fixtures/einvoice-basic.xml', import.meta.url)); const SELLER = 'CÔNG TY TNHH DỊCH VỤ MẪU'; @@ -15,24 +17,18 @@ const MEMO = `HĐ C26TAB-1234 ${SELLER}`; const eq = (value: unknown, expected: string) => new Decimal(String(value)).equals(expected); test('sign up, upload an e-invoice, approve it, then find it in the lists, the export and the database', async ({ page }) => { - const email = `e2e-${Date.now()}@example.com`; + const email = newEmail('inbox'); - await test.step('sign up', async () => { + await test.step('sign up and confirm the email', async () => { await page.goto('/'); await expect(page).toHaveURL(/\/login$/); - await page.getByRole('button', { name: 'Tạo tài khoản' }).click(); - await page.getByLabel('Email').fill(email); - await page.getByLabel('Mật khẩu').fill('e2e-password-0123'); - await page.getByRole('button', { name: 'Tạo tài khoản' }).click(); - await expect(page).toHaveURL(/\/onboarding$/); + await signUp(page, email); }); const slug = await test.step('create the first org', async () => { - await page.getByLabel('Tên công ty hoặc hộ kinh doanh').fill('Công ty E2E'); - await page.getByRole('button', { name: 'Tạo tổ chức' }).click(); - await expect(page).toHaveURL(/\/o\/[^/]+\/inbox$/); + const created = await createOrg(page, 'Công ty E2E'); await expect(page.getByRole('heading', { name: 'Hộp thư chứng từ' })).toBeVisible(); - return new URL(page.url()).pathname.split('/')[2] ?? ''; + return created; }); await test.step('upload the e-invoice and wait for the proposal', async () => { @@ -50,6 +46,14 @@ test('sign up, upload an e-invoice, approve it, then find it in the lists, the e const approve = page.getByRole('button', { name: 'Duyệt và ghi sổ' }); await expect(approve).toBeEnabled(); await approve.click(); + // Approving is R3: without a second factor the kernel answers STEP_UP_REQUIRED and the page offers to enable one, + // then approves again by itself. + await expect(page.getByText('Việc này cần xác thực hai lớp.')).toBeVisible(); + await expect(page.getByText('Đang chờ: duyệt và ghi sổ chứng từ này.')).toBeVisible(); + await page.getByRole('button', { name: 'Bật xác thực hai lớp' }).click(); + const secret = (await page.getByLabel('Khóa thiết lập').textContent())?.trim() ?? ''; + await page.getByLabel('Mã xác thực').fill((await nextTotp(secret)).code); + await page.getByRole('button', { name: 'Xác nhận' }).click(); await expect(page).toHaveURL(new RegExp(`/o/${slug}/inbox$`)); await expect(page.getByText('Không có đề xuất nào chờ duyệt')).toBeVisible(); await page.getByRole('tab', { name: 'Đã ghi sổ' }).click(); @@ -153,9 +157,21 @@ test('sign up, upload an e-invoice, approve it, then find it in the lists, the e expect.arrayContaining([ { command: 'kernel.file.ingest', outcome: 'ok', actor_type: 'user', by_user: true }, { command: 'kernel.extraction.record', outcome: 'ok', actor_type: 'system', by_user: false }, + { command: 'kernel.proposal.approve', outcome: 'denied', actor_type: 'user', by_user: true }, { command: 'kernel.proposal.approve', outcome: 'ok', actor_type: 'user', by_user: true }, { command: 'finance.purchase.record', outcome: 'ok', actor_type: 'user', by_user: true }, ]), ); + + // The denied attempt was aal1; the approval ran with the fresh second factor (policy P11). + const approvals = sql<{ outcome: string; code: string | null; aal: string; mfa: boolean }>( + `SELECT outcome, detail->>'code' AS code, detail->>'aal' AS aal, detail->>'mfaAt' IS NOT NULL AS mfa + FROM crove_kernel.audit_event WHERE org_id = :'org' AND command = 'kernel.proposal.approve' ORDER BY occurred_at, id`, + vars, + ); + expect(approvals).toEqual([ + { outcome: 'denied', code: 'STEP_UP_REQUIRED', aal: 'aal1', mfa: false }, + { outcome: 'ok', code: null, aal: 'aal2', mfa: true }, + ]); }); });
#Phải đúngHiện trạngIssue
T1Danh tính theo ADR-0028: bắt buộc issuer, audience, expiry, sub, session_id; kiểm phiên và membership mỗi request, fail closed; R3 cần aal2Một phần
Self-host xong (#22): bắt buộc đủ claim, kiểm auth_session và auth.sessions trong transaction của request (bước auth-grants), chủ hoặc admin thu hồi phiên có hiệu lực ngay, R3 cần MFA trong 5 phút; người dùng đã bật MFA mà dùng token aal1 bị từ chối ở mọi request với 401 MFA_REQUIRED (#126). Cloud xong (#22): chế độ jwt (mặc định khi không có AUTH_UPSTREAM_URL), R3 gọi GET /user (200 đúng user thì qua, 4xx khác thì 401, lỗi mạng, 429, 5xx thì 503); SLA đăng xuất ngoài Crove bằng thời hạn JWT của project. Còn mở: test liệt kê route, job kiểm lại người duyệt, #126 trên Cloud (cần nhà cung cấp danh tính ghi vào token việc người dùng đã bật MFA).
#22, #126
T2Duyệt R3 chỉ cho role được phép; aal2 với MFA mới; four-eyes (P10); audit ghi approver, aal, session_idMột phần
#71 giới hạn cho owner và admin. #75 ép P12 (chụp danh sách người được duyệt lúc tạo đề xuất, kiểm tra lại vai trò ngay lúc duyệt) và P10 (four-eyes chỉ tính người; người tải chứng từ lên và người chỉnh sửa là tác giả; người duyệt duy nhất được tự duyệt, có cờ self_approved trong audit; #88: khi mọi người được duyệt đều là tác giả, owner được duyệt, có cờ owner_override); Inbox ẩn nút Duyệt với người không được duyệt. #22 ép aal2 với MFA trong 5 phút và audit ghi aal, session_id; chưa có màn MFA (#32).
#20, #22, #75, #32
T2Duyệt R3 chỉ cho role được phép; aal2 với MFA mới; four-eyes (P10); audit ghi approver, aal, session_idMột phần
#71 giới hạn cho owner và admin. #75 ép P12 (chụp danh sách người được duyệt lúc tạo đề xuất, kiểm tra lại vai trò ngay lúc duyệt) và P10 (four-eyes chỉ tính người; người tải chứng từ lên và người chỉnh sửa là tác giả; người duyệt duy nhất được tự duyệt, có cờ self_approved trong audit; #88: khi mọi người được duyệt đều là tác giả, owner được duyệt, có cờ owner_override); Inbox ẩn nút Duyệt với người không được duyệt. #22 ép aal2 với MFA trong 5 phút và audit ghi aal, session_id; SPA đã có bật MFA (TOTP), bước nhập mã khi đăng nhập, và hỏi lại mã (hoặc mời bật MFA) khi duyệt cần MFA mới (#32).
#20, #22, #75, #32
T3Sổ cái bất biến: chỉ đường post ghi bút toán; bút toán cân và có ít nhất 2 dòng lúc commitĐã xong
#71 đã làm, có test DB. #76: kiểm tra cân và số dòng đọc mọi dòng của bút toán, kể cả dòng người gọi không thấy (test với policy RESTRICTIVE); không role runtime nào gọi được với tham số tùy ý; xid8 sau khi restore logic không mở lại bút toán cũ (§7.9). #138: đảo bút toán (reversal) ghi một bút toán MỚI qua post_journal_entry, các dòng đối xứng với bút toán gốc (đổi Nợ và Có), liên kết qua reverses_entry_id; mỗi bút toán chỉ đảo được một lần, kể cả khi hai yêu cầu chạy đồng thời; không đảo một bút toán đảo (muốn khôi phục thì ghi bút toán mới); không ghi ngày trước bút toán gốc; database so đối xứng trên mọi dòng của cả hai bút toán, kể cả dòng người gọi không thấy. Đảo bút toán cũng là ghi sổ nên phải qua đề xuất và duyệt như hóa đơn mua vào (R3, P10, P11, P12). Có property test bằng fast-check cho đường post. #165: có API route inbox.request (kèm idempotency key) và giao diện: nút "Đảo bút toán" trên sổ bút toán (chọn ngày, bắt buộc nhập lý do), thẻ đề xuất đảo bút toán trong Inbox, liên kết hai chiều giữa bút toán gốc và bút toán đảo, và thông báo rõ ràng khi database từ chối vì người duyệt không thấy hết các dòng. Đảo bút toán của chứng từ mua thì Crove hủy chứng từ đó trong sổ của mình (một dòng hủy append-only trỏ tới bút toán đảo), để ghi sổ lại đúng hóa đơn với tài khoản đúng; trùng hóa đơn chỉ bỏ qua chứng từ đã hủy. Đây chỉ là chỉnh sổ của Crove, hóa đơn điện tử của người bán không đổi; cần kế toán xác nhận cách xử lý này.
#19, #76, #138, #165
T4Tạo org đi qua execute() và có dòng auditĐã xong
PR #96: tạo org là lệnh kernel.org.create chạy qua execute(), ghi tenant, owner, dòng audit và khóa idempotency trong cùng một transaction; gửi lại cùng khóa không tạo thêm gì; tenant không có dòng audit thì không commit được. PR #107: mỗi người dùng tạo tối đa 5 org trong 24 giờ và 20 org tổng cộng, tính cả org đã đóng (đổi qua env ORG_CREATE_LIMIT_PER_DAY, ORG_CREATE_LIMIT_TOTAL); definer đếm dưới advisory lock theo người dùng nên giới hạn đúng trên mọi instance API, hai yêu cầu đồng thời ở ngưỡng chỉ một cái thắng; vượt giới hạn trả 429 RATE_LIMITED và không ghi gì; gửi lại khóa idempotency cũ không bị tính. Lần bị từ chối chưa có dòng audit vì chưa có org để ghi, chờ security_event.
#23, #94
T5Xử lý email đến chuyển khỏi system context của role API sang workerĐã xong
PR #78: API chỉ kiểm secret và lưu email thô; worker parse và xử lý; chỉ crove_worker được vào system context.
#24