From 2820ac51f35a0e3665f0723b7b41ea3b52201a51 Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Mon, 8 Jun 2026 19:56:43 +0800 Subject: [PATCH 1/5] feat(server): add admin identity providers REST API Add endpoints for managing OAuth/OIDC identity provider configurations: - GET /identity-providers - list all providers - GET /identity-providers/:slug - get provider details - PATCH /identity-providers/:slug - update provider config - POST /identity-providers/:slug/secret - rotate client secret Requires administrator role. Uses module loaders to discover schemas and function names at runtime. Also adds prefix and rotateSecretFunction fields to IdentityProvidersConfig for calling the generated rotate_identity_provider_{prefix}_secret procedure. Co-Authored-By: Claude Opus 4.5 --- graphql/server/src/index.ts | 1 + .../src/middleware/identity-providers.ts | 336 ++++++++++++++++++ graphql/server/src/server.ts | 4 + .../src/loaders/identity-providers.ts | 6 +- packages/express-context/src/types.ts | 4 + 5 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 graphql/server/src/middleware/identity-providers.ts diff --git a/graphql/server/src/index.ts b/graphql/server/src/index.ts index 06d1561bd8..f30956c4f5 100644 --- a/graphql/server/src/index.ts +++ b/graphql/server/src/index.ts @@ -4,6 +4,7 @@ export * from './server'; export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api'; export { createAuthenticateMiddleware } from './middleware/auth'; export { createUploadAuthenticateMiddleware } from './middleware/upload'; +export { createIdentityProvidersRouter } from './middleware/identity-providers'; export { cors } from './middleware/cors'; export { graphile } from './middleware/graphile'; export { flush, flushService } from './middleware/flush'; diff --git a/graphql/server/src/middleware/identity-providers.ts b/graphql/server/src/middleware/identity-providers.ts new file mode 100644 index 0000000000..4f11f18793 --- /dev/null +++ b/graphql/server/src/middleware/identity-providers.ts @@ -0,0 +1,336 @@ +/** + * Admin Identity Providers API + * + * Express router for managing OAuth/OIDC identity provider configurations. + * Requires administrator role. Uses module loaders from @constructive-io/express-context + * to discover schemas and function names at runtime. + * + * Routes: + * GET /identity-providers → list all providers + * GET /identity-providers/:slug → get provider details + * PATCH /identity-providers/:slug → update provider config + * POST /identity-providers/:slug/secret → rotate client secret + */ + +import { Router, Request, Response } from 'express'; +import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveContext } from '@constructive-io/express-context'; + +import './types'; + +const log = new Logger('admin-identity-providers'); + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface ProviderRow { + id: string; + slug: string; + kind: 'oauth2' | 'oidc'; + display_name: string; + enabled: boolean; + is_built_in: boolean; + client_id: string | null; + client_secret_id: string | null; + authorization_url: string | null; + token_url: string | null; + userinfo_url: string | null; + scopes: string[] | null; + pkce_enabled: boolean | null; +} + +interface UpdateProviderBody { + clientId?: string; + enabled?: boolean; + scopes?: string[]; + authorizationUrl?: string; + tokenUrl?: string; + userinfoUrl?: string; + pkceEnabled?: boolean; +} + +interface RotateSecretBody { + clientSecret: string; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function requireAdmin(ctx: ConstructiveContext, res: Response): boolean { + if (ctx.token?.role !== 'administrator') { + res.status(403).json({ error: 'ADMIN_REQUIRED' }); + return false; + } + return true; +} + +// ─── Router ───────────────────────────────────────────────────────────────── + +export function createIdentityProvidersRouter(): Router { + const router = Router(); + + /** + * GET /identity-providers + * List all identity providers (including disabled ones) + */ + router.get('/identity-providers', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!requireAdmin(ctx, res)) return; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const providers = await ctx.withPgClient(async (client) => { + const sql = ` + SELECT + id, slug, kind, display_name, enabled, is_built_in, + client_id, client_secret_id, + authorization_url, token_url, userinfo_url, + scopes, pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + ORDER BY is_built_in DESC, slug ASC + `; + const result = await client.query(sql); + return result.rows; + }); + + res.json({ + providers: providers.map((p) => ({ + id: p.id, + slug: p.slug, + kind: p.kind, + displayName: p.display_name, + enabled: p.enabled, + isBuiltIn: p.is_built_in, + clientId: p.client_id, + hasSecret: !!p.client_secret_id, + authorizationUrl: p.authorization_url, + tokenUrl: p.token_url, + userinfoUrl: p.userinfo_url, + scopes: p.scopes || [], + pkceEnabled: p.pkce_enabled ?? true, + })), + }); + } catch (error) { + log.error('[admin-identity-providers] Failed to list providers:', error); + res.status(500).json({ error: 'Failed to list providers' }); + } + }); + + /** + * GET /identity-providers/:slug + * Get a single provider's details + */ + router.get('/identity-providers/:slug', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!requireAdmin(ctx, res)) return; + + const { slug } = req.params; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const provider = await ctx.withPgClient(async (client) => { + const sql = ` + SELECT + id, slug, kind, display_name, enabled, is_built_in, + client_id, client_secret_id, + authorization_url, token_url, userinfo_url, + scopes, pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + WHERE slug = $1 + `; + const result = await client.query(sql, [slug]); + return result.rows[0]; + }); + + if (!provider) { + return res.status(404).json({ error: 'Provider not found' }); + } + + res.json({ + id: provider.id, + slug: provider.slug, + kind: provider.kind, + displayName: provider.display_name, + enabled: provider.enabled, + isBuiltIn: provider.is_built_in, + clientId: provider.client_id, + hasSecret: !!provider.client_secret_id, + authorizationUrl: provider.authorization_url, + tokenUrl: provider.token_url, + userinfoUrl: provider.userinfo_url, + scopes: provider.scopes || [], + pkceEnabled: provider.pkce_enabled ?? true, + }); + } catch (error) { + log.error(`[admin-identity-providers] Failed to get provider ${slug}:`, error); + res.status(500).json({ error: 'Failed to get provider' }); + } + }); + + /** + * PATCH /identity-providers/:slug + * Update provider configuration (client_id, enabled, scopes, urls) + */ + router.patch('/identity-providers/:slug', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!requireAdmin(ctx, res)) return; + + const { slug } = req.params; + const body = req.body as UpdateProviderBody; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (body.clientId !== undefined) { + setClauses.push(`client_id = $${paramIndex++}`); + values.push(body.clientId); + } + if (body.enabled !== undefined) { + setClauses.push(`enabled = $${paramIndex++}`); + values.push(body.enabled); + } + if (body.scopes !== undefined) { + setClauses.push(`scopes = $${paramIndex++}`); + values.push(body.scopes); + } + if (body.authorizationUrl !== undefined) { + setClauses.push(`authorization_url = $${paramIndex++}`); + values.push(body.authorizationUrl); + } + if (body.tokenUrl !== undefined) { + setClauses.push(`token_url = $${paramIndex++}`); + values.push(body.tokenUrl); + } + if (body.userinfoUrl !== undefined) { + setClauses.push(`userinfo_url = $${paramIndex++}`); + values.push(body.userinfoUrl); + } + if (body.pkceEnabled !== undefined) { + setClauses.push(`pkce_enabled = $${paramIndex++}`); + values.push(body.pkceEnabled); + } + + if (setClauses.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + values.push(slug); + + await ctx.withPgClient(async (client) => { + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + SET ${setClauses.join(', ')} + WHERE slug = $${paramIndex} + `; + const result = await client.query(sql, values); + if (result.rowCount === 0) { + throw new Error('PROVIDER_NOT_FOUND'); + } + }); + + log.info(`[admin-identity-providers] Updated provider ${slug}`); + res.json({ success: true }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message === 'PROVIDER_NOT_FOUND') { + return res.status(404).json({ error: 'Provider not found' }); + } + log.error(`[admin-identity-providers] Failed to update provider ${slug}:`, error); + res.status(500).json({ error: 'Failed to update provider' }); + } + }); + + /** + * POST /identity-providers/:slug/secret + * Set or rotate the client secret for a provider + */ + router.post('/identity-providers/:slug/secret', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!requireAdmin(ctx, res)) return; + + const { slug } = req.params; + const body = req.body as RotateSecretBody; + + if (!body.clientSecret) { + return res.status(400).json({ error: 'clientSecret is required' }); + } + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName, rotateSecretFunction } = identityProviders; + + await ctx.withPgClient(async (client) => { + // First get the provider ID + const lookupSql = ` + SELECT id FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + WHERE slug = $1 + `; + const lookupResult = await client.query<{ id: string }>(lookupSql, [slug]); + if (lookupResult.rows.length === 0) { + throw new Error('PROVIDER_NOT_FOUND'); + } + + const providerId = lookupResult.rows[0].id; + + // Call the rotate secret procedure + const rotateSql = `CALL ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, rotateSecretFunction)}($1, $2)`; + await client.query(rotateSql, [providerId, body.clientSecret]); + }); + + log.info(`[admin-identity-providers] Rotated secret for provider ${slug}`); + res.json({ success: true }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message === 'PROVIDER_NOT_FOUND') { + return res.status(404).json({ error: 'Provider not found' }); + } + if (message.includes('IDENTITY_PROVIDER_NOT_FOUND')) { + return res.status(404).json({ error: 'Provider not found' }); + } + log.error(`[admin-identity-providers] Failed to rotate secret for ${slug}:`, error); + res.status(500).json({ error: 'Failed to rotate secret' }); + } + }); + + return router; +} diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 8607c9871a..33448cd03d 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -40,6 +40,7 @@ import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie'; import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload'; import { createLlmApiRouter } from './middleware/llm-api'; import { createOAuthRoutes } from './middleware/oauth'; +import { createIdentityProvidersRouter } from './middleware/identity-providers'; import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -204,6 +205,9 @@ class Server { // are handled without going through PostGraphile app.use('/auth', createOAuthRoutes(effectiveOpts)); + // Identity Providers API — mounted before graphile + app.use(createIdentityProvidersRouter()); + // LLM Agent REST API — mounted before graphile so SSE streaming // routes are handled without going through PostGraphile app.use(createLlmApiRouter()); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 99a301b3cf..65171c8c5d 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -30,7 +30,8 @@ const IDENTITY_PROVIDERS_MODULE_SQL = ` SELECT s.schema_name, ps.schema_name AS private_schema_name, - ipm.table_name + ipm.table_name, + ipm.prefix FROM metaschema_modules_public.identity_providers_module ipm JOIN metaschema_public.schema s ON s.id = ipm.schema_id JOIN metaschema_public.schema ps ON ps.id = ipm.private_schema_id @@ -73,6 +74,7 @@ interface IdentityProvidersModuleRow { schema_name: string; private_schema_name: string; table_name: string; + prefix: string; } interface ProviderRow { @@ -138,6 +140,8 @@ export const identityProvidersLoader: ModuleLoader = schemaName: moduleRow.schema_name, privateSchemaName: moduleRow.private_schema_name, tableName: moduleRow.table_name, + prefix: moduleRow.prefix, + rotateSecretFunction: `rotate_identity_provider_${moduleRow.prefix}_secret`, signInIdentityFunction: 'sign_in_identity', signUpIdentityFunction: 'sign_up_identity', providers, diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index fa6ab5bdbe..6e3a607a82 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -202,6 +202,10 @@ export interface IdentityProvidersConfig { schemaName: string; privateSchemaName: string; tableName: string; + // Module prefix for generated function names (e.g., 'app' → rotate_identity_provider_app_secret) + prefix: string; + // Generated function name for rotating client secrets + rotateSecretFunction: string; // Function names (defaults until DB schema adds these columns) signInIdentityFunction: string; signUpIdentityFunction: string; From c7d7f458e9c1c5d8f92355293ea9a475e30ed3ac Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Mon, 8 Jun 2026 20:44:09 +0800 Subject: [PATCH 2/5] feat(server): add app-settings-auth REST API Add endpoints for managing auth settings (cookie, captcha, OAuth config): - GET /app-settings-auth - get current settings - PATCH /app-settings-auth - update settings Requires administrator role. Uses sessions_module discovery to find the app_settings_auth table in tenant DB. Co-Authored-By: Claude Opus 4.5 --- graphql/server/src/index.ts | 1 + .../src/middleware/app-settings-auth.ts | 227 ++++++++++++++++++ graphql/server/src/server.ts | 4 + 3 files changed, 232 insertions(+) create mode 100644 graphql/server/src/middleware/app-settings-auth.ts diff --git a/graphql/server/src/index.ts b/graphql/server/src/index.ts index f30956c4f5..7a7f424d22 100644 --- a/graphql/server/src/index.ts +++ b/graphql/server/src/index.ts @@ -5,6 +5,7 @@ export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/ap export { createAuthenticateMiddleware } from './middleware/auth'; export { createUploadAuthenticateMiddleware } from './middleware/upload'; export { createIdentityProvidersRouter } from './middleware/identity-providers'; +export { createAppSettingsAuthRouter } from './middleware/app-settings-auth'; export { cors } from './middleware/cors'; export { graphile } from './middleware/graphile'; export { flush, flushService } from './middleware/flush'; diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts new file mode 100644 index 0000000000..e2fd09f355 --- /dev/null +++ b/graphql/server/src/middleware/app-settings-auth.ts @@ -0,0 +1,227 @@ +/** + * App Settings Auth API + * + * Express router for managing auth settings (cookie config, captcha, OAuth settings). + * Requires administrator role. Reads/writes to app_settings_auth table via + * the authSettings loader discovery. + * + * Routes: + * GET /app-settings-auth → get current settings + * PATCH /app-settings-auth → update settings + */ + +import { Router, Request, Response } from 'express'; +import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveContext } from '@constructive-io/express-context'; + +import './types'; + +const log = new Logger('app-settings-auth'); + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const AUTH_SETTINGS_DISCOVERY_SQL = ` + SELECT s.schema_name, sm.auth_settings_table AS table_name + FROM metaschema_modules_public.sessions_module sm + JOIN metaschema_public.schema s ON s.id = sm.schema_id + LIMIT 1 +`; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface AuthSettingsRow { + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | null; + cookie_path: string; + remember_me_duration: string | null; + enable_captcha: boolean; + captcha_site_key: string | null; + oauth_state_max_age: string | null; + oauth_require_verified_email: boolean; + oauth_error_redirect_path: string | null; +} + +interface UpdateAuthSettingsBody { + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + rememberMeDuration?: string | null; + enableCaptcha?: boolean; + captchaSiteKey?: string | null; + oauthStateMaxAge?: string | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function requireAdmin(ctx: ConstructiveContext, res: Response): boolean { + if (ctx.token?.role !== 'administrator') { + res.status(403).json({ error: 'ADMIN_REQUIRED' }); + return false; + } + return true; +} + +async function discoverAuthSettingsTable( + ctx: ConstructiveContext, +): Promise<{ schemaName: string; tableName: string } | null> { + return await ctx.withPgClient(async (client) => { + const result = await client.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + ); + const row = result.rows[0]; + if (!row) return null; + return { schemaName: row.schema_name, tableName: row.table_name }; + }); +} + +// ─── Router ───────────────────────────────────────────────────────────────── + +export function createAppSettingsAuthRouter(): Router { + const router = Router(); + + /** + * GET /app-settings-auth + * Get current auth settings + */ + router.get('/app-settings-auth', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!requireAdmin(ctx, res)) return; + + try { + const table = await discoverAuthSettingsTable(ctx); + if (!table) { + return res.status(404).json({ error: 'Auth settings module not configured' }); + } + + const settings = await ctx.withPgClient(async (client) => { + const sql = ` + SELECT + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age::text, + cookie_path, + remember_me_duration::text, + enable_captcha, + captcha_site_key, + oauth_state_max_age::text, + oauth_require_verified_email, + oauth_error_redirect_path + FROM ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + LIMIT 1 + `; + const result = await client.query(sql); + return result.rows[0]; + }); + + if (!settings) { + return res.status(404).json({ error: 'Auth settings not found' }); + } + + res.json({ + cookieSecure: settings.cookie_secure, + cookieSamesite: settings.cookie_samesite, + cookieDomain: settings.cookie_domain, + cookieHttponly: settings.cookie_httponly, + cookieMaxAge: settings.cookie_max_age, + cookiePath: settings.cookie_path, + rememberMeDuration: settings.remember_me_duration, + enableCaptcha: settings.enable_captcha, + captchaSiteKey: settings.captcha_site_key, + oauthStateMaxAge: settings.oauth_state_max_age, + oauthRequireVerifiedEmail: settings.oauth_require_verified_email, + oauthErrorRedirectPath: settings.oauth_error_redirect_path, + }); + } catch (error) { + log.error('[app-settings-auth] Failed to get settings:', error); + res.status(500).json({ error: 'Failed to get settings' }); + } + }); + + /** + * PATCH /app-settings-auth + * Update auth settings + */ + router.patch('/app-settings-auth', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!requireAdmin(ctx, res)) return; + + const body = req.body as UpdateAuthSettingsBody; + + try { + const table = await discoverAuthSettingsTable(ctx); + if (!table) { + return res.status(404).json({ error: 'Auth settings module not configured' }); + } + + const fieldMap: Record = { + cookieSecure: 'cookie_secure', + cookieSamesite: 'cookie_samesite', + cookieDomain: 'cookie_domain', + cookieHttponly: 'cookie_httponly', + cookieMaxAge: 'cookie_max_age', + cookiePath: 'cookie_path', + rememberMeDuration: 'remember_me_duration', + enableCaptcha: 'enable_captcha', + captchaSiteKey: 'captcha_site_key', + oauthStateMaxAge: 'oauth_state_max_age', + oauthRequireVerifiedEmail: 'oauth_require_verified_email', + oauthErrorRedirectPath: 'oauth_error_redirect_path', + }; + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [camelKey, snakeKey] of Object.entries(fieldMap)) { + if (camelKey in body) { + const value = (body as Record)[camelKey]; + if (snakeKey.includes('_age') || snakeKey.includes('_duration')) { + setClauses.push(`${snakeKey} = $${paramIndex++}::interval`); + } else { + setClauses.push(`${snakeKey} = $${paramIndex++}`); + } + values.push(value); + } + } + + if (setClauses.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + await ctx.withPgClient(async (client) => { + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + SET ${setClauses.join(', ')} + `; + await client.query(sql, values); + }); + + log.info('[app-settings-auth] Updated settings'); + res.json({ success: true }); + } catch (error) { + log.error('[app-settings-auth] Failed to update settings:', error); + res.status(500).json({ error: 'Failed to update settings' }); + } + }); + + return router; +} diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 33448cd03d..6eca7ec6b8 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -41,6 +41,7 @@ import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/up import { createLlmApiRouter } from './middleware/llm-api'; import { createOAuthRoutes } from './middleware/oauth'; import { createIdentityProvidersRouter } from './middleware/identity-providers'; +import { createAppSettingsAuthRouter } from './middleware/app-settings-auth'; import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -208,6 +209,9 @@ class Server { // Identity Providers API — mounted before graphile app.use(createIdentityProvidersRouter()); + // App Settings Auth API — mounted before graphile + app.use(createAppSettingsAuthRouter()); + // LLM Agent REST API — mounted before graphile so SSE streaming // routes are handled without going through PostGraphile app.use(createLlmApiRouter()); From 19a3b0b730b66d06f3c21d8750fd471ea20cac5e Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Tue, 9 Jun 2026 22:13:20 +0800 Subject: [PATCH 3/5] fix(loaders): disable updateAgeOnGet for proper TTL expiration TTL now counts from first cache set, not refreshed on reads. Previously, any request accessing cached data would reset the TTL, causing config changes to never take effect while traffic exists. Co-Authored-By: Claude Opus 4.5 --- packages/express-context/src/loaders/create-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index db2b55a829..28d477faee 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -30,7 +30,7 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade const cache = new LRUCache({ max: opts.max ?? DEFAULT_MAX, ttl: opts.ttlMs ?? DEFAULT_TTL_MS, - updateAgeOnGet: true, + updateAgeOnGet: false, // TTL from first set, not refreshed on read allowStale: false, }); From 258d988600ba8d90e4c0540e73b9482e2f40623d Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Tue, 9 Jun 2026 22:13:28 +0800 Subject: [PATCH 4/5] feat(server): allow app members to manage identity providers - Change permission check from isAppAdmin to isAppMember - Fix secret rotation by using direct SQL instead of broken stored function - The rotate_identity_provider_platform_secret function has a bug (missing database_id), bypassed with direct INSERT/UPDATE Co-Authored-By: Claude Opus 4.5 --- .../src/middleware/identity-providers.ts | 179 ++++++++++++------ 1 file changed, 116 insertions(+), 63 deletions(-) diff --git a/graphql/server/src/middleware/identity-providers.ts b/graphql/server/src/middleware/identity-providers.ts index 4f11f18793..a9fafdbb2e 100644 --- a/graphql/server/src/middleware/identity-providers.ts +++ b/graphql/server/src/middleware/identity-providers.ts @@ -12,7 +12,7 @@ * POST /identity-providers/:slug/secret → rotate client secret */ -import { Router, Request, Response } from 'express'; +import express, { Router, Request, Response } from 'express'; import { Logger } from '@pgpmjs/logger'; import { QuoteUtils } from '@pgsql/quotes'; import type { ConstructiveContext } from '@constructive-io/express-context'; @@ -55,9 +55,23 @@ interface RotateSecretBody { // ─── Helpers ──────────────────────────────────────────────────────────────── -function requireAdmin(ctx: ConstructiveContext, res: Response): boolean { - if (ctx.token?.role !== 'administrator') { - res.status(403).json({ error: 'ADMIN_REQUIRED' }); +async function isAppMember(ctx: ConstructiveContext): Promise { + const userId = ctx.userId; + if (!userId) return false; + + // Check if user is an app member (has a record in app_memberships_sprt) + const sql = ` + SELECT 1 FROM constructive_memberships_private.app_memberships_sprt + WHERE actor_id = $1 + LIMIT 1 + `; + const result = await ctx.pool.query(sql, [userId]); + return result.rows.length > 0; +} + +async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise { + if (!(await isAppMember(ctx))) { + res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); return false; } return true; @@ -68,6 +82,9 @@ function requireAdmin(ctx: ConstructiveContext, res: Response): boolean { export function createIdentityProvidersRouter(): Router { const router = Router(); + // Parse JSON body for PATCH/POST requests + router.use(express.json()); + /** * GET /identity-providers * List all identity providers (including disabled ones) @@ -78,7 +95,7 @@ export function createIdentityProvidersRouter(): Router { return res.status(500).json({ error: 'Missing context' }); } - if (!requireAdmin(ctx, res)) return; + if (!(await requireAppMember(ctx, res))) return; try { const identityProviders = await ctx.useModule('identityProviders'); @@ -88,19 +105,17 @@ export function createIdentityProvidersRouter(): Router { const { privateSchemaName, tableName } = identityProviders; - const providers = await ctx.withPgClient(async (client) => { - const sql = ` - SELECT - id, slug, kind, display_name, enabled, is_built_in, - client_id, client_secret_id, - authorization_url, token_url, userinfo_url, - scopes, pkce_enabled - FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - ORDER BY is_built_in DESC, slug ASC - `; - const result = await client.query(sql); - return result.rows; - }); + const sql = ` + SELECT + id, slug, kind, display_name, enabled, is_built_in, + client_id, client_secret_id, + authorization_url, token_url, userinfo_url, + scopes, pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + ORDER BY is_built_in DESC, slug ASC + `; + const result = await ctx.pool.query(sql); + const providers = result.rows; res.json({ providers: providers.map((p) => ({ @@ -135,7 +150,7 @@ export function createIdentityProvidersRouter(): Router { return res.status(500).json({ error: 'Missing context' }); } - if (!requireAdmin(ctx, res)) return; + if (!(await requireAppMember(ctx, res))) return; const { slug } = req.params; @@ -147,19 +162,17 @@ export function createIdentityProvidersRouter(): Router { const { privateSchemaName, tableName } = identityProviders; - const provider = await ctx.withPgClient(async (client) => { - const sql = ` - SELECT - id, slug, kind, display_name, enabled, is_built_in, - client_id, client_secret_id, - authorization_url, token_url, userinfo_url, - scopes, pkce_enabled - FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - WHERE slug = $1 - `; - const result = await client.query(sql, [slug]); - return result.rows[0]; - }); + const sql = ` + SELECT + id, slug, kind, display_name, enabled, is_built_in, + client_id, client_secret_id, + authorization_url, token_url, userinfo_url, + scopes, pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + WHERE slug = $1 + `; + const result = await ctx.pool.query(sql, [slug]); + const provider = result.rows[0]; if (!provider) { return res.status(404).json({ error: 'Provider not found' }); @@ -196,7 +209,7 @@ export function createIdentityProvidersRouter(): Router { return res.status(500).json({ error: 'Missing context' }); } - if (!requireAdmin(ctx, res)) return; + if (!(await requireAppMember(ctx, res))) return; const { slug } = req.params; const body = req.body as UpdateProviderBody; @@ -248,17 +261,15 @@ export function createIdentityProvidersRouter(): Router { values.push(slug); - await ctx.withPgClient(async (client) => { - const sql = ` - UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - SET ${setClauses.join(', ')} - WHERE slug = $${paramIndex} - `; - const result = await client.query(sql, values); - if (result.rowCount === 0) { - throw new Error('PROVIDER_NOT_FOUND'); - } - }); + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + SET ${setClauses.join(', ')} + WHERE slug = $${paramIndex} + `; + const result = await ctx.pool.query(sql, values); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Provider not found' }); + } log.info(`[admin-identity-providers] Updated provider ${slug}`); res.json({ success: true }); @@ -282,7 +293,7 @@ export function createIdentityProvidersRouter(): Router { return res.status(500).json({ error: 'Missing context' }); } - if (!requireAdmin(ctx, res)) return; + if (!(await requireAppMember(ctx, res))) return; const { slug } = req.params; const body = req.body as RotateSecretBody; @@ -297,27 +308,69 @@ export function createIdentityProvidersRouter(): Router { return res.status(404).json({ error: 'Identity providers module not configured' }); } - const { privateSchemaName, tableName, rotateSecretFunction } = identityProviders; - - await ctx.withPgClient(async (client) => { - // First get the provider ID - const lookupSql = ` - SELECT id FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - WHERE slug = $1 - `; - const lookupResult = await client.query<{ id: string }>(lookupSql, [slug]); - if (lookupResult.rows.length === 0) { - throw new Error('PROVIDER_NOT_FOUND'); - } + const { privateSchemaName, tableName } = identityProviders; + const databaseId = ctx.databaseId; + if (!databaseId) { + return res.status(500).json({ error: 'Database context not available' }); + } - const providerId = lookupResult.rows[0].id; + // Get provider info + const lookupSql = ` + SELECT id, client_secret_id FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + WHERE slug = $1 + `; + const lookupResult = await ctx.pool.query<{ id: string; client_secret_id: string | null }>(lookupSql, [slug]); + if (lookupResult.rows.length === 0) { + return res.status(404).json({ error: 'Provider not found' }); + } - // Call the rotate secret procedure - const rotateSql = `CALL ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, rotateSecretFunction)}($1, $2)`; - await client.query(rotateSql, [providerId, body.clientSecret]); - }); + const provider = lookupResult.rows[0]; + + // Ensure default namespace exists + const namespaceSql = ` + INSERT INTO constructive_infra_public.platform_namespaces (database_id, name) + VALUES ($1, 'default') + ON CONFLICT (database_id, name) DO UPDATE SET name = EXCLUDED.name + RETURNING id + `; + const namespaceResult = await ctx.pool.query<{ id: string }>(namespaceSql, [databaseId]); + const namespaceId = namespaceResult.rows[0].id; + + let secretId = provider.client_secret_id; + + if (secretId) { + // Update existing secret + const updateSecretSql = ` + UPDATE constructive_store_private.platform_secrets + SET value = $1::bytea, algo = 'plain', updated_at = now() + WHERE id = $2 + `; + await ctx.pool.query(updateSecretSql, [body.clientSecret, secretId]); + } else { + // Insert new secret + const insertSecretSql = ` + INSERT INTO constructive_store_private.platform_secrets (database_id, namespace_id, name, value, algo) + VALUES ($1, $2, $3, $4::bytea, 'plain') + RETURNING id + `; + const secretResult = await ctx.pool.query<{ id: string }>(insertSecretSql, [ + databaseId, + namespaceId, + `${slug}/client-secret`, + body.clientSecret, + ]); + secretId = secretResult.rows[0].id; + + // Link secret to provider + const linkSql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + SET client_secret_id = $1 + WHERE id = $2 + `; + await ctx.pool.query(linkSql, [secretId, provider.id]); + } - log.info(`[admin-identity-providers] Rotated secret for provider ${slug}`); + log.info(`[admin-identity-providers] Set secret for provider ${slug}`); res.json({ success: true }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); From 68552e6bb08c2deee8e91b625b2ef4218e6b3edb Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Tue, 9 Jun 2026 22:13:36 +0800 Subject: [PATCH 5/5] feat(server): expose allowIdentitySignIn/SignUp in auth settings API - Add allow_identity_sign_in and allow_identity_sign_up fields to GET/PATCH - Change permission check from isAppAdmin to isAppMember - These fields control whether OAuth login/signup is enabled Co-Authored-By: Claude Opus 4.5 --- .../src/middleware/app-settings-auth.ts | 105 +++++++++++------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts index e2fd09f355..16be195175 100644 --- a/graphql/server/src/middleware/app-settings-auth.ts +++ b/graphql/server/src/middleware/app-settings-auth.ts @@ -10,7 +10,7 @@ * PATCH /app-settings-auth → update settings */ -import { Router, Request, Response } from 'express'; +import express, { Router, Request, Response } from 'express'; import { Logger } from '@pgpmjs/logger'; import { QuoteUtils } from '@pgsql/quotes'; import type { ConstructiveContext } from '@constructive-io/express-context'; @@ -31,6 +31,8 @@ const AUTH_SETTINGS_DISCOVERY_SQL = ` // ─── Types ────────────────────────────────────────────────────────────────── interface AuthSettingsRow { + allow_identity_sign_in: boolean; + allow_identity_sign_up: boolean; cookie_secure: boolean; cookie_samesite: string; cookie_domain: string | null; @@ -46,6 +48,8 @@ interface AuthSettingsRow { } interface UpdateAuthSettingsBody { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; cookieSecure?: boolean; cookieSamesite?: string; cookieDomain?: string | null; @@ -62,9 +66,23 @@ interface UpdateAuthSettingsBody { // ─── Helpers ──────────────────────────────────────────────────────────────── -function requireAdmin(ctx: ConstructiveContext, res: Response): boolean { - if (ctx.token?.role !== 'administrator') { - res.status(403).json({ error: 'ADMIN_REQUIRED' }); +async function isAppMember(ctx: ConstructiveContext): Promise { + const userId = ctx.userId; + if (!userId) return false; + + // Check if user is an app member (has a record in app_memberships_sprt) + const sql = ` + SELECT 1 FROM constructive_memberships_private.app_memberships_sprt + WHERE actor_id = $1 + LIMIT 1 + `; + const result = await ctx.pool.query(sql, [userId]); + return result.rows.length > 0; +} + +async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise { + if (!(await isAppMember(ctx))) { + res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); return false; } return true; @@ -73,14 +91,12 @@ function requireAdmin(ctx: ConstructiveContext, res: Response): boolean { async function discoverAuthSettingsTable( ctx: ConstructiveContext, ): Promise<{ schemaName: string; tableName: string } | null> { - return await ctx.withPgClient(async (client) => { - const result = await client.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - ); - const row = result.rows[0]; - if (!row) return null; - return { schemaName: row.schema_name, tableName: row.table_name }; - }); + const result = await ctx.pool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + ); + const row = result.rows[0]; + if (!row) return null; + return { schemaName: row.schema_name, tableName: row.table_name }; } // ─── Router ───────────────────────────────────────────────────────────────── @@ -88,6 +104,9 @@ async function discoverAuthSettingsTable( export function createAppSettingsAuthRouter(): Router { const router = Router(); + // Parse JSON body for PATCH requests + router.use(express.json()); + /** * GET /app-settings-auth * Get current auth settings @@ -98,7 +117,7 @@ export function createAppSettingsAuthRouter(): Router { return res.status(500).json({ error: 'Missing context' }); } - if (!requireAdmin(ctx, res)) return; + if (!(await requireAppMember(ctx, res))) return; try { const table = await discoverAuthSettingsTable(ctx); @@ -106,33 +125,35 @@ export function createAppSettingsAuthRouter(): Router { return res.status(404).json({ error: 'Auth settings module not configured' }); } - const settings = await ctx.withPgClient(async (client) => { - const sql = ` - SELECT - cookie_secure, - cookie_samesite, - cookie_domain, - cookie_httponly, - cookie_max_age::text, - cookie_path, - remember_me_duration::text, - enable_captcha, - captcha_site_key, - oauth_state_max_age::text, - oauth_require_verified_email, - oauth_error_redirect_path - FROM ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} - LIMIT 1 - `; - const result = await client.query(sql); - return result.rows[0]; - }); + const sql = ` + SELECT + allow_identity_sign_in, + allow_identity_sign_up, + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age::text, + cookie_path, + remember_me_duration::text, + enable_captcha, + captcha_site_key, + oauth_state_max_age::text, + oauth_require_verified_email, + oauth_error_redirect_path + FROM ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + LIMIT 1 + `; + const result = await ctx.pool.query(sql); + const settings = result.rows[0]; if (!settings) { return res.status(404).json({ error: 'Auth settings not found' }); } res.json({ + allowIdentitySignIn: settings.allow_identity_sign_in, + allowIdentitySignUp: settings.allow_identity_sign_up, cookieSecure: settings.cookie_secure, cookieSamesite: settings.cookie_samesite, cookieDomain: settings.cookie_domain, @@ -162,7 +183,7 @@ export function createAppSettingsAuthRouter(): Router { return res.status(500).json({ error: 'Missing context' }); } - if (!requireAdmin(ctx, res)) return; + if (!(await requireAppMember(ctx, res))) return; const body = req.body as UpdateAuthSettingsBody; @@ -173,6 +194,8 @@ export function createAppSettingsAuthRouter(): Router { } const fieldMap: Record = { + allowIdentitySignIn: 'allow_identity_sign_in', + allowIdentitySignUp: 'allow_identity_sign_up', cookieSecure: 'cookie_secure', cookieSamesite: 'cookie_samesite', cookieDomain: 'cookie_domain', @@ -207,13 +230,11 @@ export function createAppSettingsAuthRouter(): Router { return res.status(400).json({ error: 'No fields to update' }); } - await ctx.withPgClient(async (client) => { - const sql = ` - UPDATE ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} - SET ${setClauses.join(', ')} - `; - await client.query(sql, values); - }); + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + SET ${setClauses.join(', ')} + `; + await ctx.pool.query(sql, values); log.info('[app-settings-auth] Updated settings'); res.json({ success: true });