@objectstack/plugin-auth 4.0.5 → 4.1.1

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.
package/dist/index.js CHANGED
@@ -31,6 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AUTH_ACCOUNT_CONFIG: () => AUTH_ACCOUNT_CONFIG,
34
+ AUTH_ADMIN_SESSION_FIELDS: () => AUTH_ADMIN_SESSION_FIELDS,
35
+ AUTH_ADMIN_USER_FIELDS: () => AUTH_ADMIN_USER_FIELDS,
34
36
  AUTH_DEVICE_CODE_SCHEMA: () => AUTH_DEVICE_CODE_SCHEMA,
35
37
  AUTH_INVITATION_SCHEMA: () => AUTH_INVITATION_SCHEMA,
36
38
  AUTH_JWKS_SCHEMA: () => AUTH_JWKS_SCHEMA,
@@ -52,6 +54,7 @@ __export(index_exports, {
52
54
  AUTH_VERIFICATION_CONFIG: () => AUTH_VERIFICATION_CONFIG,
53
55
  AuthManager: () => AuthManager,
54
56
  AuthPlugin: () => AuthPlugin,
57
+ buildAdminPluginSchema: () => buildAdminPluginSchema,
55
58
  buildDeviceAuthorizationPluginSchema: () => buildDeviceAuthorizationPluginSchema,
56
59
  buildJwtPluginSchema: () => buildJwtPluginSchema,
57
60
  buildOauthProviderPluginSchema: () => buildOauthProviderPluginSchema,
@@ -79,6 +82,41 @@ var AUTH_MODEL_TO_PROTOCOL = {
79
82
  function resolveProtocolName(model) {
80
83
  return AUTH_MODEL_TO_PROTOCOL[model] ?? model;
81
84
  }
85
+ var LEGACY_DATETIME_FIELDS_BY_MODEL = {
86
+ user: ["created_at", "updated_at"],
87
+ session: ["expires_at", "created_at", "updated_at"],
88
+ account: [
89
+ "access_token_expires_at",
90
+ "refresh_token_expires_at",
91
+ "created_at",
92
+ "updated_at"
93
+ ],
94
+ verification: ["expires_at", "created_at", "updated_at"]
95
+ };
96
+ var NUMERIC_STRING_RE = /^-?\d+(\.\d+)?$/;
97
+ function normaliseLegacyDate(value) {
98
+ if (typeof value !== "string") return value;
99
+ if (!NUMERIC_STRING_RE.test(value)) return value;
100
+ const n = parseFloat(value);
101
+ if (!Number.isFinite(n)) return value;
102
+ if (Math.abs(n) < 1e10) return value;
103
+ const d = new Date(n);
104
+ if (Number.isNaN(d.getTime())) return value;
105
+ return d.toISOString();
106
+ }
107
+ function normaliseLegacyDates(model, record) {
108
+ if (!record) return record;
109
+ const cols = LEGACY_DATETIME_FIELDS_BY_MODEL[model];
110
+ if (!cols) return record;
111
+ for (const col of cols) {
112
+ if (col in record) {
113
+ record[col] = normaliseLegacyDate(
114
+ record[col]
115
+ );
116
+ }
117
+ }
118
+ return record;
119
+ }
82
120
  function convertWhere(where) {
83
121
  const filter = {};
84
122
  for (const condition of where) {
@@ -107,20 +145,24 @@ function createObjectQLAdapterFactory(dataEngine) {
107
145
  return (0, import_adapters.createAdapterFactory)({
108
146
  config: {
109
147
  adapterId: "objectql",
110
- // ObjectQL natively supports these types no extra conversion needed
111
- supportsBooleans: true,
112
- supportsDates: true,
148
+ // We let better-auth handle Date↔string and boolean↔0/1 conversion so
149
+ // that values land in the underlying SQL driver as primitive strings
150
+ // and integers. Some drivers (e.g. libsql over the HTTP transport)
151
+ // otherwise mangle `Date` objects into `"<epoch>.0"` strings that
152
+ // break the client-side session parser.
153
+ supportsBooleans: false,
154
+ supportsDates: false,
113
155
  supportsJSON: true
114
156
  },
115
157
  adapter: () => ({
116
158
  create: async ({ model, data, select: _select }) => {
117
159
  const result = await dataEngine.insert(model, data);
118
- return result;
160
+ return normaliseLegacyDates(model, result);
119
161
  },
120
162
  findOne: async ({ model, where, select, join: _join }) => {
121
163
  const filter = convertWhere(where);
122
164
  const result = await dataEngine.findOne(model, { where: filter, fields: select });
123
- return result ? result : null;
165
+ return result ? normaliseLegacyDates(model, result) : null;
124
166
  },
125
167
  findMany: async ({ model, where, limit, offset, sortBy, join: _join }) => {
126
168
  const filter = where ? convertWhere(where) : {};
@@ -131,7 +173,7 @@ function createObjectQLAdapterFactory(dataEngine) {
131
173
  offset,
132
174
  orderBy
133
175
  });
134
- return results;
176
+ return results.map((r) => normaliseLegacyDates(model, r));
135
177
  },
136
178
  count: async ({ model, where }) => {
137
179
  const filter = where ? convertWhere(where) : {};
@@ -142,7 +184,7 @@ function createObjectQLAdapterFactory(dataEngine) {
142
184
  const record = await dataEngine.findOne(model, { where: filter });
143
185
  if (!record) return null;
144
186
  const result = await dataEngine.update(model, { ...update, id: record.id });
145
- return result ? result : null;
187
+ return result ? normaliseLegacyDates(model, result) : null;
146
188
  },
147
189
  updateMany: async ({ model, where, update }) => {
148
190
  const filter = convertWhere(where);
@@ -339,6 +381,13 @@ var AUTH_TWO_FACTOR_SCHEMA = {
339
381
  var AUTH_TWO_FACTOR_USER_FIELDS = {
340
382
  twoFactorEnabled: "two_factor_enabled"
341
383
  };
384
+ var AUTH_ADMIN_USER_FIELDS = {
385
+ banReason: "ban_reason",
386
+ banExpires: "ban_expires"
387
+ };
388
+ var AUTH_ADMIN_SESSION_FIELDS = {
389
+ impersonatedBy: "impersonated_by"
390
+ };
342
391
  var AUTH_OAUTH_CLIENT_SCHEMA = {
343
392
  modelName: import_system2.SystemObjectName.OAUTH_APPLICATION,
344
393
  // 'sys_oauth_application'
@@ -422,6 +471,16 @@ function buildTwoFactorPluginSchema() {
422
471
  }
423
472
  };
424
473
  }
474
+ function buildAdminPluginSchema() {
475
+ return {
476
+ user: {
477
+ fields: AUTH_ADMIN_USER_FIELDS
478
+ },
479
+ session: {
480
+ fields: AUTH_ADMIN_SESSION_FIELDS
481
+ }
482
+ };
483
+ }
425
484
  function buildOrganizationPluginSchema() {
426
485
  return {
427
486
  organization: AUTH_ORGANIZATION_SCHEMA,
@@ -503,7 +562,41 @@ var AuthManager = class {
503
562
  ...AUTH_USER_CONFIG
504
563
  },
505
564
  account: {
506
- ...AUTH_ACCOUNT_CONFIG
565
+ ...AUTH_ACCOUNT_CONFIG,
566
+ // Allow OIDC/OAuth callbacks to implicitly link the incoming
567
+ // identity to a pre-existing local user when the emails match.
568
+ //
569
+ // ObjectStack's platform SSO ("objectstack-cloud" provider) is the
570
+ // canonical case: cloud is the IdP for every project, so a user
571
+ // arriving via SSO is — by construction — the same person who was
572
+ // auto-seeded as the project owner when the project was created.
573
+ // Without trusting the provider, better-auth's safety check rejects
574
+ // the link with `error=account_not_linked` because the seeded user
575
+ // row has `emailVerified=false` (no actual verification ever runs
576
+ // in the IdP-mediated flow). See packages/plugins/plugin-auth/
577
+ // node_modules/better-auth/dist/oauth2/link-account.mjs:22.
578
+ //
579
+ // Custom-deployment consumers can extend the trusted set via
580
+ // `config.account.accountLinking.trustedProviders`; we always
581
+ // include `objectstack-cloud` because it is the platform IdP.
582
+ accountLinking: {
583
+ enabled: true,
584
+ // better-auth's account-linking gate has TWO independent clauses
585
+ // (see link-account.mjs:22). Trusting the provider only satisfies
586
+ // the first clause; the second — `requireLocalEmailVerified &&
587
+ // !dbUser.user.emailVerified` — still blocks linking when the
588
+ // pre-existing local user row has `emailVerified=false` (the
589
+ // default for owner-seeded rows). Disabling the local-email gate
590
+ // is safe here because the OAuth side is what we actually trust:
591
+ // the incoming identity was verified by the IdP. Consumers who
592
+ // need the stricter behavior can override via config.
593
+ requireLocalEmailVerified: false,
594
+ ...this.config?.account?.accountLinking ?? {},
595
+ trustedProviders: Array.from(/* @__PURE__ */ new Set([
596
+ "objectstack-cloud",
597
+ ...this.config?.account?.accountLinking?.trustedProviders ?? []
598
+ ]))
599
+ }
507
600
  },
508
601
  verification: {
509
602
  ...AUTH_VERIFICATION_CONFIG
@@ -519,15 +612,71 @@ var AuthManager = class {
519
612
  ...this.config.emailAndPassword?.maxPasswordLength != null ? { maxPasswordLength: this.config.emailAndPassword.maxPasswordLength } : {},
520
613
  ...this.config.emailAndPassword?.resetPasswordTokenExpiresIn != null ? { resetPasswordTokenExpiresIn: this.config.emailAndPassword.resetPasswordTokenExpiresIn } : {},
521
614
  ...this.config.emailAndPassword?.autoSignIn != null ? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {},
522
- ...this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}
615
+ ...this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {},
616
+ sendResetPassword: async ({ user, url, token }) => {
617
+ const email = this.getEmailService();
618
+ if (!email) {
619
+ console.warn(
620
+ `[AuthManager] Password-reset requested for ${user.email} but no email service is wired. URL: ${url}`
621
+ );
622
+ return;
623
+ }
624
+ const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
625
+ try {
626
+ await email.sendTemplate({
627
+ template: "auth.password_reset",
628
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
629
+ data: {
630
+ user: { name: user.name || user.email, email: user.email, id: user.id },
631
+ resetUrl: url,
632
+ token,
633
+ expiresInMinutes: Math.round(ttlSec / 60),
634
+ appName: this.getAppName()
635
+ },
636
+ relatedObject: "sys_user",
637
+ relatedId: user.id
638
+ });
639
+ } catch (err) {
640
+ console.error(`[AuthManager] sendResetPassword failed: ${err?.message ?? err}`);
641
+ throw err;
642
+ }
643
+ }
523
644
  },
524
645
  // Email verification
525
- ...this.config.emailVerification ? {
646
+ ...this.config.emailVerification || this.config.emailService ? {
526
647
  emailVerification: {
527
- ...this.config.emailVerification.sendOnSignUp != null ? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {},
528
- ...this.config.emailVerification.sendOnSignIn != null ? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {},
529
- ...this.config.emailVerification.autoSignInAfterVerification != null ? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {},
530
- ...this.config.emailVerification.expiresIn != null ? { expiresIn: this.config.emailVerification.expiresIn } : {}
648
+ ...this.config.emailVerification?.sendOnSignUp != null ? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {},
649
+ ...this.config.emailVerification?.sendOnSignIn != null ? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {},
650
+ ...this.config.emailVerification?.autoSignInAfterVerification != null ? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {},
651
+ ...this.config.emailVerification?.expiresIn != null ? { expiresIn: this.config.emailVerification.expiresIn } : {},
652
+ sendVerificationEmail: async ({ user, url, token }) => {
653
+ const email = this.getEmailService();
654
+ if (!email) {
655
+ console.warn(
656
+ `[AuthManager] Verification email requested for ${user.email} but no email service is wired. URL: ${url}`
657
+ );
658
+ return;
659
+ }
660
+ const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
661
+ try {
662
+ await email.sendTemplate({
663
+ template: "auth.verify_email",
664
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
665
+ data: {
666
+ user: { name: user.name || user.email, email: user.email, id: user.id },
667
+ verificationUrl: url,
668
+ token,
669
+ expiresInMinutes: Math.round(ttlSec / 60),
670
+ appName: this.getAppName()
671
+ },
672
+ relatedObject: "sys_user",
673
+ relatedId: user.id
674
+ });
675
+ } catch (err) {
676
+ console.error(`[AuthManager] sendVerificationEmail failed: ${err?.message ?? err}`);
677
+ throw err;
678
+ }
679
+ }
531
680
  }
532
681
  } : {},
533
682
  // Session configuration
@@ -552,6 +701,8 @@ var AuthManager = class {
552
701
  }
553
702
  if (!origins.length && (!corsOrigin || corsOrigin === "*")) {
554
703
  origins.push("http://localhost:*");
704
+ origins.push("http://*.localhost:*");
705
+ origins.push("https://*.localhost:*");
555
706
  }
556
707
  return origins.length ? { trustedOrigins: origins } : {};
557
708
  })(),
@@ -583,12 +734,34 @@ var AuthManager = class {
583
734
  passkeys: pluginConfig.passkeys ?? false,
584
735
  magicLink: pluginConfig.magicLink ?? false,
585
736
  oidcProvider: pluginConfig.oidcProvider ?? false,
586
- deviceAuthorization: pluginConfig.deviceAuthorization ?? false
737
+ deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
738
+ admin: pluginConfig.admin ?? false
587
739
  };
588
740
  const { bearer } = await import("better-auth/plugins/bearer");
589
741
  plugins.push(bearer());
590
742
  if (enabled.organization) {
591
743
  const { organization } = await import("better-auth/plugins/organization");
744
+ let customOrgRoles;
745
+ const extra = this.config.additionalOrgRoles;
746
+ if (extra && extra.length > 0) {
747
+ try {
748
+ const accessMod = await import("better-auth/plugins/organization/access");
749
+ const { defaultAc, memberAc, defaultRoles: importedDefaultRoles } = accessMod;
750
+ const defaultRoles = importedDefaultRoles || null;
751
+ if (defaultAc && memberAc && typeof memberAc.statements === "object") {
752
+ const built = defaultRoles ? { ...defaultRoles } : {};
753
+ const stmts = memberAc.statements;
754
+ for (const name of extra) {
755
+ if (!name) continue;
756
+ if (built[name]) continue;
757
+ built[name] = defaultAc.newRole(stmts);
758
+ }
759
+ customOrgRoles = built;
760
+ }
761
+ } catch {
762
+ customOrgRoles = void 0;
763
+ }
764
+ }
592
765
  plugins.push(organization({
593
766
  schema: buildOrganizationPluginSchema(),
594
767
  // Enable the team sub-feature so the framework's `sys_team` /
@@ -599,15 +772,95 @@ var AuthManager = class {
599
772
  // portal exposes a Teams page; without this flag those endpoints
600
773
  // 404 and the section silently breaks.
601
774
  teams: { enabled: true },
775
+ // Without a mailer wired in framework, requiring email verification
776
+ // before accepting invitations dead-ends every invite flow with
777
+ // FORBIDDEN EMAIL_VERIFICATION_REQUIRED…. Default-off here keeps
778
+ // the built-in /accept-invitation route usable for pilots; operators
779
+ // who wire a real mailer can re-enable downstream.
780
+ requireEmailVerificationOnInvitation: false,
781
+ ...customOrgRoles ? { roles: customOrgRoles } : {},
782
+ // ── Slug-change guard ─────────────────────────────────────
783
+ // An org's slug is baked into every env hostname at creation
784
+ // time (see service-tenant `project-provisioning.ts`). Renaming
785
+ // it while live envs exist would silently desync the URL from
786
+ // the org identity. Block the change here; the cloud Console
787
+ // surfaces this as an actionable error and points users to
788
+ // `change_hostname` or archiving the env. Org `name` (display
789
+ // label) is unaffected — only `slug` is guarded.
790
+ //
791
+ // We resolve the data engine lazily so non-cloud apps (which
792
+ // never seed `sys_environment`) keep working: any lookup error
793
+ // is treated as "no envs to protect".
794
+ organizationHooks: {
795
+ beforeUpdateOrganization: async ({ organization: organization2, member }) => {
796
+ const newSlug = organization2?.slug;
797
+ const orgId = member?.organizationId;
798
+ if (!newSlug || !orgId) return;
799
+ const dataEngine2 = this.config.dataEngine;
800
+ if (!dataEngine2) return;
801
+ let currentSlug;
802
+ try {
803
+ const current = await dataEngine2.findOne("sys_organization", {
804
+ where: { id: orgId }
805
+ });
806
+ currentSlug = current?.slug;
807
+ } catch {
808
+ return;
809
+ }
810
+ if (!currentSlug || currentSlug === newSlug) return;
811
+ let activeEnvs = 0;
812
+ try {
813
+ const envs = await dataEngine2.find("sys_environment", {
814
+ where: { organization_id: orgId }
815
+ });
816
+ activeEnvs = (envs ?? []).filter(
817
+ (e) => e?.status !== "archived" && e?.status !== "failed"
818
+ ).length;
819
+ } catch {
820
+ return;
821
+ }
822
+ if (activeEnvs > 0) {
823
+ const { APIError } = await import("better-auth/api");
824
+ throw new APIError("FORBIDDEN", {
825
+ message: `Cannot change organization slug while ${activeEnvs} active environment(s) still reference it. Archive those environments or rename their hostnames first.`
826
+ });
827
+ }
828
+ }
829
+ },
602
830
  // No mailer is wired in framework yet — log the accept URL so
603
831
  // operators / UI can fall back to copy-paste flows. Replace this
604
832
  // with a real mail integration when available.
605
- sendInvitationEmail: async ({ email, invitation, organization: org, inviter }) => {
833
+ sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => {
606
834
  const baseUrl = (this.config.baseUrl ?? "").replace(/\/$/, "");
607
835
  const acceptUrl = `${baseUrl}/accept-invitation/${invitation.id}`;
608
- console.warn(
609
- `[AuthManager] Invitation email not configured. To: ${email} (org: ${org?.name ?? invitation.organizationId}, role: ${invitation.role}, inviter: ${inviter?.user?.email ?? "unknown"}) URL: ${acceptUrl}`
610
- );
836
+ const emailService = this.getEmailService();
837
+ if (!emailService) {
838
+ console.warn(
839
+ `[AuthManager] Invitation email not configured. To: ${recipientEmail} (org: ${org?.name ?? invitation.organizationId}, role: ${invitation.role}, inviter: ${inviter?.user?.email ?? "unknown"}) URL: ${acceptUrl}`
840
+ );
841
+ return;
842
+ }
843
+ try {
844
+ await emailService.sendTemplate({
845
+ template: "auth.invitation",
846
+ to: recipientEmail,
847
+ data: {
848
+ inviter: {
849
+ name: inviter?.user?.name ?? inviter?.user?.email ?? "A teammate",
850
+ email: inviter?.user?.email ?? ""
851
+ },
852
+ organization: { name: org?.name ?? invitation.organizationId },
853
+ role: invitation.role || "",
854
+ acceptUrl,
855
+ appName: this.getAppName()
856
+ },
857
+ relatedObject: "sys_invitation",
858
+ relatedId: invitation.id
859
+ });
860
+ } catch (err) {
861
+ console.error(`[AuthManager] sendInvitationEmail failed: ${err?.message ?? err}`);
862
+ throw err;
863
+ }
611
864
  }
612
865
  }));
613
866
  }
@@ -617,13 +870,38 @@ var AuthManager = class {
617
870
  schema: buildTwoFactorPluginSchema()
618
871
  }));
619
872
  }
873
+ if (enabled.admin) {
874
+ const { admin } = await import("better-auth/plugins/admin");
875
+ plugins.push(admin({
876
+ schema: buildAdminPluginSchema()
877
+ }));
878
+ }
620
879
  if (enabled.magicLink) {
621
880
  const { magicLink } = await import("better-auth/plugins/magic-link");
622
881
  plugins.push(magicLink({
623
- sendMagicLink: async ({ email, url }) => {
624
- console.warn(
625
- `[AuthManager] Magic-link requested for ${email} but no sendMagicLink handler configured. URL: ${url}`
626
- );
882
+ sendMagicLink: async ({ email: recipientEmail, url, token }) => {
883
+ const emailService = this.getEmailService();
884
+ if (!emailService) {
885
+ console.warn(
886
+ `[AuthManager] Magic-link requested for ${recipientEmail} but no email service is wired. URL: ${url}`
887
+ );
888
+ return;
889
+ }
890
+ try {
891
+ await emailService.sendTemplate({
892
+ template: "auth.magic_link",
893
+ to: recipientEmail,
894
+ data: {
895
+ magicLinkUrl: url,
896
+ token,
897
+ expiresInMinutes: 10,
898
+ appName: this.getAppName()
899
+ }
900
+ });
901
+ } catch (err) {
902
+ console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`);
903
+ throw err;
904
+ }
627
905
  }
628
906
  }));
629
907
  }
@@ -664,6 +942,55 @@ var AuthManager = class {
664
942
  schema: buildDeviceAuthorizationPluginSchema()
665
943
  }));
666
944
  }
945
+ const dataEngine = this.config.dataEngine;
946
+ if (dataEngine) {
947
+ const { customSession } = await import("better-auth/plugins/custom-session");
948
+ plugins.push(customSession(async ({ user, session }) => {
949
+ if (!user?.id) return { user, session };
950
+ const isPlatformAdmin = async () => {
951
+ try {
952
+ const links = await dataEngine.find("sys_user_permission_set", {
953
+ where: { user_id: user.id },
954
+ limit: 50
955
+ });
956
+ const platformLinks = (Array.isArray(links) ? links : []).filter(
957
+ (l) => !l.organization_id
958
+ );
959
+ if (platformLinks.length === 0) return false;
960
+ const sets = await dataEngine.find("sys_permission_set", { limit: 50 });
961
+ const adminSet = (Array.isArray(sets) ? sets : []).find(
962
+ (r) => r.name === "admin_full_access"
963
+ );
964
+ if (!adminSet) return false;
965
+ return platformLinks.some(
966
+ (l) => l.permission_set_id === adminSet.id
967
+ );
968
+ } catch {
969
+ return false;
970
+ }
971
+ };
972
+ const isActiveOrgAdmin = async () => {
973
+ try {
974
+ const orgId = session?.activeOrganizationId;
975
+ if (!orgId) return false;
976
+ const members = await dataEngine.find("sys_member", {
977
+ where: { user_id: user.id, organization_id: orgId },
978
+ limit: 5
979
+ });
980
+ return (Array.isArray(members) ? members : []).some((m) => {
981
+ const raw = typeof m?.role === "string" ? m.role : "";
982
+ const roles = raw.split(",").map((s) => s.trim().toLowerCase());
983
+ return roles.includes("owner") || roles.includes("admin");
984
+ });
985
+ } catch {
986
+ return false;
987
+ }
988
+ };
989
+ const promote = await isPlatformAdmin() || await isActiveOrgAdmin();
990
+ if (!promote) return { user, session };
991
+ return { user: { ...user, role: "admin" }, session };
992
+ }));
993
+ }
667
994
  return plugins;
668
995
  }
669
996
  /**
@@ -720,6 +1047,26 @@ var AuthManager = class {
720
1047
  }
721
1048
  this.config = { ...this.config, baseUrl: url };
722
1049
  }
1050
+ /**
1051
+ * Inject (or replace) the outbound email service used by better-auth
1052
+ * callbacks. Safe to call after construction but BEFORE the first
1053
+ * request hits the auth handler — callbacks read this via
1054
+ * {@link getEmailService} when invoked.
1055
+ *
1056
+ * AuthPlugin calls this on `kernel:ready` once `ctx.getService('email')`
1057
+ * resolves. For tests / serverless, callers may invoke directly.
1058
+ */
1059
+ setEmailService(email) {
1060
+ this.config.emailService = email;
1061
+ }
1062
+ /** @internal Used by callback closures. */
1063
+ getEmailService() {
1064
+ return this.config.emailService;
1065
+ }
1066
+ /** @internal `{{appName}}` placeholder value for built-in templates. */
1067
+ getAppName() {
1068
+ return this.config.appName ?? "ObjectStack";
1069
+ }
723
1070
  /**
724
1071
  * Get the underlying better-auth instance
725
1072
  * Useful for advanced use cases
@@ -900,24 +1247,22 @@ var AuthPlugin = class {
900
1247
  ctx.registerService("auth", this.authManager);
901
1248
  ctx.getService("manifest").register({
902
1249
  ...authPluginManifestHeader,
1250
+ ...this.options.manifestDatasource ? { defaultDatasource: this.options.manifestDatasource } : {},
903
1251
  objects: authIdentityObjects,
904
1252
  // The platform Setup App is a static metadata artifact (lives in
905
1253
  // @objectstack/platform-objects/apps). plugin-auth is the natural
906
1254
  // owner of its registration since it loads first among the trio
907
1255
  // (auth + security + audit) that supplies the underlying objects.
908
1256
  apps: [import_apps.SETUP_APP],
909
- // Curated list views and dashboards consumed by the Setup App's
910
- // navigation entries. The manifest service does NOT auto-discover
911
- // these from the app definition — they must be registered as
912
- // explicit top-level arrays per ObjectStackDefinitionSchema.
913
- views: [
914
- import_apps.UsersView,
915
- import_apps.OrganizationsView,
916
- import_apps.RolesView,
917
- import_apps.SessionsView,
918
- import_apps.AuditLogsView,
919
- import_apps.PackageInstallationsView
920
- ],
1257
+ // List views for each Setup-nav object are defined on the schema
1258
+ // itself via the canonical `listViews` map (e.g.
1259
+ // sys_user.listViews.{all_users,unverified,two_factor}). Registering
1260
+ // top-level views here is the legacy pre-M10.30c pattern — it caused
1261
+ // duplicate "Users"/"Roles"/"Sessions" tabs to appear alongside the
1262
+ // schema-derived ones, sometimes referencing nonexistent fields
1263
+ // (e.g. legacy `users.view` had phone/status/active columns that do
1264
+ // not exist on sys_user). Schema-embedded listViews is the single
1265
+ // source of truth.
921
1266
  dashboards: [import_apps.SystemOverviewDashboard, import_apps.SecurityOverviewDashboard]
922
1267
  });
923
1268
  ctx.logger.info("Auth Plugin initialized successfully");
@@ -927,8 +1272,43 @@ var AuthPlugin = class {
927
1272
  if (!this.authManager) {
928
1273
  throw new Error("Auth manager not initialized");
929
1274
  }
1275
+ ctx.hook("kernel:ready", async () => {
1276
+ try {
1277
+ const i18n = ctx.getService("i18n");
1278
+ let loaded = 0;
1279
+ for (const [locale, data] of Object.entries(import_apps.SetupAppTranslations)) {
1280
+ if (data && typeof data === "object") {
1281
+ try {
1282
+ i18n.loadTranslations(locale, data);
1283
+ loaded++;
1284
+ } catch (err) {
1285
+ ctx.logger.warn(
1286
+ `Auth: failed to load Setup App translations for '${locale}': ${err?.message ?? err}`
1287
+ );
1288
+ }
1289
+ }
1290
+ }
1291
+ if (loaded > 0) {
1292
+ ctx.logger.info(
1293
+ `Auth: contributed Setup App translations (${loaded} locale${loaded > 1 ? "s" : ""})`
1294
+ );
1295
+ }
1296
+ } catch {
1297
+ }
1298
+ });
930
1299
  if (this.options.registerRoutes) {
931
1300
  ctx.hook("kernel:ready", async () => {
1301
+ if (this.authManager) {
1302
+ try {
1303
+ const emailSvc = ctx.getService("email");
1304
+ if (emailSvc) {
1305
+ this.authManager.setEmailService(emailSvc);
1306
+ ctx.logger.info("Auth: email service wired (transactional mail enabled)");
1307
+ }
1308
+ } catch {
1309
+ ctx.logger.info("Auth: no email service registered \u2014 auth callbacks will log instead of sending");
1310
+ }
1311
+ }
932
1312
  let httpServer = null;
933
1313
  try {
934
1314
  httpServer = ctx.getService("http-server");
@@ -1034,6 +1414,19 @@ var AuthPlugin = class {
1034
1414
  ctx.logger.error("[AuthPlugin] better-auth returned server error", new Error(`HTTP ${response.status}: (unable to read body)`));
1035
1415
  }
1036
1416
  }
1417
+ try {
1418
+ const url = c.req.url;
1419
+ if (response.ok && /\/jwks(\?|$)/.test(url)) {
1420
+ const existing = response.headers.get("cache-control");
1421
+ if (!existing) {
1422
+ response.headers.set(
1423
+ "cache-control",
1424
+ "public, max-age=300, stale-while-revalidate=86400"
1425
+ );
1426
+ }
1427
+ }
1428
+ } catch {
1429
+ }
1037
1430
  return response;
1038
1431
  } catch (error) {
1039
1432
  const err = error instanceof Error ? error : new Error(String(error));
@@ -1068,8 +1461,19 @@ var AuthPlugin = class {
1068
1461
  const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = await import("@better-auth/oauth-provider");
1069
1462
  const authServerHandler = oauthProviderAuthServerMetadata(auth);
1070
1463
  const openidConfigHandler = oauthProviderOpenIdConfigMetadata(auth);
1071
- rawApp.get("/.well-known/oauth-authorization-server", (c) => authServerHandler(c.req.raw));
1072
- rawApp.get("/.well-known/openid-configuration", (c) => openidConfigHandler(c.req.raw));
1464
+ const DISCOVERY_CACHE = "public, max-age=300, stale-while-revalidate=86400";
1465
+ const withDiscoveryCache = async (handler, req) => {
1466
+ const resp = await handler(req);
1467
+ try {
1468
+ if (resp.ok && !resp.headers.get("cache-control")) {
1469
+ resp.headers.set("cache-control", DISCOVERY_CACHE);
1470
+ }
1471
+ } catch {
1472
+ }
1473
+ return resp;
1474
+ };
1475
+ rawApp.get("/.well-known/oauth-authorization-server", (c) => withDiscoveryCache(authServerHandler, c.req.raw));
1476
+ rawApp.get("/.well-known/openid-configuration", (c) => withDiscoveryCache(openidConfigHandler, c.req.raw));
1073
1477
  ctx.logger.info(
1074
1478
  "OIDC discovery endpoints mounted at /.well-known/{oauth-authorization-server,openid-configuration}"
1075
1479
  );
@@ -1078,6 +1482,8 @@ var AuthPlugin = class {
1078
1482
  // Annotate the CommonJS export names for ESM import in node:
1079
1483
  0 && (module.exports = {
1080
1484
  AUTH_ACCOUNT_CONFIG,
1485
+ AUTH_ADMIN_SESSION_FIELDS,
1486
+ AUTH_ADMIN_USER_FIELDS,
1081
1487
  AUTH_DEVICE_CODE_SCHEMA,
1082
1488
  AUTH_INVITATION_SCHEMA,
1083
1489
  AUTH_JWKS_SCHEMA,
@@ -1099,6 +1505,7 @@ var AuthPlugin = class {
1099
1505
  AUTH_VERIFICATION_CONFIG,
1100
1506
  AuthManager,
1101
1507
  AuthPlugin,
1508
+ buildAdminPluginSchema,
1102
1509
  buildDeviceAuthorizationPluginSchema,
1103
1510
  buildJwtPluginSchema,
1104
1511
  buildOauthProviderPluginSchema,