compassmock/src/lib/netsuite/auth/token-manager.ts
Nicholai fbd31b58ae
feat(netsuite): add NetSuite integration and financials (#29)
* feat(schema): add auth, people, and financial tables

Add users, organizations, teams, groups, and project
members tables. Extend customers/vendors with netsuite
fields. Add netsuite schema for invoices, bills,
payments, and credit memos. Include all migrations,
seeds, new UI primitives, and config updates.

* feat(auth): add WorkOS authentication system

Add login, signup, password reset, email verification,
and invitation flows via WorkOS AuthKit. Includes auth
middleware, permission helpers, dev mode fallbacks,
and auth page components.

* feat(people): add people management system

Add user, team, group, and organization management
with CRUD actions, dashboard pages, invite dialog,
user drawer, and role-based filtering. Includes
WorkOS invitation integration.

* feat(netsuite): add NetSuite integration and financials

Add bidirectional NetSuite REST API integration with
OAuth 2.0, rate limiting, sync engine, and conflict
resolution. Includes invoices, vendor bills, payments,
credit memos CRUD, customer/vendor management pages,
and financial dashboard with tabbed views.

* ci: retrigger build

* fix: add mobile-list-card dependency for people-table

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-04 16:36:19 -07:00

149 lines
3.7 KiB
TypeScript
Executable File

import { eq } from "drizzle-orm"
import type { NetSuiteConfig } from "../config"
import { encrypt, decrypt } from "./crypto"
import {
refreshAccessToken,
type OAuthTokens,
} from "./oauth-client"
import { netsuiteAuth } from "@/db/schema-netsuite"
import type { DrizzleD1Database } from "drizzle-orm/d1"
// refresh at 80% of token lifetime to avoid edge-case expiry
const REFRESH_THRESHOLD = 0.8
export class TokenManager {
private config: NetSuiteConfig
private db: DrizzleD1Database
private cachedTokens: OAuthTokens | null = null
constructor(config: NetSuiteConfig, db: DrizzleD1Database) {
this.config = config
this.db = db
}
async getAccessToken(): Promise<string> {
const tokens = await this.loadTokens()
if (!tokens) {
throw new Error(
"No NetSuite tokens found. Complete OAuth setup first."
)
}
if (this.shouldRefresh(tokens)) {
const refreshed = await this.refresh(tokens.refreshToken)
return refreshed.accessToken
}
return tokens.accessToken
}
async storeTokens(tokens: OAuthTokens): Promise<void> {
const encryptedAccess = await encrypt(
tokens.accessToken,
this.config.tokenEncryptionKey
)
const encryptedRefresh = await encrypt(
tokens.refreshToken,
this.config.tokenEncryptionKey
)
const existing = await this.db
.select()
.from(netsuiteAuth)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
.limit(1)
const now = new Date().toISOString()
const values = {
accountId: this.config.accountId,
accessTokenEncrypted: encryptedAccess,
refreshTokenEncrypted: encryptedRefresh,
expiresIn: tokens.expiresIn,
tokenType: tokens.tokenType,
issuedAt: tokens.issuedAt,
updatedAt: now,
}
if (existing.length > 0) {
await this.db
.update(netsuiteAuth)
.set(values)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
} else {
await this.db.insert(netsuiteAuth).values({
id: crypto.randomUUID(),
...values,
createdAt: now,
})
}
this.cachedTokens = tokens
}
async hasTokens(): Promise<boolean> {
const row = await this.db
.select({ id: netsuiteAuth.id })
.from(netsuiteAuth)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
.limit(1)
return row.length > 0
}
async clearTokens(): Promise<void> {
await this.db
.delete(netsuiteAuth)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
this.cachedTokens = null
}
private shouldRefresh(tokens: OAuthTokens): boolean {
const elapsed = Date.now() - tokens.issuedAt
const threshold = tokens.expiresIn * 1000 * REFRESH_THRESHOLD
return elapsed >= threshold
}
private async refresh(
refreshToken: string
): Promise<OAuthTokens> {
const tokens = await refreshAccessToken(
this.config,
refreshToken
)
await this.storeTokens(tokens)
return tokens
}
private async loadTokens(): Promise<OAuthTokens | null> {
if (this.cachedTokens) return this.cachedTokens
const rows = await this.db
.select()
.from(netsuiteAuth)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
.limit(1)
if (rows.length === 0) return null
const row = rows[0]
const accessToken = await decrypt(
row.accessTokenEncrypted,
this.config.tokenEncryptionKey
)
const refreshToken = await decrypt(
row.refreshTokenEncrypted,
this.config.tokenEncryptionKey
)
this.cachedTokens = {
accessToken,
refreshToken,
expiresIn: row.expiresIn,
tokenType: row.tokenType,
issuedAt: row.issuedAt,
}
return this.cachedTokens
}
}