@objectstack/platform-objects 10.3.0 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,13 @@ var SysUser = data.ObjectSchema.create({
35
35
  locations: ["list_toolbar"],
36
36
  type: "api",
37
37
  target: "/api/v1/auth/organization/invite-member",
38
+ // Org invitations are a multi-org-only flow (the endpoint resolves
39
+ // an active org that does not exist in single-org mode). Hide the
40
+ // affordance unless multi-org is enabled — matching the
41
+ // `create_organization` gate on sys_organization. This action is the
42
+ // most exposed of the set because the Users list is always reachable
43
+ // in single-org, unlike the org/membership lists.
44
+ visible: "features.multiOrgEnabled != false",
38
45
  successMessage: "Invitation sent",
39
46
  refreshAfter: true,
40
47
  params: [
@@ -78,6 +85,21 @@ var SysUser = data.ObjectSchema.create({
78
85
  successMessage: "User unbanned",
79
86
  refreshAfter: true
80
87
  },
88
+ {
89
+ // ADR-0069 D2 — clear a brute-force lockout early (locked_until auto-
90
+ // expires, but an admin can release a user immediately). Hits the
91
+ // plugin-auth custom route, which is admin-guarded server-side.
92
+ name: "unlock_user",
93
+ label: "Unlock Account",
94
+ icon: "lock-open",
95
+ variant: "secondary",
96
+ locations: ["list_item"],
97
+ type: "api",
98
+ target: "/api/v1/auth/admin/unlock-user",
99
+ recordIdParam: "userId",
100
+ successMessage: "Account unlocked",
101
+ refreshAfter: true
102
+ },
81
103
  {
82
104
  name: "set_user_password",
83
105
  label: "Set Password",
@@ -154,7 +176,11 @@ var SysUser = data.ObjectSchema.create({
154
176
  locations: ["record_header", "record_more", "record_section"],
155
177
  type: "api",
156
178
  target: "/api/v1/auth/change-password",
157
- visible: "record.id == ctx.user.id",
179
+ // Managed (IdP-provisioned) users hold no local credential — hide the
180
+ // password form so they can't self-mint a password that bypasses
181
+ // enforced SSO. The break-glass owner (env-native, or flipped back when
182
+ // their break-glass password is set) keeps it. ADR-0024 D4/D5.2.
183
+ visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"',
158
184
  successMessage: "Password changed",
159
185
  refreshAfter: false,
160
186
  params: [
@@ -171,7 +197,9 @@ var SysUser = data.ObjectSchema.create({
171
197
  locations: ["record_header", "record_more", "record_section"],
172
198
  type: "api",
173
199
  target: "/api/v1/auth/change-email",
174
- visible: "record.id == ctx.user.id",
200
+ // A managed user's email is owned by the IdP — a local change would
201
+ // desync. Hide for IdP-provisioned; env-native users keep it.
202
+ visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"',
175
203
  successMessage: "Verification email sent \u2014 check the new address to confirm.",
176
204
  refreshAfter: false,
177
205
  params: [
@@ -202,7 +230,9 @@ var SysUser = data.ObjectSchema.create({
202
230
  locations: ["record_more", "record_section"],
203
231
  type: "api",
204
232
  target: "/api/v1/auth/delete-user",
205
- visible: "record.id == ctx.user.id",
233
+ // Self-delete needs a local password; managed users are deprovisioned
234
+ // via the IdP (org-removal / SCIM), not local self-service. Hide for them.
235
+ visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"',
206
236
  confirmText: "Permanently delete your account? This cannot be undone \u2014 all your sessions will be terminated and all data you own will be removed per the configured retention policy.",
207
237
  successMessage: "Account deleted",
208
238
  refreshAfter: false,
@@ -285,7 +315,7 @@ var SysUser = data.ObjectSchema.create({
285
315
  name: "all_users",
286
316
  label: "All Users",
287
317
  data: { provider: "object", object: "sys_user" },
288
- columns: ["name", "email", "email_verified", "two_factor_enabled", "created_at"],
318
+ columns: ["name", "email", "email_verified", "source", "two_factor_enabled", "created_at"],
289
319
  sort: [{ field: "name", order: "asc" }],
290
320
  pagination: { pageSize: 50 }
291
321
  },
@@ -372,6 +402,51 @@ var SysUser = data.ObjectSchema.create({
372
402
  group: "Admin",
373
403
  description: "When set, the ban auto-clears at this time."
374
404
  }),
405
+ // ── Anti-brute-force (ADR-0069 D2) — owned by objectql, better-auth is
406
+ // oblivious. The auth manager's sign-in hooks maintain these: failures
407
+ // increment the counter; crossing `lockout_threshold` stamps
408
+ // `locked_until`; a successful sign-in resets both. Admins can clear
409
+ // them early via the Unlock action.
410
+ failed_login_count: data.Field.number({
411
+ label: "Failed Login Count",
412
+ required: false,
413
+ defaultValue: 0,
414
+ readonly: true,
415
+ group: "Admin",
416
+ description: "Consecutive failed sign-in attempts; reset to 0 on success. Maintained by the auth manager."
417
+ }),
418
+ locked_until: data.Field.datetime({
419
+ label: "Locked Until",
420
+ required: false,
421
+ readonly: true,
422
+ group: "Admin",
423
+ description: "When set and in the future, sign-in is rejected (brute-force lockout). Auto-clears past this time; an admin can clear it early via Unlock."
424
+ }),
425
+ // ADR-0069 D1 — last password change; drives password-expiry enforcement.
426
+ // Stamped on sign-up / change-password / reset-password. Null = never
427
+ // expires (until the user next changes their password).
428
+ password_changed_at: data.Field.datetime({
429
+ label: "Password Changed At",
430
+ required: false,
431
+ readonly: true,
432
+ group: "Admin",
433
+ description: "Timestamp of the last password change. Backs password_expiry_days; system-managed."
434
+ }),
435
+ // ADR-0069 D3 — when enforced MFA first applied to this user; starts the
436
+ // grace clock. Stamped lazily at session validation; system-managed.
437
+ mfa_required_at: data.Field.datetime({
438
+ label: "MFA Required At",
439
+ required: false,
440
+ readonly: true,
441
+ group: "Admin",
442
+ description: "When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed."
443
+ }),
444
+ ai_access: data.Field.boolean({
445
+ label: "AI Access",
446
+ defaultValue: false,
447
+ group: "Admin",
448
+ description: "Whether this user holds an AI seat \u2014 grants access to the in-UI AI agents (build / ask). The framework synthesizes the `ai_seat` capability from this flag (plugin-hono-server resolveCtx). Assignment is capped by the licensed / purchased seat count (enforced by @objectstack/security-enterprise AiSeatPlugin). Owned by objectql (better-auth is oblivious to this column)."
449
+ }),
375
450
  // ── Profile ──────────────────────────────────────────────────
376
451
  image: data.Field.url({
377
452
  label: "Profile Image",
@@ -392,6 +467,28 @@ var SysUser = data.ObjectSchema.create({
392
467
  description: "The user's primary business unit \u2014 a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership."
393
468
  }),
394
469
  // ── System (auto-managed, hidden from create/edit forms) ─────
470
+ // Identity provenance (ADR-0024 D4). `idp_provisioned` users were
471
+ // JIT-created on first federated login (a `sys_account` exists for an
472
+ // external/OIDC provider — e.g. the cloud-as-IdP `objectstack-cloud`
473
+ // provider, or a customer's own IdP); `env_native` users registered
474
+ // locally (email/password) or are app end-users. Stamped automatically by
475
+ // the AuthManager `account.create.after` hook — never edited by hand.
476
+ // Drives the managed-vs-native user-mgmt UI gating (the password /
477
+ // identity-edit actions hide for managed users, who hold no local
478
+ // credential) and is the marker SCIM lifecycle keys off. Owned by objectql
479
+ // (better-auth is oblivious to this column — like `ai_access`).
480
+ source: data.Field.select({
481
+ label: "Identity Source",
482
+ required: false,
483
+ readonly: true,
484
+ group: "System",
485
+ defaultValue: "env_native",
486
+ options: [
487
+ { label: "IdP-Provisioned", value: "idp_provisioned" },
488
+ { label: "Env-Native", value: "env_native" }
489
+ ],
490
+ description: "How this identity was created \u2014 idp_provisioned (federated SSO JIT) or env_native (local signup / app end-user). System-managed; do not edit."
491
+ }),
395
492
  id: data.Field.text({
396
493
  label: "User ID",
397
494
  required: true,
@@ -516,6 +613,29 @@ var SysSession = data.ObjectSchema.create({
516
613
  required: true,
517
614
  group: "Session"
518
615
  }),
616
+ // ── ADR-0069 D4 — session controls (idle / absolute / revoke) ──
617
+ last_activity_at: data.Field.datetime({
618
+ label: "Last Activity At",
619
+ required: false,
620
+ readonly: true,
621
+ group: "Session",
622
+ description: "Timestamp of the last request on this session; drives idle-timeout. System-managed."
623
+ }),
624
+ revoked_at: data.Field.datetime({
625
+ label: "Revoked At",
626
+ required: false,
627
+ readonly: true,
628
+ group: "Session",
629
+ description: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed."
630
+ }),
631
+ revoke_reason: data.Field.text({
632
+ label: "Revoke Reason",
633
+ required: false,
634
+ maxLength: 64,
635
+ readonly: true,
636
+ group: "Session",
637
+ description: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, \u2026)."
638
+ }),
519
639
  // ── Active context (multi-org/team) ──────────────────────────
520
640
  active_organization_id: data.Field.lookup("sys_organization", {
521
641
  label: "Active Organization",
@@ -757,6 +877,16 @@ var SysAccount = data.ObjectSchema.create({
757
877
  label: "Password Hash",
758
878
  required: false,
759
879
  description: "Hashed password for email/password provider"
880
+ }),
881
+ // ADR-0069 D1 — bounded ring of previous password hashes (JSON array of
882
+ // strings), used to reject password reuse on change/reset. Maintained by
883
+ // the auth manager; never exposed in UI.
884
+ previous_password_hashes: data.Field.textarea({
885
+ label: "Previous Password Hashes",
886
+ required: false,
887
+ readonly: true,
888
+ hidden: true,
889
+ description: "JSON array of prior password hashes (bounded by password_history_count); reuse-prevention only. System-managed."
760
890
  })
761
891
  },
762
892
  indexes: [
@@ -822,7 +952,13 @@ var SysVerification = data.ObjectSchema.create({
822
952
  })
823
953
  },
824
954
  indexes: [
825
- { fields: ["value"], unique: true },
955
+ // `value` must NOT be unique. better-auth's oauth-provider stores OIDC
956
+ // authorization codes in this table with `value` = a JSON blob keyed by
957
+ // user+client+state, which can legitimately repeat. A UNIQUE constraint
958
+ // makes `/api/v1/auth/oauth2/authorize` fail (`UNIQUE constraint failed:
959
+ // sys_verification.value`) → 503, breaking cloud-as-IdP SSO entirely.
960
+ // better-auth keys verification lookups on `identifier`, not `value`.
961
+ { fields: ["value"], unique: false },
826
962
  { fields: ["identifier"], unique: false },
827
963
  { fields: ["expires_at"], unique: false }
828
964
  ],
@@ -890,6 +1026,10 @@ var SysOrganization = data.ObjectSchema.create({
890
1026
  recordIdParam: "organizationId",
891
1027
  // better-auth `organization/update` nests editable fields under `data`.
892
1028
  bodyShape: { wrap: "data" },
1029
+ // Org-admin actions are multi-org-only; hide them in single-org for
1030
+ // consistency with `create_organization` (the org list is empty there,
1031
+ // but this also guards direct record-URL access).
1032
+ visible: "features.multiOrgEnabled != false",
893
1033
  successMessage: "Organization updated",
894
1034
  refreshAfter: true,
895
1035
  params: [
@@ -908,6 +1048,7 @@ var SysOrganization = data.ObjectSchema.create({
908
1048
  type: "api",
909
1049
  target: "/api/v1/auth/organization/delete",
910
1050
  recordIdParam: "organizationId",
1051
+ visible: "features.multiOrgEnabled != false",
911
1052
  confirmText: "Delete this organization? All members will lose access immediately. This cannot be undone.",
912
1053
  successMessage: "Organization deleted",
913
1054
  refreshAfter: true
@@ -926,6 +1067,7 @@ var SysOrganization = data.ObjectSchema.create({
926
1067
  type: "api",
927
1068
  target: "/api/v1/auth/organization/set-active",
928
1069
  recordIdParam: "organizationId",
1070
+ visible: "features.multiOrgEnabled != false",
929
1071
  successMessage: "Active organization switched",
930
1072
  refreshAfter: true
931
1073
  },
@@ -942,6 +1084,7 @@ var SysOrganization = data.ObjectSchema.create({
942
1084
  type: "api",
943
1085
  target: "/api/v1/auth/organization/leave",
944
1086
  recordIdParam: "organizationId",
1087
+ visible: "features.multiOrgEnabled != false",
945
1088
  confirmText: "Leave this organization? You will lose access to all of its resources.",
946
1089
  successMessage: "You have left the organization",
947
1090
  refreshAfter: true
@@ -963,6 +1106,7 @@ var SysOrganization = data.ObjectSchema.create({
963
1106
  type: "api",
964
1107
  target: "/api/v1/cloud/organizations/{id}/change-slug",
965
1108
  method: "POST",
1109
+ visible: "features.multiOrgEnabled != false",
966
1110
  confirmText: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?",
967
1111
  successMessage: "Organization slug changed",
968
1112
  refreshAfter: true,
@@ -1012,6 +1156,17 @@ var SysOrganization = data.ObjectSchema.create({
1012
1156
  description: "JSON-serialized organization metadata",
1013
1157
  group: "Configuration"
1014
1158
  }),
1159
+ // ADR-0069 D3 — per-org MFA tightening above the global floor. When true,
1160
+ // members of this org must enrol TOTP to access data (enforced at the
1161
+ // session-validation gate). An org can only tighten, never loosen, the
1162
+ // global `mfa_required` setting.
1163
+ require_mfa: data.Field.boolean({
1164
+ label: "Require Multi-Factor Auth",
1165
+ required: false,
1166
+ defaultValue: false,
1167
+ group: "Configuration",
1168
+ description: "When true, every member of this organization must enroll an authenticator app to access data."
1169
+ }),
1015
1170
  // ── System ───────────────────────────────────────────────────
1016
1171
  id: data.Field.text({
1017
1172
  label: "Organization ID",
@@ -1061,7 +1216,10 @@ var SysMember = data.ObjectSchema.create({
1061
1216
  docsUrl: "https://docs.objectstack.ai/adr/0010-metadata-protection"
1062
1217
  },
1063
1218
  description: "Organization membership records",
1064
- titleFormat: "{user_id} in {organization_id}",
1219
+ // Org-independent title: organization_id is null in single-org mode, so a
1220
+ // '{user_id} in {organization_id}' format renders "… in null". User + role
1221
+ // identifies the membership in both single- and multi-org deployments.
1222
+ titleFormat: "{user_id} ({role})",
1065
1223
  compactLayout: ["user_id", "organization_id", "role"],
1066
1224
  // Row-level actions: better-auth `organization/update-member-role` and
1067
1225
  // `organization/remove-member`. Generic CRUD is suppressed on better-auth
@@ -1082,6 +1240,11 @@ var SysMember = data.ObjectSchema.create({
1082
1240
  locations: ["list_toolbar"],
1083
1241
  type: "api",
1084
1242
  target: "/api/v1/auth/organization/add-member",
1243
+ // Org-membership mutations are multi-org-only: the better-auth
1244
+ // endpoints resolve an active org that does not exist in single-org
1245
+ // mode, so these actions would fail at the API. Gate every one on
1246
+ // the multi-org flag (mirrors sys_organization.create_organization).
1247
+ visible: "features.multiOrgEnabled != false",
1085
1248
  successMessage: "Member added",
1086
1249
  refreshAfter: true,
1087
1250
  params: [
@@ -1099,6 +1262,7 @@ var SysMember = data.ObjectSchema.create({
1099
1262
  type: "api",
1100
1263
  target: "/api/v1/auth/organization/update-member-role",
1101
1264
  recordIdParam: "memberId",
1265
+ visible: "features.multiOrgEnabled != false",
1102
1266
  successMessage: "Member role updated",
1103
1267
  refreshAfter: true,
1104
1268
  params: [
@@ -1115,6 +1279,7 @@ var SysMember = data.ObjectSchema.create({
1115
1279
  type: "api",
1116
1280
  target: "/api/v1/auth/organization/remove-member",
1117
1281
  recordIdParam: "memberIdOrEmail",
1282
+ visible: "features.multiOrgEnabled != false",
1118
1283
  confirmText: "Remove this member from the organization? They will lose access to all org resources.",
1119
1284
  successMessage: "Member removed",
1120
1285
  refreshAfter: true
@@ -1136,7 +1301,7 @@ var SysMember = data.ObjectSchema.create({
1136
1301
  target: "/api/v1/auth/organization/update-member-role",
1137
1302
  recordIdParam: "memberId",
1138
1303
  bodyExtra: { role: "owner" },
1139
- visible: "record.role != 'owner'",
1304
+ visible: "record.role != 'owner' && features.multiOrgEnabled != false",
1140
1305
  confirmText: "Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.",
1141
1306
  successMessage: "Ownership transferred",
1142
1307
  refreshAfter: true
@@ -1221,7 +1386,10 @@ var SysInvitation = data.ObjectSchema.create({
1221
1386
  docsUrl: "https://docs.objectstack.ai/adr/0010-metadata-protection"
1222
1387
  },
1223
1388
  description: "Organization invitations for user onboarding",
1224
- titleFormat: "Invitation to {organization_id}",
1389
+ // Title by invitee email rather than organization_id: the latter is null in
1390
+ // single-org mode (renders "Invitation to null"), and the recipient email is
1391
+ // the more useful identifier in both modes anyway.
1392
+ titleFormat: "Invitation for {email}",
1225
1393
  compactLayout: ["email", "organization_id", "status"],
1226
1394
  // Custom actions — generic CRUD is suppressed (better-auth-managed).
1227
1395
  // Mirror the `invite_user` toolbar action from sys_user here so admins
@@ -1235,6 +1403,13 @@ var SysInvitation = data.ObjectSchema.create({
1235
1403
  locations: ["list_toolbar"],
1236
1404
  type: "api",
1237
1405
  target: "/api/v1/auth/organization/invite-member",
1406
+ // Inviting/managing invitations is a multi-org-only flow (the
1407
+ // endpoint resolves an active org absent in single-org mode). Gate
1408
+ // the admin-side actions on the multi-org flag (mirrors
1409
+ // sys_organization.create_organization). The recipient-side
1410
+ // accept/reject actions below stay record-gated — they are
1411
+ // unreachable in single-org anyway (no invitation rows exist).
1412
+ visible: "features.multiOrgEnabled != false",
1238
1413
  successMessage: "Invitation sent",
1239
1414
  refreshAfter: true,
1240
1415
  params: [
@@ -1252,6 +1427,7 @@ var SysInvitation = data.ObjectSchema.create({
1252
1427
  type: "api",
1253
1428
  target: "/api/v1/auth/organization/cancel-invitation",
1254
1429
  recordIdParam: "invitationId",
1430
+ visible: "features.multiOrgEnabled != false",
1255
1431
  confirmText: "Cancel this invitation? The recipient will no longer be able to accept it.",
1256
1432
  successMessage: "Invitation canceled",
1257
1433
  refreshAfter: true
@@ -1265,6 +1441,7 @@ var SysInvitation = data.ObjectSchema.create({
1265
1441
  type: "api",
1266
1442
  target: "/api/v1/auth/organization/invite-member",
1267
1443
  bodyExtra: { resend: true },
1444
+ visible: "features.multiOrgEnabled != false",
1268
1445
  successMessage: "Invitation resent",
1269
1446
  refreshAfter: true,
1270
1447
  params: [
@@ -1449,6 +1626,10 @@ var SysTeam = data.ObjectSchema.create({
1449
1626
  locations: ["list_toolbar"],
1450
1627
  type: "api",
1451
1628
  target: "/api/v1/auth/organization/create-team",
1629
+ // Teams are nested inside organizations — a multi-org-only concept.
1630
+ // Gate every team mutation on the multi-org flag so the affordances
1631
+ // disappear in single-org (mirrors sys_organization.create_organization).
1632
+ visible: "features.multiOrgEnabled != false",
1452
1633
  successMessage: "Team created",
1453
1634
  refreshAfter: true,
1454
1635
  params: [
@@ -1469,6 +1650,7 @@ var SysTeam = data.ObjectSchema.create({
1469
1650
  target: "/api/v1/auth/organization/update-team",
1470
1651
  recordIdParam: "teamId",
1471
1652
  bodyShape: { wrap: "data" },
1653
+ visible: "features.multiOrgEnabled != false",
1472
1654
  successMessage: "Team updated",
1473
1655
  refreshAfter: true,
1474
1656
  params: [
@@ -1487,6 +1669,7 @@ var SysTeam = data.ObjectSchema.create({
1487
1669
  type: "api",
1488
1670
  target: "/api/v1/auth/organization/remove-team",
1489
1671
  recordIdParam: "teamId",
1672
+ visible: "features.multiOrgEnabled != false",
1490
1673
  confirmText: "Delete this team? Members will lose any team-scoped access. This cannot be undone.",
1491
1674
  successMessage: "Team deleted",
1492
1675
  refreshAfter: true
@@ -1597,6 +1780,10 @@ var SysTeamMember = data.ObjectSchema.create({
1597
1780
  locations: ["list_toolbar"],
1598
1781
  type: "api",
1599
1782
  target: "/api/v1/auth/organization/add-team-member",
1783
+ // Team membership lives under organizations — multi-org-only. Gate
1784
+ // both mutations so they vanish in single-org (mirrors
1785
+ // sys_organization.create_organization).
1786
+ visible: "features.multiOrgEnabled != false",
1600
1787
  successMessage: "Team member added",
1601
1788
  refreshAfter: true,
1602
1789
  params: [
@@ -1617,6 +1804,7 @@ var SysTeamMember = data.ObjectSchema.create({
1617
1804
  locations: ["list_item"],
1618
1805
  type: "api",
1619
1806
  target: "/api/v1/auth/organization/remove-team-member",
1807
+ visible: "features.multiOrgEnabled != false",
1620
1808
  confirmText: "Remove this user from the team? They will lose any team-scoped access.",
1621
1809
  successMessage: "Team member removed",
1622
1810
  refreshAfter: true,
@@ -3156,6 +3344,342 @@ var SysJwks = data.ObjectSchema.create({
3156
3344
  mru: false
3157
3345
  }
3158
3346
  });
3347
+ var SysSsoProvider = data.ObjectSchema.create({
3348
+ name: "sys_sso_provider",
3349
+ label: "SSO Provider",
3350
+ pluralLabel: "SSO Providers",
3351
+ icon: "shield-check",
3352
+ isSystem: true,
3353
+ managedBy: "better-auth",
3354
+ // ADR-0024 — env-global, ADMIN-ONLY identity config. Two orthogonal controls:
3355
+ // • `tenancy.enabled: false` — the env IS the tenant; providers are env-wide,
3356
+ // not org-partitioned. Opting out of multi-tenancy lets a platform admin's
3357
+ // `viewAllRecords` superuser bypass see every provider (without it, the
3358
+ // `member_default` wildcard `tenant_isolation` RLS denies every row, since
3359
+ // better-auth writes via its adapter with no tenantId → `organization_id`
3360
+ // is never stamped).
3361
+ // • `requiredPermissions: ['manage_platform_settings']` — object-level
3362
+ // capability gate (ADR-0066 D3) so ordinary members are denied entirely
3363
+ // (without it, tenancy-disabled + `member_default`'s `'*': allowRead` would
3364
+ // leak providers to every authenticated user).
3365
+ // Together: admins see all env providers; non-admins get 403. better-auth's
3366
+ // own endpoints already read via a system context. (Env-only object — no
3367
+ // control-plane cross-tenant risk.)
3368
+ tenancy: { enabled: false, strategy: "shared" },
3369
+ requiredPermissions: ["manage_platform_settings"],
3370
+ // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
3371
+ protection: {
3372
+ lock: "full",
3373
+ reason: "Identity table managed by better-auth (@better-auth/sso) \u2014 see ADR-0024.",
3374
+ docsUrl: "https://docs.objectstack.ai/adr/0010-metadata-protection"
3375
+ },
3376
+ description: "External SSO identity providers (OIDC / SAML) this environment federates login to",
3377
+ displayNameField: "provider_id",
3378
+ titleFormat: "{provider_id}",
3379
+ compactLayout: ["provider_id", "issuer", "domain"],
3380
+ // All mutations go through @better-auth/sso's endpoints under
3381
+ // /api/v1/auth/sso/* (register / delete-provider) rather than the generic
3382
+ // data layer, so server-side config validation + secret handling run.
3383
+ actions: [
3384
+ {
3385
+ name: "register_sso_provider",
3386
+ label: "Register SSO Provider",
3387
+ icon: "plus-circle",
3388
+ variant: "primary",
3389
+ mode: "create",
3390
+ locations: ["list_toolbar"],
3391
+ type: "api",
3392
+ method: "POST",
3393
+ // Routed through the env-side bridge (plugin-auth `auth-plugin.ts`), which
3394
+ // reshapes these FLAT form fields into the nested `oidcConfig` body that
3395
+ // `@better-auth/sso`'s /sso/register requires, then re-dispatches to it
3396
+ // (so the admin gate + discovery hydration run). Posting straight to
3397
+ // /sso/register would drop clientId/clientSecret (top-level → Zod-stripped)
3398
+ // and persist an unusable `oidc_config = null` provider.
3399
+ target: "/api/v1/auth/admin/sso/register",
3400
+ refreshAfter: true,
3401
+ params: [
3402
+ { name: "providerId", label: "Provider ID", type: "text", required: true, helpText: 'Stable identifier, e.g. "okta" or "acme-entra".' },
3403
+ { name: "issuer", label: "Issuer URL", type: "text", required: true, helpText: "IdP issuer, e.g. https://acme.okta.com. Discovery is fetched from here unless an explicit URL is given below." },
3404
+ { name: "domain", label: "Email Domain", type: "text", required: true, helpText: "Users with this email domain are routed to this IdP, e.g. acme.com." },
3405
+ { name: "clientId", label: "Client ID", type: "text", required: true, helpText: "OAuth client ID issued by the IdP for this environment." },
3406
+ { name: "clientSecret", label: "Client Secret", type: "text", required: true, helpText: "OAuth client secret (stored encrypted by better-auth)." },
3407
+ { name: "discoveryEndpoint", label: "Discovery URL", type: "text", required: false, helpText: "Optional. OIDC discovery document URL. Leave blank to derive `<issuer>/.well-known/openid-configuration`." },
3408
+ { name: "scopes", label: "Scopes", type: "text", required: false, placeholder: "openid email profile", helpText: 'Optional. Space- or comma-separated OAuth scopes. Defaults to "openid email profile".' },
3409
+ { name: "mapId", label: "Map: User ID claim", type: "text", required: false, placeholder: "sub", helpText: 'Optional. ID-token claim mapped to the user ID. Defaults to "sub".' },
3410
+ { name: "mapEmail", label: "Map: Email claim", type: "text", required: false, placeholder: "email", helpText: 'Optional. Claim mapped to email. Defaults to "email".' },
3411
+ { name: "mapName", label: "Map: Name claim", type: "text", required: false, placeholder: "name", helpText: 'Optional. Claim mapped to display name. Defaults to "name".' }
3412
+ ]
3413
+ },
3414
+ {
3415
+ name: "register_saml_provider",
3416
+ label: "Register SAML Provider",
3417
+ icon: "shield",
3418
+ variant: "primary",
3419
+ mode: "create",
3420
+ locations: ["list_toolbar"],
3421
+ type: "api",
3422
+ method: "POST",
3423
+ // SAML 2.0 via @better-auth/sso (samlify-backed). Routed through the
3424
+ // env-side bridge (plugin-auth `auth-plugin.ts` → register-saml), which
3425
+ // reshapes these FLAT IdP fields into the nested `samlConfig` body that
3426
+ // @better-auth/sso's /sso/register requires (entryPoint/cert/callbackUrl/
3427
+ // spMetadata/identifierFormat), derives the per-provider ACS URL, and
3428
+ // re-dispatches to /sso/register (admin gate runs). The response returns
3429
+ // the SP ACS + metadata URLs to configure on the IdP.
3430
+ target: "/api/v1/auth/admin/sso/register-saml",
3431
+ refreshAfter: true,
3432
+ params: [
3433
+ { name: "providerId", label: "Provider ID", type: "text", required: true, helpText: 'Stable identifier, e.g. "acme-saml".' },
3434
+ { name: "issuer", label: "IdP Entity ID", type: "text", required: true, helpText: "The IdP\u2019s SAML EntityID (issuer), e.g. https://saml.acme.com/entityid." },
3435
+ { name: "domain", label: "Email Domain", type: "text", required: true, helpText: "Users with this email domain are routed to this IdP, e.g. acme.com." },
3436
+ { name: "entryPoint", label: "IdP SSO URL", type: "text", required: true, helpText: "The IdP\u2019s SAML single sign-on (redirect) endpoint that receives the SAMLRequest." },
3437
+ { name: "cert", label: "IdP Signing Certificate", type: "textarea", required: true, helpText: "The IdP\u2019s X.509 signing certificate (PEM body). Used to verify assertion signatures." },
3438
+ { name: "identifierFormat", label: "NameID Format", type: "text", required: false, placeholder: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", helpText: "Optional. Requested SAML NameID format. Defaults to the IdP\u2019s configured format." }
3439
+ ]
3440
+ },
3441
+ {
3442
+ name: "request_domain_verification",
3443
+ label: "Request Domain Verification",
3444
+ icon: "globe",
3445
+ variant: "secondary",
3446
+ locations: ["list_item", "record_header"],
3447
+ type: "api",
3448
+ method: "POST",
3449
+ // ADR-0024 ② (opt-in OS_SSO_DOMAIN_VERIFICATION). Asks @better-auth/sso
3450
+ // for a one-time DNS-TXT challenge and reveals the ready-to-paste record
3451
+ // ONCE via `resultDialog`. Routed through the env bridge (plugin-auth /
3452
+ // cloud AuthProxyPlugin) which reshapes the `{domainVerificationToken}`
3453
+ // response into the `{ data: { dnsRecordName, dnsRecordValue } }` envelope
3454
+ // the dialog reads. When the feature is OFF the bridge returns a clear
3455
+ // "not enabled for this environment" error instead of a bare 404.
3456
+ target: "/api/v1/auth/admin/sso/request-domain-verification",
3457
+ params: [
3458
+ { name: "providerId", field: "provider_id", defaultFromRow: true, required: true },
3459
+ { name: "domain", field: "domain", defaultFromRow: true, required: false }
3460
+ ],
3461
+ resultDialog: {
3462
+ title: "Verify your domain",
3463
+ description: "Add the DNS TXT record below at your domain\u2019s DNS provider, then run \u201CVerify Domain\u201D. The token is shown once.",
3464
+ acknowledge: "Done",
3465
+ fields: [
3466
+ { path: "data.dnsRecordType", label: "Record type", format: "text" },
3467
+ { path: "data.dnsRecordName", label: "Name / Host", format: "secret" },
3468
+ { path: "data.dnsRecordValue", label: "Value", format: "secret" }
3469
+ ]
3470
+ }
3471
+ },
3472
+ {
3473
+ name: "verify_domain",
3474
+ label: "Verify Domain",
3475
+ icon: "shield-check",
3476
+ variant: "secondary",
3477
+ locations: ["list_item", "record_header"],
3478
+ type: "api",
3479
+ method: "POST",
3480
+ // ADR-0024 ②. Re-checks the DNS-TXT record and flips `domain_verified`
3481
+ // on success. Routed through the env bridge, which maps @better-auth/sso's
3482
+ // empty 204 / 502 into a clear success/error toast.
3483
+ target: "/api/v1/auth/admin/sso/verify-domain",
3484
+ successMessage: "Domain ownership verified",
3485
+ refreshAfter: true,
3486
+ params: [
3487
+ { name: "providerId", field: "provider_id", defaultFromRow: true, required: true }
3488
+ ]
3489
+ },
3490
+ {
3491
+ name: "delete_sso_provider",
3492
+ label: "Delete SSO Provider",
3493
+ icon: "trash-2",
3494
+ variant: "danger",
3495
+ mode: "delete",
3496
+ locations: ["list_item", "record_header"],
3497
+ type: "api",
3498
+ method: "POST",
3499
+ target: "/api/v1/auth/sso/delete-provider",
3500
+ confirmText: "Delete this SSO provider? Users from its domain will no longer be able to sign in through it.",
3501
+ successMessage: "SSO provider deleted",
3502
+ refreshAfter: true,
3503
+ params: [
3504
+ { name: "providerId", field: "provider_id", defaultFromRow: true, required: true }
3505
+ ]
3506
+ }
3507
+ ],
3508
+ listViews: {
3509
+ all: {
3510
+ type: "grid",
3511
+ name: "all",
3512
+ label: "All",
3513
+ data: { provider: "object", object: "sys_sso_provider" },
3514
+ columns: ["provider_id", "issuer", "domain", "domain_verified", "created_at"],
3515
+ sort: [{ field: "provider_id", order: "asc" }],
3516
+ pagination: { pageSize: 50 },
3517
+ // Per-object empty state — the shared identity-object copy ("created
3518
+ // automatically … cannot be added here") is wrong for this object, which
3519
+ // HAS a "Register SSO Provider" action. Point admins at it instead.
3520
+ emptyState: {
3521
+ title: "No SSO providers yet",
3522
+ message: "Register your organization\u2019s external IdP \u2014 OIDC (Okta, Entra, Auth0, \u2026) with \u201CRegister SSO Provider\u201D, or SAML 2.0 with \u201CRegister SAML Provider\u201D. Members whose email domain matches can then sign in through it.",
3523
+ icon: "log-in"
3524
+ }
3525
+ }
3526
+ },
3527
+ fields: {
3528
+ id: data.Field.text({ label: "ID", required: true, readonly: true, group: "System" }),
3529
+ provider_id: data.Field.text({
3530
+ label: "Provider ID",
3531
+ required: true,
3532
+ searchable: true,
3533
+ maxLength: 255,
3534
+ description: "Stable provider identifier (unique within the environment)",
3535
+ group: "Identity"
3536
+ }),
3537
+ issuer: data.Field.text({
3538
+ label: "Issuer",
3539
+ required: true,
3540
+ maxLength: 2048,
3541
+ description: "IdP issuer URL",
3542
+ group: "Identity"
3543
+ }),
3544
+ domain: data.Field.text({
3545
+ label: "Email Domain",
3546
+ required: true,
3547
+ maxLength: 255,
3548
+ description: "Email domain routed to this IdP (e.g. acme.com)",
3549
+ group: "Identity"
3550
+ }),
3551
+ domain_verified: data.Field.boolean({
3552
+ label: "Domain Verified",
3553
+ defaultValue: false,
3554
+ readonly: true,
3555
+ description: "Whether DNS ownership of the email domain has been proven (ADR-0024 \u2461). Set by \u201CVerify Domain\u201D after the DNS TXT record resolves. Managed by better-auth \u2014 not directly editable. Only enforced when domain verification is enabled for the environment.",
3556
+ group: "Identity"
3557
+ }),
3558
+ oidc_config: data.Field.textarea({
3559
+ label: "OIDC Config",
3560
+ required: false,
3561
+ description: "JSON: clientId, clientSecret, endpoints, scopes, mapping, pkce (managed by better-auth)",
3562
+ group: "Protocol"
3563
+ }),
3564
+ saml_config: data.Field.textarea({
3565
+ label: "SAML Config",
3566
+ required: false,
3567
+ description: "JSON: entryPoint, cert, identifierFormat, mapping (managed by better-auth)",
3568
+ group: "Protocol"
3569
+ }),
3570
+ user_id: data.Field.lookup("sys_user", {
3571
+ label: "Registered By",
3572
+ required: false,
3573
+ description: "User who registered this provider",
3574
+ group: "System"
3575
+ }),
3576
+ organization_id: data.Field.text({
3577
+ label: "Organization",
3578
+ required: false,
3579
+ maxLength: 255,
3580
+ description: "Organization scope (when org-scoped SSO is used)",
3581
+ group: "System"
3582
+ }),
3583
+ created_at: data.Field.datetime({ label: "Created At", defaultValue: "NOW()", readonly: true, group: "System" }),
3584
+ updated_at: data.Field.datetime({ label: "Updated At", defaultValue: "NOW()", readonly: true, group: "System" })
3585
+ },
3586
+ indexes: [
3587
+ { fields: ["provider_id"], unique: true },
3588
+ { fields: ["domain"] },
3589
+ { fields: ["user_id"] }
3590
+ ],
3591
+ enable: {
3592
+ trackHistory: true,
3593
+ searchable: true,
3594
+ apiEnabled: true,
3595
+ // Mutations go through /api/v1/auth/sso/* (register / delete-provider);
3596
+ // the generic data layer is read-only so sysadmins cannot bypass
3597
+ // server-side validation / secret handling.
3598
+ apiMethods: ["get", "list"],
3599
+ trash: false,
3600
+ mru: false
3601
+ }
3602
+ });
3603
+ var SysScimProvider = data.ObjectSchema.create({
3604
+ name: "sys_scim_provider",
3605
+ label: "SCIM Provider",
3606
+ pluralLabel: "SCIM Providers",
3607
+ icon: "users",
3608
+ isSystem: true,
3609
+ managedBy: "better-auth",
3610
+ // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
3611
+ protection: {
3612
+ lock: "full",
3613
+ reason: "Identity table managed by better-auth (@better-auth/scim) \u2014 see ADR-0071.",
3614
+ docsUrl: "https://docs.objectstack.ai/adr/0010-metadata-protection"
3615
+ },
3616
+ description: "SCIM 2.0 connections (bearer tokens) external IdPs use to provision/deprovision this environment's users",
3617
+ displayNameField: "provider_id",
3618
+ titleFormat: "{provider_id}",
3619
+ compactLayout: ["provider_id", "organization_id"],
3620
+ listViews: {
3621
+ all: {
3622
+ type: "grid",
3623
+ name: "all",
3624
+ label: "All",
3625
+ data: { provider: "object", object: "sys_scim_provider" },
3626
+ // scim_token is intentionally excluded — never surface the credential.
3627
+ columns: ["provider_id", "organization_id", "created_at"],
3628
+ sort: [{ field: "provider_id", order: "asc" }],
3629
+ pagination: { pageSize: 50 }
3630
+ }
3631
+ },
3632
+ fields: {
3633
+ id: data.Field.text({ label: "ID", required: true, readonly: true, group: "System" }),
3634
+ provider_id: data.Field.text({
3635
+ label: "Provider ID",
3636
+ required: true,
3637
+ searchable: true,
3638
+ maxLength: 255,
3639
+ description: 'Stable SCIM provider identifier (e.g. "okta-scim")',
3640
+ group: "Identity"
3641
+ }),
3642
+ scim_token: data.Field.text({
3643
+ label: "SCIM Token (hash)",
3644
+ required: false,
3645
+ readonly: true,
3646
+ maxLength: 1024,
3647
+ description: "Hashed bearer credential for this SCIM connection \u2014 the plaintext is shown once at generate-token. Sensitive; do not expose.",
3648
+ group: "Secret"
3649
+ }),
3650
+ organization_id: data.Field.text({
3651
+ label: "Organization",
3652
+ required: false,
3653
+ maxLength: 255,
3654
+ description: "Organization scope of this token (org-scoped tokens restrict provisioning to that org)",
3655
+ group: "System"
3656
+ }),
3657
+ user_id: data.Field.lookup("sys_user", {
3658
+ label: "Owned By",
3659
+ required: false,
3660
+ description: "User who generated this token (when provider-ownership is enabled)",
3661
+ group: "System"
3662
+ }),
3663
+ created_at: data.Field.datetime({ label: "Created At", defaultValue: "NOW()", readonly: true, group: "System" }),
3664
+ updated_at: data.Field.datetime({ label: "Updated At", defaultValue: "NOW()", readonly: true, group: "System" })
3665
+ },
3666
+ indexes: [
3667
+ { fields: ["provider_id"], unique: true },
3668
+ { fields: ["organization_id"] },
3669
+ { fields: ["user_id"] }
3670
+ ],
3671
+ enable: {
3672
+ trackHistory: true,
3673
+ searchable: false,
3674
+ apiEnabled: true,
3675
+ // Mutations + token issuance go through @better-auth/scim's endpoints
3676
+ // under /api/v1/auth/scim/*; the generic data layer is read-only so the
3677
+ // credential cannot be written/bypassed through it.
3678
+ apiMethods: ["list"],
3679
+ trash: false,
3680
+ mru: false
3681
+ }
3682
+ });
3159
3683
 
3160
3684
  exports.SysAccount = SysAccount;
3161
3685
  exports.SysApiKey = SysApiKey;
@@ -3170,7 +3694,9 @@ exports.SysOauthApplication = SysOauthApplication;
3170
3694
  exports.SysOauthConsent = SysOauthConsent;
3171
3695
  exports.SysOauthRefreshToken = SysOauthRefreshToken;
3172
3696
  exports.SysOrganization = SysOrganization;
3697
+ exports.SysScimProvider = SysScimProvider;
3173
3698
  exports.SysSession = SysSession;
3699
+ exports.SysSsoProvider = SysSsoProvider;
3174
3700
  exports.SysTeam = SysTeam;
3175
3701
  exports.SysTeamMember = SysTeamMember;
3176
3702
  exports.SysTwoFactor = SysTwoFactor;