hazo_auth 9.1.1 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +124 -6
  2. package/SETUP_CHECKLIST.md +24 -16
  3. package/cli-src/cli/init_users.ts +40 -48
  4. package/cli-src/lib/auth/auth_types.ts +0 -2
  5. package/cli-src/lib/auth/hazo_get_auth.server.ts +31 -25
  6. package/cli-src/lib/auth/hazo_get_tenant_auth.server.ts +9 -13
  7. package/cli-src/lib/auth/nextauth_config.ts +41 -0
  8. package/cli-src/lib/auth/request_google_scopes.ts +23 -0
  9. package/cli-src/lib/constants.ts +2 -0
  10. package/cli-src/lib/profile_pic_menu_config.server.ts +4 -3
  11. package/cli-src/lib/schema/sqlite_schema.ts +16 -4
  12. package/cli-src/lib/scope_hierarchy_config.server.ts +1 -9
  13. package/cli-src/lib/services/google_token_service.ts +408 -0
  14. package/cli-src/lib/services/index.ts +1 -1
  15. package/cli-src/lib/services/invitation_service.ts +1 -1
  16. package/cli-src/lib/services/scope_service.ts +2 -76
  17. package/cli-src/lib/services/user_scope_service.ts +7 -61
  18. package/dist/cli/init_users.d.ts.map +1 -1
  19. package/dist/cli/init_users.js +42 -42
  20. package/dist/client.d.ts +2 -1
  21. package/dist/client.d.ts.map +1 -1
  22. package/dist/client.js +3 -1
  23. package/dist/components/layouts/google_token_test/index.d.ts +6 -0
  24. package/dist/components/layouts/google_token_test/index.d.ts.map +1 -0
  25. package/dist/components/layouts/google_token_test/index.js +74 -0
  26. package/dist/components/layouts/shared/components/profile_pic_menu.d.ts.map +1 -1
  27. package/dist/components/layouts/shared/components/profile_pic_menu.js +7 -1
  28. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
  29. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +2 -2
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -1
  33. package/dist/lib/auth/auth_types.d.ts +0 -2
  34. package/dist/lib/auth/auth_types.d.ts.map +1 -1
  35. package/dist/lib/auth/hazo_get_auth.server.d.ts.map +1 -1
  36. package/dist/lib/auth/hazo_get_auth.server.js +27 -19
  37. package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts.map +1 -1
  38. package/dist/lib/auth/hazo_get_tenant_auth.server.js +10 -10
  39. package/dist/lib/auth/nextauth_config.d.ts +2 -0
  40. package/dist/lib/auth/nextauth_config.d.ts.map +1 -1
  41. package/dist/lib/auth/nextauth_config.js +39 -1
  42. package/dist/lib/auth/request_google_scopes.d.ts +10 -0
  43. package/dist/lib/auth/request_google_scopes.d.ts.map +1 -0
  44. package/dist/lib/auth/request_google_scopes.js +13 -0
  45. package/dist/lib/constants.d.ts +1 -0
  46. package/dist/lib/constants.d.ts.map +1 -1
  47. package/dist/lib/constants.js +1 -0
  48. package/dist/lib/profile_pic_menu_config.server.d.ts +2 -1
  49. package/dist/lib/profile_pic_menu_config.server.d.ts.map +1 -1
  50. package/dist/lib/profile_pic_menu_config.server.js +1 -1
  51. package/dist/lib/schema/sqlite_schema.d.ts +1 -1
  52. package/dist/lib/schema/sqlite_schema.d.ts.map +1 -1
  53. package/dist/lib/schema/sqlite_schema.js +16 -4
  54. package/dist/lib/scope_hierarchy_config.server.d.ts +0 -2
  55. package/dist/lib/scope_hierarchy_config.server.d.ts.map +1 -1
  56. package/dist/lib/scope_hierarchy_config.server.js +1 -3
  57. package/dist/lib/services/google_token_service.d.ts +48 -0
  58. package/dist/lib/services/google_token_service.d.ts.map +1 -0
  59. package/dist/lib/services/google_token_service.js +319 -0
  60. package/dist/lib/services/index.d.ts +1 -0
  61. package/dist/lib/services/index.d.ts.map +1 -1
  62. package/dist/lib/services/index.js +1 -0
  63. package/dist/lib/services/invitation_service.d.ts +1 -1
  64. package/dist/lib/services/invitation_service.js +1 -1
  65. package/dist/lib/services/scope_service.d.ts +1 -14
  66. package/dist/lib/services/scope_service.d.ts.map +1 -1
  67. package/dist/lib/services/scope_service.js +2 -67
  68. package/dist/lib/services/user_scope_service.d.ts +5 -12
  69. package/dist/lib/services/user_scope_service.d.ts.map +1 -1
  70. package/dist/lib/services/user_scope_service.js +8 -45
  71. package/dist/server/routes/google_token.d.ts +13 -0
  72. package/dist/server/routes/google_token.d.ts.map +1 -0
  73. package/dist/server/routes/google_token.js +66 -0
  74. package/dist/server/routes/index.d.ts +1 -0
  75. package/dist/server/routes/index.d.ts.map +1 -1
  76. package/dist/server/routes/index.js +2 -0
  77. package/dist/server/routes/invitations.d.ts +1 -1
  78. package/dist/server/routes/invitations.d.ts.map +1 -1
  79. package/dist/server/routes/invitations.js +12 -11
  80. package/dist/server/routes/user_management_users.d.ts +1 -1
  81. package/package.json +17 -13
