@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.mjs CHANGED
@@ -3,12 +3,7 @@ import {
3
3
  SETUP_APP,
4
4
  SystemOverviewDashboard,
5
5
  SecurityOverviewDashboard,
6
- UsersView,
7
- OrganizationsView,
8
- RolesView,
9
- SessionsView,
10
- AuditLogsView,
11
- PackageInstallationsView
6
+ SetupAppTranslations
12
7
  } from "@objectstack/platform-objects/apps";
13
8
 
14
9
  // src/objectql-adapter.ts
@@ -23,6 +18,41 @@ var AUTH_MODEL_TO_PROTOCOL = {
23
18
  function resolveProtocolName(model) {
24
19
  return AUTH_MODEL_TO_PROTOCOL[model] ?? model;
25
20
  }
21
+ var LEGACY_DATETIME_FIELDS_BY_MODEL = {
22
+ user: ["created_at", "updated_at"],
23
+ session: ["expires_at", "created_at", "updated_at"],
24
+ account: [
25
+ "access_token_expires_at",
26
+ "refresh_token_expires_at",
27
+ "created_at",
28
+ "updated_at"
29
+ ],
30
+ verification: ["expires_at", "created_at", "updated_at"]
31
+ };
32
+ var NUMERIC_STRING_RE = /^-?\d+(\.\d+)?$/;
33
+ function normaliseLegacyDate(value) {
34
+ if (typeof value !== "string") return value;
35
+ if (!NUMERIC_STRING_RE.test(value)) return value;
36
+ const n = parseFloat(value);
37
+ if (!Number.isFinite(n)) return value;
38
+ if (Math.abs(n) < 1e10) return value;
39
+ const d = new Date(n);
40
+ if (Number.isNaN(d.getTime())) return value;
41
+ return d.toISOString();
42
+ }
43
+ function normaliseLegacyDates(model, record) {
44
+ if (!record) return record;
45
+ const cols = LEGACY_DATETIME_FIELDS_BY_MODEL[model];
46
+ if (!cols) return record;
47
+ for (const col of cols) {
48
+ if (col in record) {
49
+ record[col] = normaliseLegacyDate(
50
+ record[col]
51
+ );
52
+ }
53
+ }
54
+ return record;
55
+ }
26
56
  function convertWhere(where) {
27
57
  const filter = {};
28
58
  for (const condition of where) {
@@ -51,20 +81,24 @@ function createObjectQLAdapterFactory(dataEngine) {
51
81
  return createAdapterFactory({
52
82
  config: {
53
83
  adapterId: "objectql",
54
- // ObjectQL natively supports these types no extra conversion needed
55
- supportsBooleans: true,
56
- supportsDates: true,
84
+ // We let better-auth handle Date↔string and boolean↔0/1 conversion so
85
+ // that values land in the underlying SQL driver as primitive strings
86
+ // and integers. Some drivers (e.g. libsql over the HTTP transport)
87
+ // otherwise mangle `Date` objects into `"<epoch>.0"` strings that
88
+ // break the client-side session parser.
89
+ supportsBooleans: false,
90
+ supportsDates: false,
57
91
  supportsJSON: true
58
92
  },
59
93
  adapter: () => ({
60
94
  create: async ({ model, data, select: _select }) => {
61
95
  const result = await dataEngine.insert(model, data);
62
- return result;
96
+ return normaliseLegacyDates(model, result);
63
97
  },
64
98
  findOne: async ({ model, where, select, join: _join }) => {
65
99
  const filter = convertWhere(where);
66
100
  const result = await dataEngine.findOne(model, { where: filter, fields: select });
67
- return result ? result : null;
101
+ return result ? normaliseLegacyDates(model, result) : null;
68
102
  },
69
103
  findMany: async ({ model, where, limit, offset, sortBy, join: _join }) => {
70
104
  const filter = where ? convertWhere(where) : {};
@@ -75,7 +109,7 @@ function createObjectQLAdapterFactory(dataEngine) {
75
109
  offset,
76
110
  orderBy
77
111
  });
78
- return results;
112
+ return results.map((r) => normaliseLegacyDates(model, r));
79
113
  },
80
114
  count: async ({ model, where }) => {
81
115
  const filter = where ? convertWhere(where) : {};
@@ -86,7 +120,7 @@ function createObjectQLAdapterFactory(dataEngine) {
86
120
  const record = await dataEngine.findOne(model, { where: filter });
87
121
  if (!record) return null;
88
122
  const result = await dataEngine.update(model, { ...update, id: record.id });
89
- return result ? result : null;
123
+ return result ? normaliseLegacyDates(model, result) : null;
90
124
  },
91
125
  updateMany: async ({ model, where, update }) => {
92
126
  const filter = convertWhere(where);
@@ -283,6 +317,13 @@ var AUTH_TWO_FACTOR_SCHEMA = {
283
317
  var AUTH_TWO_FACTOR_USER_FIELDS = {
284
318
  twoFactorEnabled: "two_factor_enabled"
285
319
  };
320
+ var AUTH_ADMIN_USER_FIELDS = {
321
+ banReason: "ban_reason",
322
+ banExpires: "ban_expires"
323
+ };
324
+ var AUTH_ADMIN_SESSION_FIELDS = {
325
+ impersonatedBy: "impersonated_by"
326
+ };
286
327
  var AUTH_OAUTH_CLIENT_SCHEMA = {
287
328
  modelName: SystemObjectName2.OAUTH_APPLICATION,
288
329
  // 'sys_oauth_application'
@@ -366,6 +407,16 @@ function buildTwoFactorPluginSchema() {
366
407
  }
367
408
  };
368
409
  }
410
+ function buildAdminPluginSchema() {
411
+ return {
412
+ user: {
413
+ fields: AUTH_ADMIN_USER_FIELDS
414
+ },
415
+ session: {
416
+ fields: AUTH_ADMIN_SESSION_FIELDS
417
+ }
418
+ };
419
+ }
369
420
  function buildOrganizationPluginSchema() {
370
421
  return {
371
422
  organization: AUTH_ORGANIZATION_SCHEMA,
@@ -447,7 +498,41 @@ var AuthManager = class {
447
498
  ...AUTH_USER_CONFIG
448
499
  },
449
500
  account: {
450
- ...AUTH_ACCOUNT_CONFIG
501
+ ...AUTH_ACCOUNT_CONFIG,
502
+ // Allow OIDC/OAuth callbacks to implicitly link the incoming
503
+ // identity to a pre-existing local user when the emails match.
504
+ //
505
+ // ObjectStack's platform SSO ("objectstack-cloud" provider) is the
506
+ // canonical case: cloud is the IdP for every project, so a user
507
+ // arriving via SSO is — by construction — the same person who was
508
+ // auto-seeded as the project owner when the project was created.
509
+ // Without trusting the provider, better-auth's safety check rejects
510
+ // the link with `error=account_not_linked` because the seeded user
511
+ // row has `emailVerified=false` (no actual verification ever runs
512
+ // in the IdP-mediated flow). See packages/plugins/plugin-auth/
513
+ // node_modules/better-auth/dist/oauth2/link-account.mjs:22.
514
+ //
515
+ // Custom-deployment consumers can extend the trusted set via
516
+ // `config.account.accountLinking.trustedProviders`; we always
517
+ // include `objectstack-cloud` because it is the platform IdP.
518
+ accountLinking: {
519
+ enabled: true,
520
+ // better-auth's account-linking gate has TWO independent clauses
521
+ // (see link-account.mjs:22). Trusting the provider only satisfies
522
+ // the first clause; the second — `requireLocalEmailVerified &&
523
+ // !dbUser.user.emailVerified` — still blocks linking when the
524
+ // pre-existing local user row has `emailVerified=false` (the
525
+ // default for owner-seeded rows). Disabling the local-email gate
526
+ // is safe here because the OAuth side is what we actually trust:
527
+ // the incoming identity was verified by the IdP. Consumers who
528
+ // need the stricter behavior can override via config.
529
+ requireLocalEmailVerified: false,
530
+ ...this.config?.account?.accountLinking ?? {},
531
+ trustedProviders: Array.from(/* @__PURE__ */ new Set([
532
+ "objectstack-cloud",
533
+ ...this.config?.account?.accountLinking?.trustedProviders ?? []
534
+ ]))
535
+ }
451
536
  },
452
537
  verification: {
453
538
  ...AUTH_VERIFICATION_CONFIG
@@ -463,15 +548,71 @@ var AuthManager = class {
463
548
  ...this.config.emailAndPassword?.maxPasswordLength != null ? { maxPasswordLength: this.config.emailAndPassword.maxPasswordLength } : {},
464
549
  ...this.config.emailAndPassword?.resetPasswordTokenExpiresIn != null ? { resetPasswordTokenExpiresIn: this.config.emailAndPassword.resetPasswordTokenExpiresIn } : {},
465
550
  ...this.config.emailAndPassword?.autoSignIn != null ? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {},
466
- ...this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}
551
+ ...this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {},
552
+ sendResetPassword: async ({ user, url, token }) => {
553
+ const email = this.getEmailService();
554
+ if (!email) {
555
+ console.warn(
556
+ `[AuthManager] Password-reset requested for ${user.email} but no email service is wired. URL: ${url}`
557
+ );
558
+ return;
559
+ }
560
+ const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
561
+ try {
562
+ await email.sendTemplate({
563
+ template: "auth.password_reset",
564
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
565
+ data: {
566
+ user: { name: user.name || user.email, email: user.email, id: user.id },
567
+ resetUrl: url,
568
+ token,
569
+ expiresInMinutes: Math.round(ttlSec / 60),
570
+ appName: this.getAppName()
571
+ },
572
+ relatedObject: "sys_user",
573
+ relatedId: user.id
574
+ });
575
+ } catch (err) {
576
+ console.error(`[AuthManager] sendResetPassword failed: ${err?.message ?? err}`);
577
+ throw err;
578
+ }
579
+ }
467
580
  },
468
581
  // Email verification
469
- ...this.config.emailVerification ? {
582
+ ...this.config.emailVerification || this.config.emailService ? {
470
583
  emailVerification: {
471
- ...this.config.emailVerification.sendOnSignUp != null ? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {},
472
- ...this.config.emailVerification.sendOnSignIn != null ? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {},
473
- ...this.config.emailVerification.autoSignInAfterVerification != null ? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {},
474
- ...this.config.emailVerification.expiresIn != null ? { expiresIn: this.config.emailVerification.expiresIn } : {}
584
+ ...this.config.emailVerification?.sendOnSignUp != null ? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {},
585
+ ...this.config.emailVerification?.sendOnSignIn != null ? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {},
586
+ ...this.config.emailVerification?.autoSignInAfterVerification != null ? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {},
587
+ ...this.config.emailVerification?.expiresIn != null ? { expiresIn: this.config.emailVerification.expiresIn } : {},
588
+ sendVerificationEmail: async ({ user, url, token }) => {
589
+ const email = this.getEmailService();
590
+ if (!email) {
591
+ console.warn(
592
+ `[AuthManager] Verification email requested for ${user.email} but no email service is wired. URL: ${url}`
593
+ );
594
+ return;
595
+ }
596
+ const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
597
+ try {
598
+ await email.sendTemplate({
599
+ template: "auth.verify_email",
600
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
601
+ data: {
602
+ user: { name: user.name || user.email, email: user.email, id: user.id },
603
+ verificationUrl: url,
604
+ token,
605
+ expiresInMinutes: Math.round(ttlSec / 60),
606
+ appName: this.getAppName()
607
+ },
608
+ relatedObject: "sys_user",
609
+ relatedId: user.id
610
+ });
611
+ } catch (err) {
612
+ console.error(`[AuthManager] sendVerificationEmail failed: ${err?.message ?? err}`);
613
+ throw err;
614
+ }
615
+ }
475
616
  }
476
617
  } : {},
477
618
  // Session configuration
@@ -496,6 +637,8 @@ var AuthManager = class {
496
637
  }
497
638
  if (!origins.length && (!corsOrigin || corsOrigin === "*")) {
498
639
  origins.push("http://localhost:*");
640
+ origins.push("http://*.localhost:*");
641
+ origins.push("https://*.localhost:*");
499
642
  }
500
643
  return origins.length ? { trustedOrigins: origins } : {};
501
644
  })(),
@@ -527,12 +670,34 @@ var AuthManager = class {
527
670
  passkeys: pluginConfig.passkeys ?? false,
528
671
  magicLink: pluginConfig.magicLink ?? false,
529
672
  oidcProvider: pluginConfig.oidcProvider ?? false,
530
- deviceAuthorization: pluginConfig.deviceAuthorization ?? false
673
+ deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
674
+ admin: pluginConfig.admin ?? false
531
675
  };
532
676
  const { bearer } = await import("better-auth/plugins/bearer");
533
677
  plugins.push(bearer());
534
678
  if (enabled.organization) {
535
679
  const { organization } = await import("better-auth/plugins/organization");
680
+ let customOrgRoles;
681
+ const extra = this.config.additionalOrgRoles;
682
+ if (extra && extra.length > 0) {
683
+ try {
684
+ const accessMod = await import("better-auth/plugins/organization/access");
685
+ const { defaultAc, memberAc, defaultRoles: importedDefaultRoles } = accessMod;
686
+ const defaultRoles = importedDefaultRoles || null;
687
+ if (defaultAc && memberAc && typeof memberAc.statements === "object") {
688
+ const built = defaultRoles ? { ...defaultRoles } : {};
689
+ const stmts = memberAc.statements;
690
+ for (const name of extra) {
691
+ if (!name) continue;
692
+ if (built[name]) continue;
693
+ built[name] = defaultAc.newRole(stmts);
694
+ }
695
+ customOrgRoles = built;
696
+ }
697
+ } catch {
698
+ customOrgRoles = void 0;
699
+ }
700
+ }
536
701
  plugins.push(organization({
537
702
  schema: buildOrganizationPluginSchema(),
538
703
  // Enable the team sub-feature so the framework's `sys_team` /
@@ -543,15 +708,95 @@ var AuthManager = class {
543
708
  // portal exposes a Teams page; without this flag those endpoints
544
709
  // 404 and the section silently breaks.
545
710
  teams: { enabled: true },
711
+ // Without a mailer wired in framework, requiring email verification
712
+ // before accepting invitations dead-ends every invite flow with
713
+ // FORBIDDEN EMAIL_VERIFICATION_REQUIRED…. Default-off here keeps
714
+ // the built-in /accept-invitation route usable for pilots; operators
715
+ // who wire a real mailer can re-enable downstream.
716
+ requireEmailVerificationOnInvitation: false,
717
+ ...customOrgRoles ? { roles: customOrgRoles } : {},
718
+ // ── Slug-change guard ─────────────────────────────────────
719
+ // An org's slug is baked into every env hostname at creation
720
+ // time (see service-tenant `project-provisioning.ts`). Renaming
721
+ // it while live envs exist would silently desync the URL from
722
+ // the org identity. Block the change here; the cloud Console
723
+ // surfaces this as an actionable error and points users to
724
+ // `change_hostname` or archiving the env. Org `name` (display
725
+ // label) is unaffected — only `slug` is guarded.
726
+ //
727
+ // We resolve the data engine lazily so non-cloud apps (which
728
+ // never seed `sys_environment`) keep working: any lookup error
729
+ // is treated as "no envs to protect".
730
+ organizationHooks: {
731
+ beforeUpdateOrganization: async ({ organization: organization2, member }) => {
732
+ const newSlug = organization2?.slug;
733
+ const orgId = member?.organizationId;
734
+ if (!newSlug || !orgId) return;
735
+ const dataEngine2 = this.config.dataEngine;
736
+ if (!dataEngine2) return;
737
+ let currentSlug;
738
+ try {
739
+ const current = await dataEngine2.findOne("sys_organization", {
740
+ where: { id: orgId }
741
+ });
742
+ currentSlug = current?.slug;
743
+ } catch {
744
+ return;
745
+ }
746
+ if (!currentSlug || currentSlug === newSlug) return;
747
+ let activeEnvs = 0;
748
+ try {
749
+ const envs = await dataEngine2.find("sys_environment", {
750
+ where: { organization_id: orgId }
751
+ });
752
+ activeEnvs = (envs ?? []).filter(
753
+ (e) => e?.status !== "archived" && e?.status !== "failed"
754
+ ).length;
755
+ } catch {
756
+ return;
757
+ }
758
+ if (activeEnvs > 0) {
759
+ const { APIError } = await import("better-auth/api");
760
+ throw new APIError("FORBIDDEN", {
761
+ message: `Cannot change organization slug while ${activeEnvs} active environment(s) still reference it. Archive those environments or rename their hostnames first.`
762
+ });
763
+ }
764
+ }
765
+ },
546
766
  // No mailer is wired in framework yet — log the accept URL so
547
767
  // operators / UI can fall back to copy-paste flows. Replace this
548
768
  // with a real mail integration when available.
549
- sendInvitationEmail: async ({ email, invitation, organization: org, inviter }) => {
769
+ sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => {
550
770
  const baseUrl = (this.config.baseUrl ?? "").replace(/\/$/, "");
551
771
  const acceptUrl = `${baseUrl}/accept-invitation/${invitation.id}`;
552
- console.warn(
553
- `[AuthManager] Invitation email not configured. To: ${email} (org: ${org?.name ?? invitation.organizationId}, role: ${invitation.role}, inviter: ${inviter?.user?.email ?? "unknown"}) URL: ${acceptUrl}`
554
- );
772
+ const emailService = this.getEmailService();
773
+ if (!emailService) {
774
+ console.warn(
775
+ `[AuthManager] Invitation email not configured. To: ${recipientEmail} (org: ${org?.name ?? invitation.organizationId}, role: ${invitation.role}, inviter: ${inviter?.user?.email ?? "unknown"}) URL: ${acceptUrl}`
776
+ );
777
+ return;
778
+ }
779
+ try {
780
+ await emailService.sendTemplate({
781
+ template: "auth.invitation",
782
+ to: recipientEmail,
783
+ data: {
784
+ inviter: {
785
+ name: inviter?.user?.name ?? inviter?.user?.email ?? "A teammate",
786
+ email: inviter?.user?.email ?? ""
787
+ },
788
+ organization: { name: org?.name ?? invitation.organizationId },
789
+ role: invitation.role || "",
790
+ acceptUrl,
791
+ appName: this.getAppName()
792
+ },
793
+ relatedObject: "sys_invitation",
794
+ relatedId: invitation.id
795
+ });
796
+ } catch (err) {
797
+ console.error(`[AuthManager] sendInvitationEmail failed: ${err?.message ?? err}`);
798
+ throw err;
799
+ }
555
800
  }
556
801
  }));
557
802
  }
@@ -561,13 +806,38 @@ var AuthManager = class {
561
806
  schema: buildTwoFactorPluginSchema()
562
807
  }));
563
808
  }
809
+ if (enabled.admin) {
810
+ const { admin } = await import("better-auth/plugins/admin");
811
+ plugins.push(admin({
812
+ schema: buildAdminPluginSchema()
813
+ }));
814
+ }
564
815
  if (enabled.magicLink) {
565
816
  const { magicLink } = await import("better-auth/plugins/magic-link");
566
817
  plugins.push(magicLink({
567
- sendMagicLink: async ({ email, url }) => {
568
- console.warn(
569
- `[AuthManager] Magic-link requested for ${email} but no sendMagicLink handler configured. URL: ${url}`
570
- );
818
+ sendMagicLink: async ({ email: recipientEmail, url, token }) => {
819
+ const emailService = this.getEmailService();
820
+ if (!emailService) {
821
+ console.warn(
822
+ `[AuthManager] Magic-link requested for ${recipientEmail} but no email service is wired. URL: ${url}`
823
+ );
824
+ return;
825
+ }
826
+ try {
827
+ await emailService.sendTemplate({
828
+ template: "auth.magic_link",
829
+ to: recipientEmail,
830
+ data: {
831
+ magicLinkUrl: url,
832
+ token,
833
+ expiresInMinutes: 10,
834
+ appName: this.getAppName()
835
+ }
836
+ });
837
+ } catch (err) {
838
+ console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`);
839
+ throw err;
840
+ }
571
841
  }
572
842
  }));
573
843
  }
@@ -608,6 +878,55 @@ var AuthManager = class {
608
878
  schema: buildDeviceAuthorizationPluginSchema()
609
879
  }));
610
880
  }
881
+ const dataEngine = this.config.dataEngine;
882
+ if (dataEngine) {
883
+ const { customSession } = await import("better-auth/plugins/custom-session");
884
+ plugins.push(customSession(async ({ user, session }) => {
885
+ if (!user?.id) return { user, session };
886
+ const isPlatformAdmin = async () => {
887
+ try {
888
+ const links = await dataEngine.find("sys_user_permission_set", {
889
+ where: { user_id: user.id },
890
+ limit: 50
891
+ });
892
+ const platformLinks = (Array.isArray(links) ? links : []).filter(
893
+ (l) => !l.organization_id
894
+ );
895
+ if (platformLinks.length === 0) return false;
896
+ const sets = await dataEngine.find("sys_permission_set", { limit: 50 });
897
+ const adminSet = (Array.isArray(sets) ? sets : []).find(
898
+ (r) => r.name === "admin_full_access"
899
+ );
900
+ if (!adminSet) return false;
901
+ return platformLinks.some(
902
+ (l) => l.permission_set_id === adminSet.id
903
+ );
904
+ } catch {
905
+ return false;
906
+ }
907
+ };
908
+ const isActiveOrgAdmin = async () => {
909
+ try {
910
+ const orgId = session?.activeOrganizationId;
911
+ if (!orgId) return false;
912
+ const members = await dataEngine.find("sys_member", {
913
+ where: { user_id: user.id, organization_id: orgId },
914
+ limit: 5
915
+ });
916
+ return (Array.isArray(members) ? members : []).some((m) => {
917
+ const raw = typeof m?.role === "string" ? m.role : "";
918
+ const roles = raw.split(",").map((s) => s.trim().toLowerCase());
919
+ return roles.includes("owner") || roles.includes("admin");
920
+ });
921
+ } catch {
922
+ return false;
923
+ }
924
+ };
925
+ const promote = await isPlatformAdmin() || await isActiveOrgAdmin();
926
+ if (!promote) return { user, session };
927
+ return { user: { ...user, role: "admin" }, session };
928
+ }));
929
+ }
611
930
  return plugins;
612
931
  }
613
932
  /**
@@ -664,6 +983,26 @@ var AuthManager = class {
664
983
  }
665
984
  this.config = { ...this.config, baseUrl: url };
666
985
  }
986
+ /**
987
+ * Inject (or replace) the outbound email service used by better-auth
988
+ * callbacks. Safe to call after construction but BEFORE the first
989
+ * request hits the auth handler — callbacks read this via
990
+ * {@link getEmailService} when invoked.
991
+ *
992
+ * AuthPlugin calls this on `kernel:ready` once `ctx.getService('email')`
993
+ * resolves. For tests / serverless, callers may invoke directly.
994
+ */
995
+ setEmailService(email) {
996
+ this.config.emailService = email;
997
+ }
998
+ /** @internal Used by callback closures. */
999
+ getEmailService() {
1000
+ return this.config.emailService;
1001
+ }
1002
+ /** @internal `{{appName}}` placeholder value for built-in templates. */
1003
+ getAppName() {
1004
+ return this.config.appName ?? "ObjectStack";
1005
+ }
667
1006
  /**
668
1007
  * Get the underlying better-auth instance
669
1008
  * Useful for advanced use cases
@@ -863,24 +1202,22 @@ var AuthPlugin = class {
863
1202
  ctx.registerService("auth", this.authManager);
864
1203
  ctx.getService("manifest").register({
865
1204
  ...authPluginManifestHeader,
1205
+ ...this.options.manifestDatasource ? { defaultDatasource: this.options.manifestDatasource } : {},
866
1206
  objects: authIdentityObjects,
867
1207
  // The platform Setup App is a static metadata artifact (lives in
868
1208
  // @objectstack/platform-objects/apps). plugin-auth is the natural
869
1209
  // owner of its registration since it loads first among the trio
870
1210
  // (auth + security + audit) that supplies the underlying objects.
871
1211
  apps: [SETUP_APP],
872
- // Curated list views and dashboards consumed by the Setup App's
873
- // navigation entries. The manifest service does NOT auto-discover
874
- // these from the app definition — they must be registered as
875
- // explicit top-level arrays per ObjectStackDefinitionSchema.
876
- views: [
877
- UsersView,
878
- OrganizationsView,
879
- RolesView,
880
- SessionsView,
881
- AuditLogsView,
882
- PackageInstallationsView
883
- ],
1212
+ // List views for each Setup-nav object are defined on the schema
1213
+ // itself via the canonical `listViews` map (e.g.
1214
+ // sys_user.listViews.{all_users,unverified,two_factor}). Registering
1215
+ // top-level views here is the legacy pre-M10.30c pattern — it caused
1216
+ // duplicate "Users"/"Roles"/"Sessions" tabs to appear alongside the
1217
+ // schema-derived ones, sometimes referencing nonexistent fields
1218
+ // (e.g. legacy `users.view` had phone/status/active columns that do
1219
+ // not exist on sys_user). Schema-embedded listViews is the single
1220
+ // source of truth.
884
1221
  dashboards: [SystemOverviewDashboard, SecurityOverviewDashboard]
885
1222
  });
886
1223
  ctx.logger.info("Auth Plugin initialized successfully");
@@ -890,8 +1227,43 @@ var AuthPlugin = class {
890
1227
  if (!this.authManager) {
891
1228
  throw new Error("Auth manager not initialized");
892
1229
  }
1230
+ ctx.hook("kernel:ready", async () => {
1231
+ try {
1232
+ const i18n = ctx.getService("i18n");
1233
+ let loaded = 0;
1234
+ for (const [locale, data] of Object.entries(SetupAppTranslations)) {
1235
+ if (data && typeof data === "object") {
1236
+ try {
1237
+ i18n.loadTranslations(locale, data);
1238
+ loaded++;
1239
+ } catch (err) {
1240
+ ctx.logger.warn(
1241
+ `Auth: failed to load Setup App translations for '${locale}': ${err?.message ?? err}`
1242
+ );
1243
+ }
1244
+ }
1245
+ }
1246
+ if (loaded > 0) {
1247
+ ctx.logger.info(
1248
+ `Auth: contributed Setup App translations (${loaded} locale${loaded > 1 ? "s" : ""})`
1249
+ );
1250
+ }
1251
+ } catch {
1252
+ }
1253
+ });
893
1254
  if (this.options.registerRoutes) {
894
1255
  ctx.hook("kernel:ready", async () => {
1256
+ if (this.authManager) {
1257
+ try {
1258
+ const emailSvc = ctx.getService("email");
1259
+ if (emailSvc) {
1260
+ this.authManager.setEmailService(emailSvc);
1261
+ ctx.logger.info("Auth: email service wired (transactional mail enabled)");
1262
+ }
1263
+ } catch {
1264
+ ctx.logger.info("Auth: no email service registered \u2014 auth callbacks will log instead of sending");
1265
+ }
1266
+ }
895
1267
  let httpServer = null;
896
1268
  try {
897
1269
  httpServer = ctx.getService("http-server");
@@ -997,6 +1369,19 @@ var AuthPlugin = class {
997
1369
  ctx.logger.error("[AuthPlugin] better-auth returned server error", new Error(`HTTP ${response.status}: (unable to read body)`));
998
1370
  }
999
1371
  }
1372
+ try {
1373
+ const url = c.req.url;
1374
+ if (response.ok && /\/jwks(\?|$)/.test(url)) {
1375
+ const existing = response.headers.get("cache-control");
1376
+ if (!existing) {
1377
+ response.headers.set(
1378
+ "cache-control",
1379
+ "public, max-age=300, stale-while-revalidate=86400"
1380
+ );
1381
+ }
1382
+ }
1383
+ } catch {
1384
+ }
1000
1385
  return response;
1001
1386
  } catch (error) {
1002
1387
  const err = error instanceof Error ? error : new Error(String(error));
@@ -1031,8 +1416,19 @@ var AuthPlugin = class {
1031
1416
  const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = await import("@better-auth/oauth-provider");
1032
1417
  const authServerHandler = oauthProviderAuthServerMetadata(auth);
1033
1418
  const openidConfigHandler = oauthProviderOpenIdConfigMetadata(auth);
1034
- rawApp.get("/.well-known/oauth-authorization-server", (c) => authServerHandler(c.req.raw));
1035
- rawApp.get("/.well-known/openid-configuration", (c) => openidConfigHandler(c.req.raw));
1419
+ const DISCOVERY_CACHE = "public, max-age=300, stale-while-revalidate=86400";
1420
+ const withDiscoveryCache = async (handler, req) => {
1421
+ const resp = await handler(req);
1422
+ try {
1423
+ if (resp.ok && !resp.headers.get("cache-control")) {
1424
+ resp.headers.set("cache-control", DISCOVERY_CACHE);
1425
+ }
1426
+ } catch {
1427
+ }
1428
+ return resp;
1429
+ };
1430
+ rawApp.get("/.well-known/oauth-authorization-server", (c) => withDiscoveryCache(authServerHandler, c.req.raw));
1431
+ rawApp.get("/.well-known/openid-configuration", (c) => withDiscoveryCache(openidConfigHandler, c.req.raw));
1036
1432
  ctx.logger.info(
1037
1433
  "OIDC discovery endpoints mounted at /.well-known/{oauth-authorization-server,openid-configuration}"
1038
1434
  );
@@ -1040,6 +1436,8 @@ var AuthPlugin = class {
1040
1436
  };
1041
1437
  export {
1042
1438
  AUTH_ACCOUNT_CONFIG,
1439
+ AUTH_ADMIN_SESSION_FIELDS,
1440
+ AUTH_ADMIN_USER_FIELDS,
1043
1441
  AUTH_DEVICE_CODE_SCHEMA,
1044
1442
  AUTH_INVITATION_SCHEMA,
1045
1443
  AUTH_JWKS_SCHEMA,
@@ -1061,6 +1459,7 @@ export {
1061
1459
  AUTH_VERIFICATION_CONFIG,
1062
1460
  AuthManager,
1063
1461
  AuthPlugin,
1462
+ buildAdminPluginSchema,
1064
1463
  buildDeviceAuthorizationPluginSchema,
1065
1464
  buildJwtPluginSchema,
1066
1465
  buildOauthProviderPluginSchema,