Feat/cloud backup (#277)

* feat: cloud backup to Google Drive + full-archive all-scope

Adds a cloud-backup extension that uploads a full-company backup ZIP to
the user's own Google Drive via OAuth (drive.file scope only). Refresh
tokens are AES-256-GCM encrypted before being stored in extension_data.

The full-archive export gains a scope=all mode for whole-company
backups (per-period SIE under sie/, per-period rapporter/ subfolders,
flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size
guard short-circuits generation before the platform response limit.

Also fixes a latent bug in lib/core/audit/audit-service.ts where the
parameter was named userId while the query filtered by company_id; the
audit-trail API route was passing user.id so audit queries returned
empty unless user and company shared a UUID.

Drive-by: scope the dashboard "fresh start" localStorage key per
companyId so dismissing the setup checklist in one company no longer
carries over to others.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review comments on cloud backup + archive export

- Extend audit trail to_date to end-of-day so last-day entries aren't
  silently excluded from period-scoped archives.
- Apply 413 size-limit guard regardless of include_documents, using the
  overhead-only figure when documents are excluded.
- Use crypto.randomUUID() for Drive multipart boundary to eliminate any
  collision risk with ZIP payload bytes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: migrate legacy setup-gate localStorage keys on dashboard

Users who previously dismissed the setup checklist via the old global
erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after
the switch to a company-scoped key. Fall back to the legacy keys on read
and migrate them to the scoped key on first hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: update customer email handling and anonymization rules in supportmail-to-ticket skill

* test: update audit trail to_date expectation for end-of-day timestamp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-04-20 10:49:59 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 2ea5a72b3d
commit d708a85d4c
34 changed files with 2900 additions and 314 deletions
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
encryptToken,
decryptToken,
createOAuthState,
verifyOAuthState,
} from '../crypto'
beforeEach(() => {
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key-that-is-long-enough'
})
describe('token encryption', () => {
it('round-trips a refresh token', () => {
const token = '1//0abcdef_refresh_token_value'
const encrypted = encryptToken(token)
expect(encrypted).not.toContain(token)
expect(decryptToken(encrypted)).toBe(token)
})
it('produces different ciphertext for the same plaintext (IV is random)', () => {
const token = 'same-token'
const a = encryptToken(token)
const b = encryptToken(token)
expect(a).not.toBe(b)
expect(decryptToken(a)).toBe(token)
expect(decryptToken(b)).toBe(token)
})
it('fails to decrypt tampered ciphertext', () => {
const encrypted = encryptToken('secret')
// Flip a byte in the middle of the ciphertext.
const buf = Buffer.from(encrypted, 'base64url')
buf[30] ^= 0xff
const tampered = buf.toString('base64url')
expect(() => decryptToken(tampered)).toThrow()
})
})
describe('OAuth state', () => {
it('round-trips userId and companyId', () => {
const state = createOAuthState('user-1', 'company-1')
expect(verifyOAuthState(state)).toEqual({
userId: 'user-1',
companyId: 'company-1',
})
})
it('returns null for garbage state', () => {
expect(verifyOAuthState('not-a-valid-state')).toBeNull()
})
it('returns null for expired state', () => {
const state = createOAuthState('user-1', 'company-1')
// Fast-forward past the 10-minute TTL.
const realNow = Date.now
Date.now = () => realNow() + 11 * 60 * 1000
try {
expect(verifyOAuthState(state)).toBeNull()
} finally {
Date.now = realNow
}
})
})
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ensureFolder, uploadFile } from '../google-drive'
beforeEach(() => {
vi.restoreAllMocks()
})
describe('ensureFolder', () => {
it('returns existing folder when found', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ files: [{ id: 'folder-1', name: 'gnubok' }] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
const folder = await ensureFolder('at', 'gnubok', null)
expect(folder.id).toBe('folder-1')
// Only the search call; no create needed.
expect(fetchMock).toHaveBeenCalledTimes(1)
const url = fetchMock.mock.calls[0][0] as string
expect(url).toContain('/files?q=')
expect(decodeURIComponent(url)).toContain(`name = 'gnubok'`)
expect(decodeURIComponent(url)).toContain(`'root' in parents`)
})
it('creates a new folder when none exists', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(JSON.stringify({ files: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'new-id', name: 'gnubok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const folder = await ensureFolder('at', 'gnubok', null)
expect(folder.id).toBe('new-id')
expect(fetchMock).toHaveBeenCalledTimes(2)
const createCall = fetchMock.mock.calls[1]
expect((createCall[1] as RequestInit).method).toBe('POST')
const body = JSON.parse(String((createCall[1] as RequestInit).body))
expect(body.mimeType).toBe('application/vnd.google-apps.folder')
expect(body.name).toBe('gnubok')
})
it('escapes single quotes in folder name and scopes to parent', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
// findFolderByName → match so we never hit create.
new Response(JSON.stringify({ files: [{ id: 'x', name: "Kalle's" }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
await ensureFolder('at', "Kalle's", 'parent-id')
const searchUrl = decodeURIComponent(fetchMock.mock.calls[0][0] as string)
expect(searchUrl).toContain(`name = 'Kalle\\'s'`)
expect(searchUrl).toContain(`'parent-id' in parents`)
})
})
describe('uploadFile', () => {
it('posts multipart body with metadata + binary and returns parsed result', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
id: 'file-123',
name: 'arkiv.zip',
size: '2048',
webViewLink: 'https://drive.google.com/file/d/file-123/view',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
const data = new Uint8Array([1, 2, 3, 4, 5]).buffer
const result = await uploadFile('access-tok', 'folder-1', 'arkiv.zip', data)
expect(result.id).toBe('file-123')
expect(result.size_bytes).toBe(2048)
expect(result.web_view_link).toContain('file-123')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toContain('uploadType=multipart')
const contentType = (init as RequestInit).headers as Record<string, string>
expect(contentType.Authorization).toBe('Bearer access-tok')
expect(contentType['Content-Type']).toContain('multipart/related')
// Body must be a Buffer that contains the metadata JSON.
const body = (init as RequestInit).body as Buffer
expect(Buffer.isBuffer(body)).toBe(true)
expect(body.toString('utf8')).toContain('"name":"arkiv.zip"')
expect(body.toString('utf8')).toContain('"parents":["folder-1"]')
})
it('throws with Drive error body when upload fails', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('quota exceeded', { status: 403 })
)
await expect(
uploadFile('at', 'folder', 'a.zip', new Uint8Array(1).buffer)
).rejects.toThrow(/403/)
})
})
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
buildAuthorizationUrl,
exchangeCodeForTokens,
refreshAccessToken,
getOAuthEnv,
} from '../google-oauth'
beforeEach(() => {
process.env.GOOGLE_CLIENT_ID = 'test-client-id'
process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret'
vi.restoreAllMocks()
})
describe('getOAuthEnv', () => {
it('builds redirect URI from origin', () => {
const env = getOAuthEnv('https://app.example.com')
expect(env.redirectUri).toBe(
'https://app.example.com/api/extensions/ext/cloud-backup/oauth/callback'
)
expect(env.clientId).toBe('test-client-id')
})
it('throws when env vars missing', () => {
delete process.env.GOOGLE_CLIENT_ID
expect(() => getOAuthEnv('http://localhost:3000')).toThrow(/GOOGLE_CLIENT_ID/)
})
})
describe('buildAuthorizationUrl', () => {
it('includes scope, offline access, consent prompt, and state', () => {
const env = getOAuthEnv('http://localhost:3000')
const url = buildAuthorizationUrl(env, 'abc123state')
const parsed = new URL(url)
expect(parsed.origin + parsed.pathname).toBe(
'https://accounts.google.com/o/oauth2/v2/auth'
)
expect(parsed.searchParams.get('access_type')).toBe('offline')
expect(parsed.searchParams.get('prompt')).toBe('consent')
expect(parsed.searchParams.get('state')).toBe('abc123state')
expect(parsed.searchParams.get('scope')).toContain(
'https://www.googleapis.com/auth/drive.file'
)
})
})
describe('exchangeCodeForTokens', () => {
it('posts form-encoded body and parses token response', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
access_token: 'at',
refresh_token: 'rt',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
const env = getOAuthEnv('http://localhost:3000')
const result = await exchangeCodeForTokens(env, 'auth-code')
expect(result.refresh_token).toBe('rt')
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://oauth2.googleapis.com/token')
expect((init as RequestInit).method).toBe('POST')
expect(String((init as RequestInit).body)).toContain('grant_type=authorization_code')
expect(String((init as RequestInit).body)).toContain('code=auth-code')
})
it('throws a clear error when no refresh_token is returned', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ access_token: 'at', expires_in: 3600 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const env = getOAuthEnv('http://localhost:3000')
await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/refresh token/i)
})
it('throws on non-OK response', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('Bad Request', { status: 400 })
)
const env = getOAuthEnv('http://localhost:3000')
await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/400/)
})
})
describe('refreshAccessToken', () => {
it('returns a fresh access token', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ access_token: 'new-at', expires_in: 3600 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const env = getOAuthEnv('http://localhost:3000')
const result = await refreshAccessToken(env, 'old-refresh')
expect(result.access_token).toBe('new-at')
})
})
@@ -0,0 +1,77 @@
import crypto from 'crypto'
/**
* AES-256-GCM encryption for long-lived refresh tokens stored in
* extension_data. Key is derived from SUPABASE_SERVICE_ROLE_KEY (same
* trust boundary as the database itself — anyone who can exfiltrate the
* key can already read the data).
*/
const ALGORITHM = 'aes-256-gcm'
function getKey(): Buffer {
const secret = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!secret) throw new Error('SUPABASE_SERVICE_ROLE_KEY is required')
// Scope the key with a purpose string so this can't be confused with
// oauth-codes.ts's derivation if both are ever compromised together.
return crypto
.createHash('sha256')
.update('cloud-backup:v1:' + secret)
.digest()
}
export function encryptToken(plaintext: string): string {
const key = getKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
}
export function decryptToken(ciphertext: string): string {
const key = getKey()
const combined = Buffer.from(ciphertext, 'base64url')
const iv = combined.subarray(0, 12)
const tag = combined.subarray(12, 28)
const encrypted = combined.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
return decrypted.toString('utf8')
}
/**
* Short-lived signed state parameter for OAuth CSRF protection.
*
* The state encodes `{userId, companyId, exp}` and is verified on the
* callback. Stateless (no DB round-trip) and self-expiring.
*/
const STATE_TTL_MS = 10 * 60 * 1000
interface StatePayload {
u: string
c: string
e: number
}
export function createOAuthState(userId: string, companyId: string): string {
const payload: StatePayload = {
u: userId,
c: companyId,
e: Date.now() + STATE_TTL_MS,
}
return encryptToken(JSON.stringify(payload))
}
export function verifyOAuthState(
state: string
): { userId: string; companyId: string } | null {
try {
const payload = JSON.parse(decryptToken(state)) as StatePayload
if (Date.now() > payload.e) return null
return { userId: payload.u, companyId: payload.c }
} catch {
return null
}
}
@@ -0,0 +1,162 @@
/**
* Minimal Google Drive v3 client — just enough to:
* - find or create a named folder,
* - upload a file via multipart.
*
* We operate on `drive.file` scope, so we can only see files we created.
* Queries by name return only app-created folders with that name.
*/
const DRIVE_API = 'https://www.googleapis.com/drive/v3'
const DRIVE_UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3/files'
const FOLDER_MIME = 'application/vnd.google-apps.folder'
interface DriveFile {
id: string
name: string
}
async function driveFetch(
accessToken: string,
path: string,
init?: RequestInit
): Promise<Response> {
const res = await fetch(`${DRIVE_API}${path}`, {
...init,
headers: {
...(init?.headers || {}),
Authorization: `Bearer ${accessToken}`,
},
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Drive API ${res.status}: ${body.slice(0, 200)}`)
}
return res
}
/**
* Find a folder by name under a parent (or root). Returns null if none exists.
* Uses q= filter; drive.file scope only sees app-created folders.
*/
async function findFolderByName(
accessToken: string,
name: string,
parentId: string | null
): Promise<DriveFile | null> {
const parentClause = parentId ? `'${parentId}' in parents` : `'root' in parents`
const q = [
`mimeType = '${FOLDER_MIME}'`,
`name = '${escapeName(name)}'`,
parentClause,
'trashed = false',
].join(' and ')
const url = `/files?q=${encodeURIComponent(q)}&fields=files(id,name)&pageSize=1`
const res = await driveFetch(accessToken, url)
const json = (await res.json()) as { files: DriveFile[] }
return json.files[0] || null
}
async function createFolder(
accessToken: string,
name: string,
parentId: string | null
): Promise<DriveFile> {
const body = {
name,
mimeType: FOLDER_MIME,
parents: parentId ? [parentId] : undefined,
}
const res = await driveFetch(accessToken, '/files?fields=id,name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return (await res.json()) as DriveFile
}
export async function ensureFolder(
accessToken: string,
name: string,
parentId: string | null
): Promise<DriveFile> {
const existing = await findFolderByName(accessToken, name, parentId)
if (existing) return existing
return createFolder(accessToken, name, parentId)
}
export interface UploadResult {
id: string
name: string
size_bytes: number
web_view_link: string
}
/**
* Multipart upload: metadata + bytes in one request. Suitable for files
* up to ~100 MB; beyond that Drive recommends resumable uploads.
*/
export async function uploadFile(
accessToken: string,
folderId: string,
fileName: string,
data: ArrayBuffer,
contentType = 'application/zip'
): Promise<UploadResult> {
const boundary = `gnubok-${crypto.randomUUID().replace(/-/g, '')}`
const metadata = JSON.stringify({
name: fileName,
parents: [folderId],
})
const head =
`--${boundary}\r\n` +
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
`${metadata}\r\n` +
`--${boundary}\r\n` +
`Content-Type: ${contentType}\r\n\r\n`
const tail = `\r\n--${boundary}--`
const body = Buffer.concat([
Buffer.from(head, 'utf8'),
Buffer.from(data),
Buffer.from(tail, 'utf8'),
])
const res = await fetch(
`${DRIVE_UPLOAD_API}?uploadType=multipart&fields=id,name,size,webViewLink`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': `multipart/related; boundary=${boundary}`,
'Content-Length': String(body.length),
},
body,
}
)
if (!res.ok) {
const errText = await res.text()
throw new Error(`Drive upload failed: ${res.status} ${errText.slice(0, 200)}`)
}
const json = (await res.json()) as {
id: string
name: string
size?: string
webViewLink?: string
}
return {
id: json.id,
name: json.name,
size_bytes: json.size ? Number(json.size) : data.byteLength,
web_view_link: json.webViewLink || `https://drive.google.com/file/d/${json.id}/view`,
}
}
function escapeName(name: string): string {
// Drive query string: escape single quotes and backslashes.
return name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
}
@@ -0,0 +1,130 @@
/**
* Minimal Google OAuth 2.0 client for the cloud-backup extension.
*
* Scope: `drive.file` — app-created files only, not the user's full Drive.
* Access type: `offline` — returns a refresh token on first consent.
* Prompt: `consent` — forces the consent screen so the refresh token is
* re-issued even if the user has previously authorised the app.
*/
const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
const USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo'
export interface OAuthEnv {
clientId: string
clientSecret: string
redirectUri: string
}
export function getOAuthEnv(origin: string): OAuthEnv {
const clientId = process.env.GOOGLE_CLIENT_ID
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
if (!clientId || !clientSecret) {
throw new Error(
'Google OAuth is not configured: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET'
)
}
return {
clientId,
clientSecret,
redirectUri: `${origin}/api/extensions/ext/cloud-backup/oauth/callback`,
}
}
export function buildAuthorizationUrl(env: OAuthEnv, state: string): string {
const params = new URLSearchParams({
client_id: env.clientId,
redirect_uri: env.redirectUri,
response_type: 'code',
scope: `openid email ${DRIVE_SCOPE}`,
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'true',
state,
})
return `${AUTH_ENDPOINT}?${params.toString()}`
}
export interface TokenExchangeResult {
access_token: string
refresh_token: string
expires_in: number
id_token?: string
}
export async function exchangeCodeForTokens(
env: OAuthEnv,
code: string
): Promise<TokenExchangeResult> {
const body = new URLSearchParams({
code,
client_id: env.clientId,
client_secret: env.clientSecret,
redirect_uri: env.redirectUri,
grant_type: 'authorization_code',
})
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) {
const errText = await res.text()
throw new Error(`Google token exchange failed: ${res.status} ${errText}`)
}
const json = (await res.json()) as TokenExchangeResult
if (!json.refresh_token) {
throw new Error(
'No refresh token returned — Google only issues one on first consent. ' +
'Revoke the app at myaccount.google.com/permissions and try again.'
)
}
return json
}
export interface AccessTokenResult {
access_token: string
expires_in: number
}
export async function refreshAccessToken(
env: OAuthEnv,
refreshToken: string
): Promise<AccessTokenResult> {
const body = new URLSearchParams({
client_id: env.clientId,
client_secret: env.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
})
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) {
const errText = await res.text()
throw new Error(`Google token refresh failed: ${res.status} ${errText}`)
}
return (await res.json()) as AccessTokenResult
}
export async function revokeToken(token: string): Promise<void> {
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
}
export async function fetchUserEmail(accessToken: string): Promise<string> {
const res = await fetch(USERINFO_ENDPOINT, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) {
throw new Error(`Failed to fetch Google user info: ${res.status}`)
}
const json = (await res.json()) as { email?: string }
return json.email || 'unknown@google'
}