@@ -0,0 +1,23 @@
1
+ // file_description: client helper to trigger incremental Google OAuth scope consent
2
+ "use client";
3
+
4
+ import { signIn } from "next-auth/react";
5
+
6
+ /**
7
+ * Triggers an incremental Google OAuth consent flow to grant additional scopes.
8
+ * Call from a client component when the user needs to grant extra Google API access
9
+ * (e.g. analytics, sheets). On completion, hazo_auth stores the refresh token.
10
+ */
11
+ export function requestGoogleScopes(
12
+ scopes: string[],
13
+ opts?: { callbackUrl?: string }
14
+ ): ReturnType<typeof signIn> {
15
+ const scope = Array.from(
16
+ new Set(["openid", "email", "profile", ...scopes])
17
+ ).join(" ");
18
+ return signIn(
19
+ "google",
20
+ { callbackUrl: opts?.callbackUrl ?? "/" },
21
+ { scope, access_type: "offline", include_granted_scopes: "true", prompt: "consent" }
22
+ );
23
+ }
@@ -13,3 +13,5 @@ export const HAZO_AUTH_PERMISSIONS = {
13
13
  } as const;
14
14
 
15
15
  export const ALL_ADMIN_PERMISSIONS: string[] = Object.values(HAZO_AUTH_PERMISSIONS);
16
+
17
+ export const GLOBAL_ADMIN_PERMISSION = "hazo_org_global_admin";
@@ -8,13 +8,14 @@ import { get_config_value, get_config_boolean, get_config_array } from "./config
8
8
  // section: types
9
9
  // Note: These types are also used in client components, but TypeScript types are erased at runtime
10
10
  // so importing from a server file is safe for type-only imports
11
- export type MenuItemType = "info" | "link" | "separator";
11
+ export type MenuItemType = "info" | "link" | "separator" | "action";
12
12
 
13
13
  export type ProfilePicMenuMenuItem = {
14
14
  type: MenuItemType;
15
- label?: string; // For info and link types
15
+ label?: string; // For info, link, and action types
16
16
  value?: string; // For info type (e.g., user name, email)
17
17
  href?: string; // For link type
18
+ onSelect?: () => void; // For action type (client-side callback, not serialisable via INI config)
18
19
  order: number; // Ordering within type group
19
20
  id: string; // Unique identifier for the item
20
21
  };
@@ -50,7 +51,7 @@ function parse_custom_menu_items(items_string: string[]): ProfilePicMenuMenuItem
50
51
 
51
52
  const type = parts[0] as MenuItemType;
