From 367bbf4fd7b7082a3e83a2c1e51f66743cfbf1e4 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 16:21:02 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(auth):=20@better-auth/sso=20=E2=80=94?= =?UTF-8?q?=20per-env=20external=20IdP=20(OIDC/SAML)=20relying=20party?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0024 V1: the OPEN per-env SSO mechanism. Lets an environment federate login to a customer's own OIDC/SAML IdP (Okta / Entra / Google), registered at runtime in the env (cloud-free for self-host). - platform-objects: new `sys_sso_provider` object mirroring @better-auth/sso @1.6.20's BaseSSOProvider (issuer, oidc_config/saml_config JSON blobs, user_id, provider_id, organization_id, domain); managedBy: better-auth; mutations via /api/v1/auth/sso/{register,delete-provider}. - plugin-auth: AUTH_SSO_PROVIDER_SCHEMA + buildSsoPluginSchema() (camelCase→ snake_case, per the buildOauthProviderPluginSchema template); auth-manager wires sso({ schema }) gated by enabled.sso (OS_SSO_ENABLED, mirrors OS_OIDC_PROVIDER_ENABLED). - package.json: add @better-auth/sso ^1.6.20. Follow-ups: provisionUser default-role, ssoClient() + admin UI (PR-2). Co-Authored-By: Claude Opus 4.8 --- .../platform-objects/src/identity/index.ts | 3 + .../src/identity/sys-sso-provider.object.ts | 177 ++++++++++++++++++ packages/plugins/plugin-auth/package.json | 1 + .../plugins/plugin-auth/src/auth-manager.ts | 22 +++ .../plugin-auth/src/auth-schema-config.ts | 44 +++++ 5 files changed, 247 insertions(+) create mode 100644 packages/platform-objects/src/identity/sys-sso-provider.object.ts diff --git a/packages/platform-objects/src/identity/index.ts b/packages/platform-objects/src/identity/index.ts index a052443012..2627b192e1 100644 --- a/packages/platform-objects/src/identity/index.ts +++ b/packages/platform-objects/src/identity/index.ts @@ -35,3 +35,6 @@ export { SysOauthAccessToken } from './sys-oauth-access-token.object.js'; export { SysOauthRefreshToken } from './sys-oauth-refresh-token.object.js'; export { SysOauthConsent } from './sys-oauth-consent.object.js'; export { SysJwks } from './sys-jwks.object.js'; + +// ── External SSO (relying-party, @better-auth/sso) ───────────────── +export { SysSsoProvider } from './sys-sso-provider.object.js'; diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts new file mode 100644 index 0000000000..8b4cdc39eb --- /dev/null +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_sso_provider — Registered external SSO identity provider (OIDC / SAML) + * + * Backed by `@better-auth/sso`'s `ssoProvider` model. Each row is an external + * IdP that THIS environment federates LOGIN to (the relying-party / client + * side) — e.g. the customer's Okta / Entra / Google Workspace. This is the + * per-environment SSO **mechanism** (ADR-0024): OPEN, configured in the env, + * and cloud-free for self-host. + * + * better-auth stores the protocol detail as a JSON blob in `oidc_config` + * (OIDC: clientId, clientSecret, endpoints, scopes, mapping, pkce, …) or + * `saml_config` (SAML: entryPoint, cert, identifierFormat, mapping, …) — not + * as separate columns. Field set mirrors `@better-auth/sso@1.6.20`'s + * `BaseSSOProvider`: issuer, oidcConfig, samlConfig, userId, providerId, + * organizationId, domain. + * + * All mutations route through better-auth's `/api/v1/auth/sso/*` endpoints + * (register / delete-provider) so config validation and secret handling run; + * the generic data layer is read-only (see `enable.apiMethods`). + * + * @namespace sys + */ +export const SysSsoProvider = ObjectSchema.create({ + name: 'sys_sso_provider', + label: 'SSO Provider', + pluralLabel: 'SSO Providers', + icon: 'shield-check', + isSystem: true, + managedBy: 'better-auth', + // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema. + protection: { + lock: 'full', + reason: 'Identity table managed by better-auth (@better-auth/sso) — see ADR-0024.', + docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', + }, + description: 'External SSO identity providers (OIDC / SAML) this environment federates login to', + displayNameField: 'provider_id', + titleFormat: '{provider_id}', + compactLayout: ['provider_id', 'issuer', 'domain'], + + // All mutations go through @better-auth/sso's endpoints under + // /api/v1/auth/sso/* (register / delete-provider) rather than the generic + // data layer, so server-side config validation + secret handling run. + actions: [ + { + name: 'register_sso_provider', + label: 'Register SSO Provider', + icon: 'plus-circle', + variant: 'primary', + mode: 'create', + locations: ['list_toolbar'], + type: 'api', + method: 'POST', + target: '/api/v1/auth/sso/register', + refreshAfter: true, + params: [ + { name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "okta" or "acme-entra".' }, + { name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer / discovery base, e.g. https://acme.okta.com.' }, + { name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP.' }, + { name: 'clientId', label: 'Client ID', type: 'text', required: true }, + { name: 'clientSecret', label: 'Client Secret', type: 'text', required: true }, + ], + }, + { + name: 'delete_sso_provider', + label: 'Delete SSO Provider', + icon: 'trash-2', + variant: 'danger', + mode: 'delete', + locations: ['list_item', 'record_header'], + type: 'api', + method: 'POST', + target: '/api/v1/auth/sso/delete-provider', + confirmText: 'Delete this SSO provider? Users from its domain will no longer be able to sign in through it.', + successMessage: 'SSO provider deleted', + refreshAfter: true, + params: [ + { name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true }, + ], + }, + ], + + listViews: { + all: { + type: 'grid', + name: 'all', + label: 'All', + data: { provider: 'object', object: 'sys_sso_provider' }, + columns: ['provider_id', 'issuer', 'domain', 'created_at'], + sort: [{ field: 'provider_id', order: 'asc' }], + pagination: { pageSize: 50 }, + }, + }, + + fields: { + id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), + + provider_id: Field.text({ + label: 'Provider ID', + required: true, + searchable: true, + maxLength: 255, + description: 'Stable provider identifier (unique within the environment)', + group: 'Identity', + }), + + issuer: Field.text({ + label: 'Issuer', + required: true, + maxLength: 2048, + description: 'IdP issuer URL', + group: 'Identity', + }), + + domain: Field.text({ + label: 'Email Domain', + required: true, + maxLength: 255, + description: 'Email domain routed to this IdP (e.g. acme.com)', + group: 'Identity', + }), + + oidc_config: Field.textarea({ + label: 'OIDC Config', + required: false, + description: 'JSON: clientId, clientSecret, endpoints, scopes, mapping, pkce (managed by better-auth)', + group: 'Protocol', + }), + + saml_config: Field.textarea({ + label: 'SAML Config', + required: false, + description: 'JSON: entryPoint, cert, identifierFormat, mapping (managed by better-auth)', + group: 'Protocol', + }), + + user_id: Field.lookup('sys_user', { + label: 'Registered By', + required: false, + description: 'User who registered this provider', + group: 'System', + }), + + organization_id: Field.text({ + label: 'Organization', + required: false, + maxLength: 255, + description: 'Organization scope (when org-scoped SSO is used)', + group: 'System', + }), + + created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', readonly: true, group: 'System' }), + updated_at: Field.datetime({ label: 'Updated At', defaultValue: 'NOW()', readonly: true, group: 'System' }), + }, + + indexes: [ + { fields: ['provider_id'], unique: true }, + { fields: ['domain'] }, + { fields: ['user_id'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + // Mutations go through /api/v1/auth/sso/* (register / delete-provider); + // the generic data layer is read-only so sysadmins cannot bypass + // server-side validation / secret handling. + apiMethods: ['get', 'list'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 9df7e2bc84..ed253b4bd2 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -20,6 +20,7 @@ "dependencies": { "@better-auth/core": "^1.6.20", "@better-auth/oauth-provider": "^1.6.20", + "@better-auth/sso": "^1.6.20", "@noble/hashes": "^2.2.0", "@objectstack/core": "workspace:*", "@objectstack/platform-objects": "workspace:*", diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f17699a42e..3b82379a07 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -23,6 +23,7 @@ import { buildOrganizationPluginSchema, buildTwoFactorPluginSchema, buildOauthProviderPluginSchema, + buildSsoPluginSchema, buildDeviceAuthorizationPluginSchema, buildJwtPluginSchema, buildAdminPluginSchema, @@ -625,6 +626,8 @@ export class AuthManager { // override per-environment without touching the application bundle. const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED; const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined; + const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED; + const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined; const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); const enabled = { organization: pluginConfig.organization ?? true, @@ -634,6 +637,7 @@ export class AuthManager { oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false, deviceAuthorization: pluginConfig.deviceAuthorization ?? false, admin: pluginConfig.admin ?? false, + sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false, }; // bearer() — ALWAYS enabled. @@ -926,6 +930,24 @@ export class AuthManager { })); } + // External SSO (OIDC / SAML) relying-party — lets this environment federate + // login to a customer's own IdP (Okta / Entra / Google …). Per-env, runtime- + // registered providers live in `sys_sso_provider` (ADR-0024: the OPEN SSO + // mechanism — cloud-free for self-host). Endpoints mount under + // /api/v1/auth/sso/{register,providers,delete-provider,callback,…}. + // + // Toggle with `OS_SSO_ENABLED` (mirrors `OS_OIDC_PROVIDER_ENABLED`). + if (enabled.sso) { + const { sso } = await import('@better-auth/sso'); + plugins.push(sso({ + schema: buildSsoPluginSchema(), + // provisionUser / organizationProvisioning are the hook for assigning a + // default env role on first federated login (ADR-0024 V1 minimal + // decisions). JIT user creation works out of the box via account + // linking; wire role defaults here in the follow-up. + })); + } + // Device Authorization Grant (RFC 8628) — for CLI / TV-style devices. // Exposes the standard `/device/{code,token,approve,deny}` endpoints // and persists pending requests in `sys_device_code`. diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index fe4eabe33f..0edfac0846 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -650,6 +650,50 @@ export function buildOauthProviderPluginSchema() { */ export const buildOidcProviderPluginSchema = buildOauthProviderPluginSchema; +// --------------------------------------------------------------------------- +// SSO plugin – ssoProvider table (@better-auth/sso) +// --------------------------------------------------------------------------- + +/** + * `@better-auth/sso` plugin `ssoProvider` model mapping. + * + * Each row is an external OIDC/SAML IdP this environment federates login to + * (the relying-party side — ADR-0024's OPEN per-env SSO mechanism). The + * protocol detail lives in JSON blobs (`oidcConfig` / `samlConfig`); the model + * itself is thin. Mirrors @better-auth/sso@1.6.20's `BaseSSOProvider`. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | providerId | provider_id | + * | oidcConfig | oidc_config | + * | samlConfig | saml_config | + * | userId | user_id | + * | organizationId | organization_id | + * | issuer / domain | (same name — no remap) | + */ +export const AUTH_SSO_PROVIDER_SCHEMA = { + modelName: 'sys_sso_provider', + fields: { + providerId: 'provider_id', + oidcConfig: 'oidc_config', + samlConfig: 'saml_config', + userId: 'user_id', + organizationId: 'organization_id', + }, +} as const; + +/** + * Builds the `schema` option for `@better-auth/sso`'s `sso()` plugin, pointing + * its single `ssoProvider` model at ObjectStack's `sys_sso_provider` table. + * + * @returns An object suitable for `sso({ schema: … })` + */ +export function buildSsoPluginSchema() { + return { + ssoProvider: AUTH_SSO_PROVIDER_SCHEMA, + }; +} + // --------------------------------------------------------------------------- // Helper: build device-authorization plugin schema option // --------------------------------------------------------------------------- From cf381a75ad6263d5dced7c9be1ec8c20f8ecdc21 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 16:36:03 +0800 Subject: [PATCH 2/5] fix(auth): sso() takes no schema option; bridge ssoProvider via adapter map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @better-auth/sso (1.6.20) exposes NO `schema` option (verified: no mergeSchema, runtime never reads options.schema), so the per-plugin schema-remap that oauthProvider uses is unavailable. Drop the invalid `schema` arg + the dead buildSsoPluginSchema(); call sso() bare (gated off by OS_SSO_ENABLED). Add ssoProvider -> sys_sso_provider to AUTH_MODEL_TO_PROTOCOL. KNOWN-INCOMPLETE (documented inline): the ACTIVE factory adapter (createObjectQLAdapterFactory) passes the raw better-auth model name to the data engine and maps fields only from per-model declarations — neither of which the sso plugin provides. Finishing the integration needs objectql-adapter to resolve schema-less plugin model + camelCase field names, then full E2E. Compiles green (DTS ok for plugin-auth + platform-objects); feature off by default. + pnpm-lock.yaml (@better-auth/sso ^1.6.20). Co-Authored-By: Claude Opus 4.8 --- .../plugins/plugin-auth/src/auth-manager.ts | 17 +- .../plugin-auth/src/auth-schema-config.ts | 16 +- .../plugin-auth/src/objectql-adapter.ts | 9 + pnpm-lock.yaml | 160 ++++++++++++++++++ 4 files changed, 183 insertions(+), 19 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 3b82379a07..d9d42eac8f 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -23,7 +23,6 @@ import { buildOrganizationPluginSchema, buildTwoFactorPluginSchema, buildOauthProviderPluginSchema, - buildSsoPluginSchema, buildDeviceAuthorizationPluginSchema, buildJwtPluginSchema, buildAdminPluginSchema, @@ -939,13 +938,15 @@ export class AuthManager { // Toggle with `OS_SSO_ENABLED` (mirrors `OS_OIDC_PROVIDER_ENABLED`). if (enabled.sso) { const { sso } = await import('@better-auth/sso'); - plugins.push(sso({ - schema: buildSsoPluginSchema(), - // provisionUser / organizationProvisioning are the hook for assigning a - // default env role on first federated login (ADR-0024 V1 minimal - // decisions). JIT user creation works out of the box via account - // linking; wire role defaults here in the follow-up. - })); + // NOTE: unlike `oauthProvider`, @better-auth/sso hardcodes its `ssoProvider` + // model and accepts NO `schema` option (verified against 1.6.20 — no + // mergeSchema, runtime never reads options.schema). Its table mapping to + // `sys_sso_provider` must therefore be resolved by the better-auth adapter + // / a global model map, not per-plugin here (see AUTH_SSO_PROVIDER_SCHEMA; + // TODO confirm the resolved table name in E2E). + // provisionUser / organizationProvisioning will assign a default env role + // on first federated login (ADR-0024 V1); JIT works via account linking. + plugins.push(sso()); } // Device Authorization Grant (RFC 8628) — for CLI / TV-style devices. diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index 0edfac0846..aaf856008f 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -682,17 +682,11 @@ export const AUTH_SSO_PROVIDER_SCHEMA = { }, } as const; -/** - * Builds the `schema` option for `@better-auth/sso`'s `sso()` plugin, pointing - * its single `ssoProvider` model at ObjectStack's `sys_sso_provider` table. - * - * @returns An object suitable for `sso({ schema: … })` - */ -export function buildSsoPluginSchema() { - return { - ssoProvider: AUTH_SSO_PROVIDER_SCHEMA, - }; -} +// NOTE: there is intentionally no `buildSsoPluginSchema()`. Unlike +// `oauthProvider`, the @better-auth/sso plugin exposes NO `schema` option +// (verified vs 1.6.20), so the mapping above cannot be handed to the plugin — +// it must be consumed at the ADAPTER layer (AUTH_MODEL_TO_PROTOCOL + field +// resolution in objectql-adapter.ts). See ADR-0024. // --------------------------------------------------------------------------- // Helper: build device-authorization plugin schema option diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 04eaf28ebe..2eee000693 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -16,6 +16,15 @@ export const AUTH_MODEL_TO_PROTOCOL: Record = { session: SystemObjectName.SESSION, account: SystemObjectName.ACCOUNT, verification: SystemObjectName.VERIFICATION, + // @better-auth/sso has NO `schema` option (verified vs 1.6.20 — no + // mergeSchema, runtime never reads options.schema), so it cannot declare + // its modelName/fields. Bridge the table name here. NOTE: the ACTIVE + // factory adapter (createObjectQLAdapterFactory) passes the raw `model` + // to dataEngine and does NOT yet consult resolveProtocolName for plugin + // models — nor map sso's camelCase fields (oidcConfig→oidc_config …). + // Finishing the @better-auth/sso integration needs that adapter work + + // E2E (see ADR-0024 / sys_sso_provider). Off by default (OS_SSO_ENABLED). + ssoProvider: 'sys_sso_provider', }; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da53cfac76..75b1ce6dbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1352,6 +1352,9 @@ importers: '@better-auth/oauth-provider': specifier: ^1.6.20 version: 1.6.20(8b7e5f0044af09e55a9541191cb216a7) + '@better-auth/sso': + specifier: ^1.6.20 + version: 1.6.20(8b7e5f0044af09e55a9541191cb216a7) '@noble/hashes': specifier: ^2.2.0 version: 2.2.0 @@ -2382,6 +2385,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@authenio/xml-encryption@2.0.2': + resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} + engines: {node: '>=12'} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -2690,6 +2697,15 @@ packages: prisma: optional: true + '@better-auth/sso@1.6.20': + resolution: {integrity: sha512-cT3dthGkfDAz/k9jTtBOfsBtxsEgbO2hs5nSQlb0FdO3L/8MLi19iaIoQd1dmEun93MNqenZmYEZrPDKWWiISQ==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + better-auth: ^1.6.20 + better-call: 1.3.6 + '@better-auth/telemetry@1.6.20': resolution: {integrity: sha512-3BhbY3naQDERvdJvJ7fGszVY6rpsVfc6c9uyBVZlC1coVEF/rkM0rIcjtMVI1GUH7vWy1wjR6qF5vQnMun3XNQ==} peerDependencies: @@ -3430,6 +3446,9 @@ packages: '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4703,6 +4722,14 @@ packages: engines: {node: '>= 20'} hasBin: true + '@xmldom/is-dom-node@1.0.1': + resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} + engines: {node: '>= 16'} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -4794,6 +4821,9 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + aproba@2.1.0: resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} @@ -4820,6 +4850,9 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -5720,6 +5753,10 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true + fast-xml-parser@5.9.3: + resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==} + hasBin: true + fastify@5.8.5: resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} @@ -6297,6 +6334,9 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-unsafe@1.0.1: + resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -7119,6 +7159,9 @@ packages: node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-rsa@1.1.1: + resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + node-sarif-builder@3.4.0: resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} engines: {node: '>=20'} @@ -7849,6 +7892,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + samlify@2.13.1: + resolution: {integrity: sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -8100,6 +8146,9 @@ packages: strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -8249,9 +8298,16 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.4.0: resolution: {integrity: sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==} + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tldts@7.4.0: resolution: {integrity: sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==} hasBin: true @@ -8682,6 +8738,13 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-crypto@6.1.2: + resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==} + engines: {node: '>=16'} + + xml-escape@1.1.0: + resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} @@ -8690,10 +8753,25 @@ packages: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xpath@0.0.32: + resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==} + engines: {node: '>=0.6.0'} + + xpath@0.0.33: + resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==} + engines: {node: '>=0.6.0'} + + xpath@0.0.34: + resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} + engines: {node: '>=0.6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -8824,6 +8902,12 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@authenio/xml-encryption@2.0.2': + dependencies: + '@xmldom/xmldom': 0.8.13 + escape-html: 1.0.3 + xpath: 0.0.32 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -9333,6 +9417,19 @@ snapshots: '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 + '@better-auth/sso@1.6.20(8b7e5f0044af09e55a9541191cb216a7)': + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + better-auth: 1.6.20(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.66.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3(@typescript-eslint/types@8.61.1))(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.55.3(@typescript-eslint/types@8.61.1))(typescript@6.0.3)(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(better-sqlite3@12.11.1)(mongodb@7.3.0(socks@2.8.9))(mysql2@3.22.4(@types/node@26.0.0))(next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(svelte@5.55.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9) + better-call: 1.3.6(zod@4.4.3) + fast-xml-parser: 5.9.3 + jose: 6.2.3 + samlify: 2.13.1 + tldts: 6.1.86 + zod: 4.4.3 + '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) @@ -10026,6 +10123,8 @@ snapshots: '@nodable/entities@2.1.0': {} + '@nodable/entities@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -11313,6 +11412,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@xmldom/is-dom-node@1.0.1': {} + + '@xmldom/xmldom@0.8.13': {} + abbrev@1.1.1: optional: true @@ -11401,6 +11504,8 @@ snapshots: any-promise@1.3.0: {} + anynum@1.0.1: {} + aproba@2.1.0: optional: true @@ -11424,6 +11529,10 @@ snapshots: array-union@2.1.0: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.4: @@ -12312,6 +12421,15 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.3.0 + fast-xml-parser@5.9.3: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.2.0 + is-unsafe: 1.0.1 + path-expression-matcher: 1.5.0 + strnum: 2.4.1 + xml-naming: 0.1.0 + fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -13017,6 +13135,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-unsafe@1.0.1: {} + is-windows@1.0.2: {} is-wsl@2.2.0: @@ -14099,6 +14219,10 @@ snapshots: node-mock-http@1.0.4: {} + node-rsa@1.1.1: + dependencies: + asn1: 0.2.6 + node-sarif-builder@3.4.0: dependencies: '@types/sarif': 2.1.7 @@ -14829,6 +14953,16 @@ snapshots: safer-buffer@2.1.2: {} + samlify@2.13.1: + dependencies: + '@authenio/xml-encryption': 2.0.2 + '@xmldom/xmldom': 0.8.13 + node-rsa: 1.1.1 + xml: 1.0.1 + xml-crypto: 6.1.2 + xml-escape: 1.1.0 + xpath: 0.0.34 + sax@1.6.0: {} scheduler@0.27.0: {} @@ -15137,6 +15271,10 @@ snapshots: strnum@2.3.0: {} + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -15330,8 +15468,14 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@6.1.86: {} + tldts-core@7.4.0: {} + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tldts@7.4.0: dependencies: tldts-core: 7.4.0 @@ -15700,6 +15844,14 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-crypto@6.1.2: + dependencies: + '@xmldom/is-dom-node': 1.0.1 + '@xmldom/xmldom': 0.8.13 + xpath: 0.0.33 + + xml-escape@1.1.0: {} + xml-naming@0.1.0: {} xml2js@0.5.0: @@ -15707,8 +15859,16 @@ snapshots: sax: 1.6.0 xmlbuilder: 11.0.1 + xml@1.0.1: {} + xmlbuilder@11.0.1: {} + xpath@0.0.32: {} + + xpath@0.0.33: {} + + xpath@0.0.34: {} + xtend@4.0.2: {} y18n@5.0.8: {} From 91041d8127ecdb9dd8c998e2a73503f06942bf93 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 16:51:30 +0800 Subject: [PATCH 3/5] feat(auth): bridge schema-less plugin models in the factory adapter (sso) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make createObjectQLAdapterFactory resolve the table name (resolveProtocolName) AND map camelCase<->snake fields for models remapped via AUTH_MODEL_TO_PROTOCOL — i.e. better-auth plugins like @better-auth/sso that expose no `schema` option. Scoped by `objectName !== model`, so core / schema-declared models are byte-for-byte unchanged. This makes the @better-auth/sso integration (sys_sso_provider) functional at the data layer. Verified: 29 unit tests pass — 3 new sso-bridging tests drive the REAL better-auth adapter wrapper (sso plugin loaded): insert maps ssoProvider + oidcConfig -> sys_sso_provider + oidc_config, reads map back to camelCase, where fields are snaked; the 26 existing core-path tests are unchanged. Remaining: full browser E2E (login via a live external OIDC IdP -> JIT user + sys_account.provider_id + default role). Co-Authored-By: Claude Opus 4.8 --- .../plugin-auth/src/objectql-adapter.test.ts | 44 +++++++++ .../plugin-auth/src/objectql-adapter.ts | 93 +++++++++++++------ 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts index a8a5cb9ba1..9633e71c04 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts @@ -24,6 +24,7 @@ import { } from './auth-schema-config'; import { SystemObjectName } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; +import { sso } from '@better-auth/sso'; describe('AUTH_MODEL_TO_PROTOCOL mapping', () => { it('should map all four core better-auth models to sys_ protocol names', () => { @@ -279,3 +280,46 @@ describe('createObjectQLAdapter – legacy model name mapping', () => { expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' }); }); }); + +describe('createObjectQLAdapterFactory – schema-less plugin bridging (@better-auth/sso)', () => { + // The sso plugin exposes no `schema` option, so its `ssoProvider` table + + // camelCase fields are bridged at the adapter layer. Pass the plugin so + // better-auth's wrapper recognises the model (it validates against the + // merged schema before delegating to our adapter methods). + const makeAdapter = (findOneRow: any = { id: '1', provider_id: 'okta', oidc_config: '{"clientId":"x"}', domain: 'acme.com' }) => { + const engine = { + insert: vi.fn().mockImplementation((_m: string, d: any) => Promise.resolve({ id: '1', ...d })), + findOne: vi.fn().mockResolvedValue(findOneRow), + find: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + update: vi.fn().mockResolvedValue({ id: '1' }), + delete: vi.fn().mockResolvedValue(undefined), + } as unknown as IDataEngine; + const adapter: any = (createObjectQLAdapterFactory(engine) as any)({ plugins: [sso()] } as any); + return { engine, adapter }; + }; + + it('resolveProtocolName bridges ssoProvider -> sys_sso_provider', () => { + expect(resolveProtocolName('ssoProvider')).toBe('sys_sso_provider'); + expect(AUTH_MODEL_TO_PROTOCOL.ssoProvider).toBe('sys_sso_provider'); + }); + + it('maps the ssoProvider model + camelCase fields to sys_sso_provider snake columns on insert', async () => { + const { engine, adapter } = makeAdapter(); + await adapter.create({ model: 'ssoProvider', data: { providerId: 'okta', oidcConfig: '{"clientId":"x"}', domain: 'acme.com' } }); + const [tbl, payload] = (engine.insert as any).mock.calls[0]; + expect(tbl).toBe('sys_sso_provider'); + expect(payload).toMatchObject({ provider_id: 'okta', oidc_config: '{"clientId":"x"}', domain: 'acme.com' }); + expect(payload).not.toHaveProperty('oidcConfig'); + }); + + it('maps snake columns back to camelCase on read', async () => { + const { adapter } = makeAdapter(); + const row: any = await adapter.findOne({ + model: 'ssoProvider', + where: [{ field: 'providerId', value: 'okta', operator: 'eq', connector: 'AND' }], + }); + expect(row).toMatchObject({ providerId: 'okta', oidcConfig: '{"clientId":"x"}' }); + expect(row).not.toHaveProperty('oidc_config'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 2eee000693..50f02f2aac 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -161,6 +161,21 @@ function convertWhere(where: CleanedWhere[]): Record { * @returns better-auth AdapterFactory */ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) { + // Field-name bridging for better-auth plugins that expose NO `schema` option + // (e.g. @better-auth/sso): when a model is remapped via AUTH_MODEL_TO_PROTOCOL, + // its camelCase model fields are also converted to snake_case columns on the + // way in and back to camelCase on the way out. SCOPED by `objectName !== model` + // so core / schema-declared models are byte-for-byte untouched. + const camelToSnake = (s: string): string => s.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()); + const snakeToCamel = (s: string): string => s.replace(/_([a-z])/g, (_m, c) => c.toUpperCase()); + const remapKeys = (obj: Record, fn: (k: string) => string): Record => { + const out: Record = {}; + for (const k of Object.keys(obj)) out[fn(k)] = obj[k]; + return out; + }; + const remapWhere = (where: CleanedWhere[]): CleanedWhere[] => + where.map((c) => ({ ...c, field: camelToSnake(c.field) })); + return createAdapterFactory({ config: { adapterId: 'objectql', @@ -177,18 +192,25 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) { create: async >( { model, data, select: _select }: { model: string; data: T; select?: string[] }, ): Promise => { - const result = await dataEngine.insert(model, data); - return normaliseLegacyDates(model, result) as T; + const objectName = resolveProtocolName(model); + const bridged = objectName !== model; + const result = await dataEngine.insert(objectName, bridged ? remapKeys(data, camelToSnake) : data); + const norm = normaliseLegacyDates(model, result); + return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T; }, findOne: async ( { model, where, select, join: _join }: { model: string; where: CleanedWhere[]; select?: string[]; join?: any }, ): Promise => { - const filter = convertWhere(where); - - const result = await dataEngine.findOne(model, { where: filter, fields: select }); - - return result ? (normaliseLegacyDates(model, result) as T) : null; + const objectName = resolveProtocolName(model); + const bridged = objectName !== model; + const filter = convertWhere(bridged ? remapWhere(where) : where); + const fields = bridged && select ? select.map(camelToSnake) : select; + + const result = await dataEngine.findOne(objectName, { where: filter, fields }); + if (!result) return null; + const norm = normaliseLegacyDates(model, result); + return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T; }, findMany: async ( @@ -197,51 +219,66 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) { offset?: number; sortBy?: { field: string; direction: 'asc' | 'desc' }; join?: any; }, ): Promise => { - const filter = where ? convertWhere(where) : {}; + const objectName = resolveProtocolName(model); + const bridged = objectName !== model; + const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {}; const orderBy = sortBy - ? [{ field: sortBy.field, order: sortBy.direction as 'asc' | 'desc' }] + ? [{ field: bridged ? camelToSnake(sortBy.field) : sortBy.field, order: sortBy.direction as 'asc' | 'desc' }] : undefined; - const results = await dataEngine.find(model, { + const results = await dataEngine.find(objectName, { where: filter, limit: limit || 100, offset, orderBy, }); - return results.map((r) => normaliseLegacyDates(model, r as Record)) as T[]; + return results.map((r) => { + const norm = normaliseLegacyDates(model, r as Record); + return bridged ? remapKeys(norm, snakeToCamel) : norm; + }) as T[]; }, count: async ( { model, where }: { model: string; where?: CleanedWhere[] }, ): Promise => { - const filter = where ? convertWhere(where) : {}; - return await dataEngine.count(model, { where: filter }); + const objectName = resolveProtocolName(model); + const bridged = objectName !== model; + const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {}; + return await dataEngine.count(objectName, { where: filter }); }, update: async ( { model, where, update }: { model: string; where: CleanedWhere[]; update: T }, ): Promise => { - const filter = convertWhere(where); + const objectName = resolveProtocolName(model); + const bridged = objectName !== model; + const filter = convertWhere(bridged ? remapWhere(where) : where); // ObjectQL requires an ID for updates – find the record first - const record = await dataEngine.findOne(model, { where: filter }); + const record = await dataEngine.findOne(objectName, { where: filter }); if (!record) return null; - const result = await dataEngine.update(model, { ...(update as any), id: record.id }); - return result ? (normaliseLegacyDates(model, result) as T) : null; + const patch = bridged ? remapKeys(update as any, camelToSnake) : (update as any); + const result = await dataEngine.update(objectName, { ...patch, id: record.id }); + if (!result) return null; + const norm = normaliseLegacyDates(model, result); + return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T; }, updateMany: async ( { model, where, update }: { model: string; where: CleanedWhere[]; update: Record }, ): Promise => { - const filter = convertWhere(where); + const objectName = resolveProtocolName(model); + const bridged = objectName !== model; + const filter = convertWhere(bridged ? remapWhere(where) : where); // Sequential updates: ObjectQL requires an ID per update - const records = await dataEngine.find(model, { where: filter }); + const records = await dataEngine.find(objectName, { where: filter }); + const patch = bridged ? remapKeys(update, camelToSnake) : update; for (const record of records) { - await dataEngine.update(model, { ...update, id: record.id }); + await dataEngine.update(objectName, { ...patch, id: record.id }); } return records.length; }, @@ -249,22 +286,26 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) { delete: async ( { model, where }: { model: string; where: CleanedWhere[] }, ): Promise => { - const filter = convertWhere(where); + const objectName = resolveProtocolName(model); + const bridged = objectName !== model; + const filter = convertWhere(bridged ? remapWhere(where) : where); - const record = await dataEngine.findOne(model, { where: filter }); + const record = await dataEngine.findOne(objectName, { where: filter }); if (!record) return; - await dataEngine.delete(model, { where: { id: record.id } }); + await dataEngine.delete(objectName, { where: { id: record.id } }); }, deleteMany: async ( { model, where }: { model: string; where: CleanedWhere[] }, ): Promise => { - const filter = convertWhere(where); + const objectName = resolveProtocolName(model); + const bridged = objectName !== model; + const filter = convertWhere(bridged ? remapWhere(where) : where); - const records = await dataEngine.find(model, { where: filter }); + const records = await dataEngine.find(objectName, { where: filter }); for (const record of records) { - await dataEngine.delete(model, { where: { id: record.id } }); + await dataEngine.delete(objectName, { where: { id: record.id } }); } return records.length; }, From e78dc961dcaab6b8af3a861ba926c9e5523c9dc8 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 17:14:40 +0800 Subject: [PATCH 4/5] fix(auth): register SysSsoProvider so the sys_sso_provider table provisions Without adding SysSsoProvider to authIdentityObjects (the manifest the Auth plugin provisions), the table is never created and the sso plugin's writes fail at runtime. Caught by the live E2E: with this, sys_sso_provider provisions with the correct snake columns. Co-Authored-By: Claude Opus 4.8 --- packages/plugins/plugin-auth/src/manifest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugins/plugin-auth/src/manifest.ts b/packages/plugins/plugin-auth/src/manifest.ts index b7fbe3a0c3..11814ed456 100644 --- a/packages/plugins/plugin-auth/src/manifest.ts +++ b/packages/plugins/plugin-auth/src/manifest.ts @@ -21,6 +21,7 @@ import { SysOauthRefreshToken, SysOrganization, SysSession, + SysSsoProvider, SysTeam, SysTeamMember, SysTwoFactor, @@ -52,6 +53,7 @@ export const authIdentityObjects: any[] = [ SysOauthConsent, SysJwks, SysDeviceCode, + SysSsoProvider, ]; /** Manifest header shared by compile-time config and runtime registration. */ From 2ef3c62e855efb3c03bccd2898f9058770544deb Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 18:57:34 +0800 Subject: [PATCH 5/5] chore: bump .objectui-sha to d6e7a84f7ccb32acad3fa435385ac15448df485e (SSO login button) Pulls in objectui#2000 (the "Sign in with SSO" login-page entry) so the framework's bundled @objectstack/console ships the SSO UI alongside this PR's @better-auth/sso backend. Co-Authored-By: Claude Opus 4.8 --- .objectui-sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.objectui-sha b/.objectui-sha index 7dddf6cbb2..2250732794 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -a3a5abff8c75d0629c3abe520dbe448edace3155 +d6e7a84f7ccb32acad3fa435385ac15448df485e