52
53
  if (type !== "info" && type !== "link" && type !== "separator") {
53
- return; // Invalid type, skip
54
+ return; // Invalid type or action (action items carry callbacks, not expressible in INI)
54
55
  }
55
56
 
56
57
  if (type === "separator") {
@@ -97,10 +97,6 @@ CREATE INDEX IF NOT EXISTS idx_hazo_scopes_parent ON hazo_scopes(parent_id);
97
97
  CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
98
98
  CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
99
99
 
100
- -- Super admin scope
101
- INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
102
- VALUES ('00000000-0000-0000-0000-000000000000', NULL, 'Super Admin', 'system', datetime('now'), datetime('now'));
103
-
104
100
  -- Default system scope (for non-multi-tenancy mode)
105
101
  INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
106
102
  VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));
@@ -165,4 +161,20 @@ CREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_child ON hazo_user_relati
165
161
  -- Firm admin role (for firm creators)
166
162
  INSERT OR IGNORE INTO hazo_roles (id, role_name, created_at, changed_at)
167
163
  VALUES (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))), 'firm_admin', datetime('now'), datetime('now'));
164
+
165
+ -- Google OAuth tokens (encrypted refresh/access tokens for extra-scope API access)
166
+ CREATE TABLE IF NOT EXISTS hazo_google_oauth_tokens (
167
+ id TEXT PRIMARY KEY,
168
+ user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
169
+ provider TEXT NOT NULL DEFAULT 'google',
170
+ refresh_token_enc TEXT NOT NULL,
171
+ access_token_enc TEXT,
172
+ scopes TEXT NOT NULL DEFAULT '',
173
+ expires_at TEXT,
174
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
175
+ changed_at TEXT NOT NULL DEFAULT (datetime('now')),
176
+ UNIQUE(user_id, provider)
177
+ );
178
+
179
+ CREATE INDEX IF NOT EXISTS idx_hazo_google_oauth_tokens_user ON hazo_google_oauth_tokens(user_id);
168
180
  `;
@@ -8,7 +8,7 @@ import {
8
8
  get_config_number,
9
9
  get_config_boolean,
10
10
  } from "./config/config_loader.server.js";
11
- import { SUPER_ADMIN_SCOPE_ID, DEFAULT_SYSTEM_SCOPE_ID } from "./services/scope_service.js";
11
+ import { DEFAULT_SYSTEM_SCOPE_ID } from "./services/scope_service.js";
12
12
 
13
13
  // section: types
14
14
 
@@ -23,8 +23,6 @@ export type ScopeHierarchyConfig = {
23
23
  scope_cache_ttl_minutes: number;
24
24
  /** Maximum entries in scope cache (default: 5000) */
25
25
  scope_cache_max_entries: number;
26
- /** Super admin scope ID */
27
- super_admin_scope_id: string;
28
26
  /** Default system scope ID (for non-multi-tenancy mode) */
29
27
  default_system_scope_id: string;
30
28
  };
@@ -57,11 +55,6 @@ export function get_scope_hierarchy_config(): ScopeHierarchyConfig {
57
55
  );
58
56
 
59
57
  // Scope IDs (with defaults)
60
- const super_admin_scope_id = get_config_value(
61
- SECTION_NAME,
62
- "super_admin_scope_id",
63
- SUPER_ADMIN_SCOPE_ID,
64
- );
65
58
  const default_system_scope_id = get_config_value(
66
59
  SECTION_NAME,
67
60
  "default_system_scope_id",
@@ -72,7 +65,6 @@ export function get_scope_hierarchy_config(): ScopeHierarchyConfig {
72
65
  enable_hrbac,
73
66
  scope_cache_ttl_minutes,
74
67
  scope_cache_max_entries,
75
- super_admin_scope_id,
76
68
  default_system_scope_id,
77
69
  };
78
70
  }
@@ -0,0 +1,408 @@
1
+ // file_description: stores, retrieves, refreshes, and revokes Google OAuth tokens with encrypted persistence
2
+ // section: imports
3
+ import { createCrudService } from "hazo_connect/server";
4
+ import { get_hazo_connect_instance } from "../hazo_connect_instance.server.js";
5
+ import { create_app_logger } from "../app_logger.js";
6
+ import { optional_import } from "hazo_core";
7
+ import { randomUUID } from "crypto";
8
+
9
+ // section: errors
10
+
11
+ export class GoogleTokenStorageUnconfigured extends Error {
12
+ constructor() {
13
+ super(
14
+ "Google token storage is not configured. Set HAZO_AUTH_OAUTH_KEY_CURRENT and HAZO_AUTH_OAUTH_KEY_<ID> env vars."
15
+ );
16
+ this.name = "GoogleTokenStorageUnconfigured";
17
+ }
18
+ }
19
+
20
+ // section: types
21
+
22
+ export type StoreGoogleOAuthTokenParams = {
23
+ user_id: string;
24
+ refresh_token: string;
25
+ access_token?: string;
26
+ scopes: string; // space-delimited
27
+ expires_at?: string; // ISO string for access token expiry
28
+ };
29
+
30
+ export type GoogleTokenResult =
31
+ | { ok: true; access_token: string; scopes: string }
32
+ | { ok: false; error: "not_connected" | "reconsent_required" | "refresh_failed" | "unconfigured" };
33
+
34
+ export type GoogleTokenStatus = {
35
+ connected: boolean;
36
+ scopes: string;
37
+ expires_at: string | null;
38
+ };
39
+
40
+ // section: internal-types
41
+
42
+ type GoogleOAuthRow = {
43
+ id: string;
44
+ user_id: string;
45
+ provider: string;
46
+ refresh_token_enc: string;
47
+ access_token_enc: string | null;
48
+ scopes: string;
49
+ expires_at: string | null;
50
+ created_at: string;
51
+ changed_at: string;
52
+ };
53
+
54
+ // section: cache
55
+
56
+ const access_token_cache = new Map<string, { token: string; expires_at: string }>();
57
+
58
+ // section: helpers
59
+
60
+ function makeKeyProvider(
61
+ LookupKeyProvider: new (lookup: (name: string) => string | undefined) => unknown
62
+ ) {
63
+ return new LookupKeyProvider((name: string) => {
64
+ if (name === "current") return process.env.HAZO_AUTH_OAUTH_KEY_CURRENT;
65
+ if (name.startsWith("key_")) {
66
+ const keyId = name.slice(4).toUpperCase();
67
+ return process.env[`HAZO_AUTH_OAUTH_KEY_${keyId}`];
68
+ }
69
+ return undefined;
70
+ });
71
+ }
72
+
73
+ async function load_crypto_module() {
74
+ const cryptoModule = await optional_import<typeof import("hazo_secure/crypto")>(
75
+ "hazo_secure/crypto"
76
+ );
77
+ if (!cryptoModule) throw new GoogleTokenStorageUnconfigured();
78
+ return cryptoModule;
79
+ }
80
+
81
+ // section: store
82
+
83
+ /**
84
+ * Stores (inserts or updates) Google OAuth tokens for a user, encrypting sensitive fields.
85
+ * Throws GoogleTokenStorageUnconfigured if hazo_secure/crypto is unavailable or keys are missing.
86
+ */
87
+ export async function store_google_oauth_token(
88
+ params: StoreGoogleOAuthTokenParams
89
+ ): Promise<void> {
90
+ const logger = create_app_logger();
91
+
92
+ const { encryptField, decryptField: _decryptField, aadFor, LookupKeyProvider } = await load_crypto_module();
93
+
94
+ const keys = makeKeyProvider(LookupKeyProvider as new (lookup: (name: string) => string | undefined) => unknown) as Parameters<typeof encryptField>[1]["keys"];
95
+
96
+ // Force-fail early if no key is configured
97
+ try {
98
+ await (keys as unknown as { current(): Promise<unknown> }).current();
99
+ } catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ logger.error("google_token_service_key_provider_failed", {
102
+ filename: "google_token_service.ts",
103
+ error: msg,
104
+ });
105
+ throw new GoogleTokenStorageUnconfigured();
106
+ }
107
+
108
+ const refresh_enc = await encryptField(params.refresh_token, {
109
+ keys,
110
+ aad: aadFor("hazo_google_oauth_tokens", params.user_id, "refresh_token"),
111
+ });
112
+ const refresh_token_enc = JSON.stringify(refresh_enc);
113
+
114
+ let access_token_enc: string | null = null;
115
+ if (params.access_token) {
116
+ const access_enc = await encryptField(params.access_token, {
117
+ keys,
118
+ aad: aadFor("hazo_google_oauth_tokens", params.user_id, "access_token"),
119
+ });
120
+ access_token_enc = JSON.stringify(access_enc);
121
+ }
122
+
123
+ const adapter = get_hazo_connect_instance();
124
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
125
+
126
+ const existing_rows = (await service.findBy({
127
+ user_id: params.user_id,
128
+ provider: "google",
129
+ })) as GoogleOAuthRow[];
130
+
131
+ const now = new Date().toISOString();
132
+
133
+ if (existing_rows.length > 0) {
134
+ const existing = existing_rows[0];
135
+ await service.updateById(existing.id, {
136
+ refresh_token_enc,
137
+ access_token_enc,
138
+ scopes: params.scopes,
139
+ expires_at: params.expires_at ?? null,
140
+ changed_at: now,
141
+ });
142
+ logger.info("google_token_service_token_updated", {
143
+ filename: "google_token_service.ts",
144
+ user_id: params.user_id,
145
+ });
146
+ } else {
147
+ await service.insert({
148
+ id: randomUUID(),
149
+ user_id: params.user_id,
150
+ provider: "google",
151
+ refresh_token_enc,
152
+ access_token_enc,
153
+ scopes: params.scopes,
154
+ expires_at: params.expires_at ?? null,
155
+ created_at: now,
156
+ changed_at: now,
157
+ });
158
+ logger.info("google_token_service_token_stored", {
159
+ filename: "google_token_service.ts",
160
+ user_id: params.user_id,
161
+ });
162
+ }
163
+ }
164
+
165
+ // section: get
166
+
167
+ /**
168
+ * Returns a valid access token for the user, refreshing via Google if needed.
169
+ * Returns a typed error result instead of throwing for expected failure modes.
170
+ */
171
+ export async function getGoogleToken(
172
+ userId: string,
173
+ opts?: { scopes?: string[] }
174
+ ): Promise<GoogleTokenResult> {
175
+ const logger = create_app_logger();
176
+
177
+ // Check in-memory cache first
178
+ const cached = access_token_cache.get(userId);
179
+ if (cached) {
180
+ const still_valid = new Date(cached.expires_at).getTime() - 60000 > Date.now();
181
+ if (still_valid) {
182
+ logger.info("google_token_service_cache_hit", {
183
+ filename: "google_token_service.ts",
184
+ user_id: userId,
185
+ });
186
+ // We need scopes from DB even on cache hit when scope check is requested
187
+ if (!opts?.scopes?.length) {
188
+ // No scope check needed — return cached token (scopes unknown from cache alone)
189
+ // Fall through to DB to get scopes for the result shape
190
+ }
191
+ }
192
+ }
193
+
194
+ const adapter = get_hazo_connect_instance();
195
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
196
+
197
+ const rows = (await service.findBy({ user_id: userId, provider: "google" })) as GoogleOAuthRow[];
198
+ if (rows.length === 0) {
199
+ return { ok: false, error: "not_connected" };
200
+ }
201
+
202
+ const row = rows[0];
203
+
204
+ // Scope check
205
+ if (opts?.scopes?.length) {
206
+ const stored_scopes = row.scopes.split(" ").filter(Boolean);
207
+ const missing = opts.scopes.some((s) => !stored_scopes.includes(s));
208
+ if (missing) {
209
+ return { ok: false, error: "reconsent_required" };
210
+ }
211
+ }
212
+
213
+ // Return cached token now that we have the row (scope check passed)
214
+ if (cached) {
215
+ const still_valid = new Date(cached.expires_at).getTime() - 60000 > Date.now();
216
+ if (still_valid) {
217
+ return { ok: true, access_token: cached.token, scopes: row.scopes };
218
+ }
219
+ }
220
+
221
+ // Need to refresh — load crypto
222
+ let cryptoModule: Awaited<ReturnType<typeof load_crypto_module>>;
223
+ try {
224
+ cryptoModule = await load_crypto_module();
225
+ } catch {
226
+ return { ok: false, error: "unconfigured" };
227
+ }
228
+
229
+ const { decryptField, aadFor, LookupKeyProvider } = cryptoModule;
230
+ const keys = makeKeyProvider(LookupKeyProvider as new (lookup: (name: string) => string | undefined) => unknown) as Parameters<typeof cryptoModule.encryptField>[1]["keys"];
231
+
232
+ let decrypted_refresh: string;
233
+ try {
234
+ decrypted_refresh = await decryptField(JSON.parse(row.refresh_token_enc), {
235
+ keys,
236
+ aad: aadFor("hazo_google_oauth_tokens", userId, "refresh_token"),
237
+ });
238
+ } catch (err) {
239
+ const msg = err instanceof Error ? err.message : String(err);
240
+ logger.error("google_token_service_decrypt_failed", {
241
+ filename: "google_token_service.ts",
242
+ user_id: userId,
243
+ error: msg,
244
+ });
245
+ return { ok: false, error: "refresh_failed" };
246
+ }
247
+
248
+ // Call Google token refresh endpoint
249
+ let resp: Response;
250
+ try {
251
+ resp = await fetch("https://oauth2.googleapis.com/token", {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
254
+ body: new URLSearchParams({
255
+ grant_type: "refresh_token",
256
+ refresh_token: decrypted_refresh,
257
+ client_id: process.env.HAZO_AUTH_GOOGLE_CLIENT_ID!,
258
+ client_secret: process.env.HAZO_AUTH_GOOGLE_CLIENT_SECRET!,
259
+ }),
260
+ });
261
+ } catch (err) {
262
+ const msg = err instanceof Error ? err.message : String(err);
263
+ logger.error("google_token_service_refresh_fetch_failed", {
264
+ filename: "google_token_service.ts",
265
+ user_id: userId,
266
+ error: msg,
267
+ });
268
+ return { ok: false, error: "refresh_failed" };
269
+ }
270
+
271
+ if (!resp.ok) {
272
+ logger.warn("google_token_service_refresh_rejected", {
273
+ filename: "google_token_service.ts",
274
+ user_id: userId,
275
+ status: resp.status,
276
+ });
277
+ return { ok: false, error: "refresh_failed" };
278
+ }
279
+
280
+ const data = await resp.json() as { access_token: string; expires_in: number };
281
+
282
+ // Persist new access token (encrypted)
283
+ const new_expires_at = new Date(Date.now() + data.expires_in * 1000).toISOString();
284
+ try {
285
+ const { encryptField } = cryptoModule;
286
+ const access_enc = await encryptField(data.access_token, {
287
+ keys,
288
+ aad: aadFor("hazo_google_oauth_tokens", userId, "access_token"),
289
+ });
290
+ const access_token_enc = JSON.stringify(access_enc);
291
+ await service.updateById(row.id, {
292
+ access_token_enc,
293
+ expires_at: new_expires_at,
294
+ changed_at: new Date().toISOString(),
295
+ });
296
+ } catch (err) {
297
+ const msg = err instanceof Error ? err.message : String(err);
298
+ logger.warn("google_token_service_persist_access_token_failed", {
299
+ filename: "google_token_service.ts",
300
+ user_id: userId,
301
+ error: msg,
302
+ note: "Access token refreshed successfully but could not be persisted",
303
+ });
304
+ }
305
+
306
+ // Update in-memory cache
307
+ access_token_cache.set(userId, { token: data.access_token, expires_at: new_expires_at });
308
+
309
+ logger.info("google_token_service_token_refreshed", {
310
+ filename: "google_token_service.ts",
311
+ user_id: userId,
312
+ });
313
+
314
+ return { ok: true, access_token: data.access_token, scopes: row.scopes };
315
+ }
316
+
317
+ // section: revoke
318
+
319
+ /**
320
+ * Revokes the user's Google OAuth tokens — best-effort remote revocation, then deletes DB row.
321
+ */
322
+ export async function revoke_google_oauth_token(
323
+ userId: string
324
+ ): Promise<{ ok: boolean; error?: string }> {
325
+ const logger = create_app_logger();
326
+
327
+ const adapter = get_hazo_connect_instance();
328
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
329
+
330
+ const rows = (await service.findBy({ user_id: userId, provider: "google" })) as GoogleOAuthRow[];
331
+ if (rows.length === 0) {
332
+ return { ok: false, error: "not_connected" };
333
+ }
334
+
335
+ const row = rows[0];
336
+
337
+ // Attempt remote revocation — best effort
338
+ try {
339
+ const { decryptField, aadFor, LookupKeyProvider } = await load_crypto_module();
340
+ const keys = makeKeyProvider(LookupKeyProvider as new (lookup: (name: string) => string | undefined) => unknown) as Parameters<typeof decryptField>[1]["keys"];
341
+
342
+ const decrypted_refresh = await decryptField(JSON.parse(row.refresh_token_enc), {
343
+ keys,
344
+ aad: aadFor("hazo_google_oauth_tokens", userId, "refresh_token"),
345
+ });
346
+
347
+ const revoke_resp = await fetch(
348
+ `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(decrypted_refresh)}`,
349
+ { method: "POST" }
350
+ );
351
+
352
+ if (!revoke_resp.ok) {
353
+ logger.warn("google_token_service_revoke_remote_failed", {
354
+ filename: "google_token_service.ts",
355
+ user_id: userId,
356
+ status: revoke_resp.status,
357
+ note: "Google revocation returned non-OK status; proceeding to delete local record",
358
+ });
359
+ } else {
360
+ logger.info("google_token_service_revoke_remote_ok", {
361
+ filename: "google_token_service.ts",
362
+ user_id: userId,
363
+ });
364
+ }
365
+ } catch (err) {
366
+ const msg = err instanceof Error ? err.message : String(err);
367
+ logger.warn("google_token_service_revoke_remote_error", {
368
+ filename: "google_token_service.ts",
369
+ user_id: userId,
370
+ error: msg,
371
+ note: "Remote revocation failed; proceeding to delete local record",
372
+ });
373
+ }
374
+
375
+ // Always delete the local row
376
+ await service.deleteById(row.id);
377
+ access_token_cache.delete(userId);
378
+
379
+ logger.info("google_token_service_revoked", {
380
+ filename: "google_token_service.ts",
381
+ user_id: userId,
382
+ });
383
+
384
+ return { ok: true };
385
+ }
386
+
387
+ // section: status
388
+
389
+ /**
390
+ * Returns connection status and scope information for a user's Google OAuth connection.
391
+ * Does not decrypt or refresh tokens.
392
+ */
393
+ export async function get_google_token_status(userId: string): Promise<GoogleTokenStatus> {
394
+ const adapter = get_hazo_connect_instance();
395
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
396
+
397
+ const rows = (await service.findBy({ user_id: userId, provider: "google" })) as GoogleOAuthRow[];
398
+ if (rows.length === 0) {
399
+ return { connected: false, scopes: "", expires_at: null };
400
+ }
401
+
402
+ const row = rows[0];
403
+ return {
404
+ connected: true,
405
+ scopes: row.scopes,
406
+ expires_at: row.expires_at ?? null,
407
+ };
408
+ }
@@ -20,5 +20,5 @@ export * from "./scope_service.js";
20
20
  export * from "./user_scope_service.js";
21
21
  export * from "./oauth_service.js";
22
22
  export * from "./branding_service.js";
23
-
23
+ export * from "./google_token_service.js";
24
24
 
@@ -478,7 +478,7 @@ export async function list_invitations_by_scope(
478
478
  }
479
479
 
480
480
  /**
481
- * Lists all invitations (for super admin)
481
+ * Lists all invitations (for global admins)
482
482
  */
483
483
  export async function list_all_invitations(
484
484
  adapter: HazoConnectAdapter,
@@ -7,12 +7,6 @@ import { sanitize_error_for_user } from "../utils/error_sanitizer.js";
7
7
 
8
8
  // section: constants
9
9
 
10
- /**
11
- * Super admin scope ID - special UUID for system-level administrators
12
- * Users assigned to this scope have global access
13
- */
14
- export const SUPER_ADMIN_SCOPE_ID = "00000000-0000-0000-0000-000000000000";
15
-
16
10
  /**
17
11
  * Default system scope ID - for non-multi-tenancy mode
18
12
  * All users are assigned to this scope when multi-tenancy is disabled
@@ -119,13 +113,6 @@ export function has_branding(scope: ScopeRecord): boolean {
119
113
  return !!(scope.logo_url || scope.primary_color || scope.secondary_color || scope.tagline);
120
114
  }
121
115
 
122
- /**
123
- * Checks if the given scope_id is the super admin scope
124
- */
125
- export function is_super_admin_scope(scope_id: string): boolean {
126
- return scope_id === SUPER_ADMIN_SCOPE_ID;
127
- }
128
-
129
116
  /**
130
117
  * Checks if the given scope_id is the default system scope
131
118
  */
@@ -134,10 +121,10 @@ export function is_default_system_scope(scope_id: string): boolean {
134
121
  }
135
122
 
136
123
  /**
137
- * Checks if the given scope_id is a system scope (super admin or default system)
124
+ * Checks if the given scope_id is a system scope (default system)
138
125
  */
139
126
  export function is_system_scope(scope_id: string): boolean {
140
- return is_super_admin_scope(scope_id) || is_default_system_scope(scope_id);
127
+ return is_default_system_scope(scope_id);
141
128
  }
142
129
 
143
130
  // section: crud operations
@@ -781,67 +768,6 @@ export async function get_scope_tree(
781
768
  }
782
769
  }
783
770
 
784
- /**
785
- * Ensures the super admin scope exists
786
- */
787
- export async function ensure_super_admin_scope(
788
- adapter: HazoConnectAdapter,
789
- ): Promise<ScopeServiceResult> {
790
- try {
791
- // Check if already exists
792
- const existing = await get_scope_by_id(adapter, SUPER_ADMIN_SCOPE_ID);
793
- if (existing.success && existing.scope) {
794
- return existing;
795
- }
796
-
797
- // Create it
798
- const scope_service = createCrudService(adapter, "hazo_scopes");
799
- const now = new Date().toISOString();
800
-
801
- const inserted = await scope_service.insert({
802
- id: SUPER_ADMIN_SCOPE_ID,
803
- name: "Super Admin",
804
- level: "system",
805
- parent_id: null,
806
- logo_url: null,
807
- primary_color: null,
808
- secondary_color: null,
809
- tagline: null,
810
- created_at: now,
811
- changed_at: now,
812
- });
813
-
814
- if (!Array.isArray(inserted) || inserted.length === 0) {
815
- return {
816
- success: false,
817
- error: "Failed to create super admin scope",
818
- };
819
- }
820
-
821
- return {
822
- success: true,
823
- scope: normalize_scope_record(inserted[0] as Record<string, unknown>),
824
- };
825
- } catch (error) {
826
- const logger = create_app_logger();
827
- const error_message = sanitize_error_for_user(error, {
828
- logToConsole: true,
829
- logToLogger: true,
830
- logger,
831
- context: {
832
- filename: "scope_service.ts",
833
- line_number: 0,
834
- operation: "ensure_super_admin_scope",
835
- },
836
- });
837
-
838
- return {
839
- success: false,
840
- error: error_message,
841
- };
842
- }
843
- }
844
-
845
771
  /**
846
772
  * Ensures the default system scope exists
847
773
  */