Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/adr/0092-sys-user-profile-field-delegation.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist

- **Status:** Accepted
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
- **Deciders:** ObjectStack Protocol Architects
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
Expand DownExpand Up@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
`allowEdit: false`; the standard edit path is therefore **platform-admin only**.
Self-service profile editing stays on better-auth `/update-user`
(the existing `update_my_profile` action).
⚠️ **Amended 2026-09-03** — a member may now edit their OWN row on the generic
data path, bounded by `member_default`'s explicit `sys_user` entry and the
`sys_user_self` RLS carve-out. Read the D5 Amendment below before this bullet.
- **D6** — an `afterUpdate` companion hook invalidates the affected user's cached
session snapshots (secondary storage), keeping better-auth session reads coherent
without delegating the write itself to `internalAdapter.updateUser`.
Expand DownExpand Up@@ -245,6 +248,10 @@ table changes its affordances in this ADR.

### D5 — Who can edit whom: unchanged permission topology

*(As amended 2026-09-03 — see the Amendment below for what changed and why. The
original text is kept verbatim, because the Amendment is only readable against
it and because two of the three bullets still hold.)*

- `member_default` / `viewer_readonly` / `organization_admin`: `allowEdit: false` on
identity tables stays. Nothing about this ADR widens *who* may write.
- Platform admins (`admin_full_access`) become the only principals whose standard-form
Expand All@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
name"), that is a permission-set + RLS decision (`sys_user_org_members` is currently
`select`-only) layered on top of the same guard — a follow-up, not this ADR.

> **Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22,
> verbatim 「同意」).** A rank-and-file member **may** edit their own `sys_user`
> row on the generic data path. The third bullet above no longer holds: an RLS
> self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
> better-auth's door is no longer strictly better — it cannot carry every
> whitelisted column.
>
> **What forced it.** The 2026-07-11 text rests on a premise that a later ruling
> retired: that better-auth `/update-user` can carry everything Tier 1 holds. It
> could, while Tier 1 was `{name, image}`. The 2026-09-03 ruling on #14787
> admitted `locale`, and `locale` is deliberately **not** a better-auth
> `additionalFields` entry — declaring it there would make `getSession` SELECT a
> column an environment that has not run schema-sync does not have (#13881
> measured this; it is the same hazard the `ai_access` note in `auth-manager.ts`
> records). So `/update-user` cannot post the column, and with `member_default`
> denying `allowEdit` the generic path could not either. The column shipped
> reachable by platform admins alone — a *user-stated* preference (#14788 ruled
> the stored value outranks `Accept-Language` precisely because it is the user's
> own statement) that the user could not state. That is ADR-0049's
> declared-but-not-enforceable shape one step removed, and it is why leaving it
> admin-only was rejected rather than deferred.
>
> **What the amendment decides.** Self-service edits of the D1 Tier-1 columns
> route through the **generic data path**, bounded on the two axes that already
> exist and in the shape `sys_api_key` has shipped since #8053:
>
> - **which rows** — `member_default` gains an explicit `sys_user` entry
> (`allowRead` / `allowEdit` true, create / delete **false**), and its
> `sys_user_self` policy (`id == current_user.id`) widens from `select` to
> `all` so it reaches the by-id write pre-image check. `sys_user_org_members`
> stays `select`-only: RLS policies OR-combine, so widening the org-peer
> *visibility* scope would compose `id == me OR id IN <every user in my org>`
> and hand every member their colleagues' profile rows. The org-admin
> follow-up named at the end of the original D5 text is therefore still open,
> and still a separate decision.
> - **which columns** — unchanged. D2's guard keeps bounding a user-context
> update to the registered whitelist, so widening *who* does not widen *what*.
> The shape rules on the columns refuse a malformed value identically on every
> path, which is the property that made this the small decision rather than
> the large one.
>
> `name` and `image` therefore become editable on the generic path too, not only
> through `/update-user`. That is the real cost of the amendment and it is
> accepted deliberately: **D6** already mirrors better-auth's
> `refreshUserSessions` for exactly those columns, so the session-cache
> coherence the original bullet bought by routing through `/update-user` is
> bought here by the companion hook instead. (`locale` is correctly *excluded*
> from that mirror — better-auth carries no such field on its user model, so
> there is no stale cached copy to repair and merging one would manufacture an
> incoherence rather than fix one.) Both doors stay open; neither is retired.
>
> **Rejected in the same ruling**, recorded so they are not re-proposed:
> a dedicated endpoint (`POST /api/v1/me/locale`, or extending
> `update_my_profile`) writing under system context — the "second stamping
> route" that #14787's own ruling rejected one level up, and the position where
> a shape check is most easily skipped; and `locale` as a better-auth
> `additionalFields` entry, refused on #13881's measurement above.
>
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
> whitelist constant is the enforced one and holds three; reconciling the table
> is tracked separately (#14951).

### D6 — Session-cache invalidation companion hook

An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/adr/0092-sys-user-profile-field-delegation.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist

- **Status:** Accepted
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
- **Deciders:** ObjectStack Protocol Architects
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
Expand DownExpand Up@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
`allowEdit: false`; the standard edit path is therefore **platform-admin only**.
Self-service profile editing stays on better-auth `/update-user`
(the existing `update_my_profile` action).
⚠️ **Amended 2026-09-03** — a member may now edit their OWN row on the generic
data path, bounded by `member_default`'s explicit `sys_user` entry and the
`sys_user_self` RLS carve-out. Read the D5 Amendment below before this bullet.
- **D6** — an `afterUpdate` companion hook invalidates the affected user's cached
session snapshots (secondary storage), keeping better-auth session reads coherent
without delegating the write itself to `internalAdapter.updateUser`.
Expand DownExpand Up@@ -245,6 +248,10 @@ table changes its affordances in this ADR.

### D5 — Who can edit whom: unchanged permission topology

*(As amended 2026-09-03 — see the Amendment below for what changed and why. The
original text is kept verbatim, because the Amendment is only readable against
it and because two of the three bullets still hold.)*

- `member_default` / `viewer_readonly` / `organization_admin`: `allowEdit: false` on
identity tables stays. Nothing about this ADR widens *who* may write.
- Platform admins (`admin_full_access`) become the only principals whose standard-form
Expand All@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
name"), that is a permission-set + RLS decision (`sys_user_org_members` is currently
`select`-only) layered on top of the same guard — a follow-up, not this ADR.

> **Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22,
> verbatim 「同意」).** A rank-and-file member **may** edit their own `sys_user`
> row on the generic data path. The third bullet above no longer holds: an RLS
> self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
> better-auth's door is no longer strictly better — it cannot carry every
> whitelisted column.
>
> **What forced it.** The 2026-07-11 text rests on a premise that a later ruling
> retired: that better-auth `/update-user` can carry everything Tier 1 holds. It
> could, while Tier 1 was `{name, image}`. The 2026-09-03 ruling on #14787
> admitted `locale`, and `locale` is deliberately **not** a better-auth
> `additionalFields` entry — declaring it there would make `getSession` SELECT a
> column an environment that has not run schema-sync does not have (#13881
> measured this; it is the same hazard the `ai_access` note in `auth-manager.ts`
> records). So `/update-user` cannot post the column, and with `member_default`
> denying `allowEdit` the generic path could not either. The column shipped
> reachable by platform admins alone — a *user-stated* preference (#14788 ruled
> the stored value outranks `Accept-Language` precisely because it is the user's
> own statement) that the user could not state. That is ADR-0049's
> declared-but-not-enforceable shape one step removed, and it is why leaving it
> admin-only was rejected rather than deferred.
>
> **What the amendment decides.** Self-service edits of the D1 Tier-1 columns
> route through the **generic data path**, bounded on the two axes that already
> exist and in the shape `sys_api_key` has shipped since #8053:
>
> - **which rows** — `member_default` gains an explicit `sys_user` entry
> (`allowRead` / `allowEdit` true, create / delete **false**), and its
> `sys_user_self` policy (`id == current_user.id`) widens from `select` to
> `all` so it reaches the by-id write pre-image check. `sys_user_org_members`
> stays `select`-only: RLS policies OR-combine, so widening the org-peer
> *visibility* scope would compose `id == me OR id IN <every user in my org>`
> and hand every member their colleagues' profile rows. The org-admin
> follow-up named at the end of the original D5 text is therefore still open,
> and still a separate decision.
> - **which columns** — unchanged. D2's guard keeps bounding a user-context
> update to the registered whitelist, so widening *who* does not widen *what*.
> The shape rules on the columns refuse a malformed value identically on every
> path, which is the property that made this the small decision rather than
> the large one.
>
> `name` and `image` therefore become editable on the generic path too, not only
> through `/update-user`. That is the real cost of the amendment and it is
> accepted deliberately: **D6** already mirrors better-auth's
> `refreshUserSessions` for exactly those columns, so the session-cache
> coherence the original bullet bought by routing through `/update-user` is
> bought here by the companion hook instead. (`locale` is correctly *excluded*
> from that mirror — better-auth carries no such field on its user model, so
> there is no stale cached copy to repair and merging one would manufacture an
> incoherence rather than fix one.) Both doors stay open; neither is retired.
>
> **Rejected in the same ruling**, recorded so they are not re-proposed:
> a dedicated endpoint (`POST /api/v1/me/locale`, or extending
> `update_my_profile`) writing under system context — the "second stamping
> route" that #14787's own ruling rejected one level up, and the position where
> a shape check is most easily skipped; and `locale` as a better-auth
> `additionalFields` entry, refused on #13881's measurement above.
>
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
> whitelist constant is the enforced one and holds three; reconciling the table
> is tracked separately (#14951).

### D6 — Session-cache invalidation companion hook

An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/adr/0092-sys-user-profile-field-delegation.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist

- **Status:** Accepted
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
- **Deciders:** ObjectStack Protocol Architects
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
Expand DownExpand Up@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
`allowEdit: false`; the standard edit path is therefore **platform-admin only**.
Self-service profile editing stays on better-auth `/update-user`
(the existing `update_my_profile` action).
⚠️ **Amended 2026-09-03** — a member may now edit their OWN row on the generic
data path, bounded by `member_default`'s explicit `sys_user` entry and the
`sys_user_self` RLS carve-out. Read the D5 Amendment below before this bullet.
- **D6** — an `afterUpdate` companion hook invalidates the affected user's cached
session snapshots (secondary storage), keeping better-auth session reads coherent
without delegating the write itself to `internalAdapter.updateUser`.
Expand DownExpand Up@@ -245,6 +248,10 @@ table changes its affordances in this ADR.

### D5 — Who can edit whom: unchanged permission topology

*(As amended 2026-09-03 — see the Amendment below for what changed and why. The
original text is kept verbatim, because the Amendment is only readable against
it and because two of the three bullets still hold.)*

- `member_default` / `viewer_readonly` / `organization_admin`: `allowEdit: false` on
identity tables stays. Nothing about this ADR widens *who* may write.
- Platform admins (`admin_full_access`) become the only principals whose standard-form
Expand All@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
name"), that is a permission-set + RLS decision (`sys_user_org_members` is currently
`select`-only) layered on top of the same guard — a follow-up, not this ADR.

> **Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22,
> verbatim 「同意」).** A rank-and-file member **may** edit their own `sys_user`
> row on the generic data path. The third bullet above no longer holds: an RLS
> self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
> better-auth's door is no longer strictly better — it cannot carry every
> whitelisted column.
>
> **What forced it.** The 2026-07-11 text rests on a premise that a later ruling
> retired: that better-auth `/update-user` can carry everything Tier 1 holds. It
> could, while Tier 1 was `{name, image}`. The 2026-09-03 ruling on #14787
> admitted `locale`, and `locale` is deliberately **not** a better-auth
> `additionalFields` entry — declaring it there would make `getSession` SELECT a
> column an environment that has not run schema-sync does not have (#13881
> measured this; it is the same hazard the `ai_access` note in `auth-manager.ts`
> records). So `/update-user` cannot post the column, and with `member_default`
> denying `allowEdit` the generic path could not either. The column shipped
> reachable by platform admins alone — a *user-stated* preference (#14788 ruled
> the stored value outranks `Accept-Language` precisely because it is the user's
> own statement) that the user could not state. That is ADR-0049's
> declared-but-not-enforceable shape one step removed, and it is why leaving it
> admin-only was rejected rather than deferred.
>
> **What the amendment decides.** Self-service edits of the D1 Tier-1 columns
> route through the **generic data path**, bounded on the two axes that already
> exist and in the shape `sys_api_key` has shipped since #8053:
>
> - **which rows** — `member_default` gains an explicit `sys_user` entry
> (`allowRead` / `allowEdit` true, create / delete **false**), and its
> `sys_user_self` policy (`id == current_user.id`) widens from `select` to
> `all` so it reaches the by-id write pre-image check. `sys_user_org_members`
> stays `select`-only: RLS policies OR-combine, so widening the org-peer
> *visibility* scope would compose `id == me OR id IN <every user in my org>`
> and hand every member their colleagues' profile rows. The org-admin
> follow-up named at the end of the original D5 text is therefore still open,
> and still a separate decision.
> - **which columns** — unchanged. D2's guard keeps bounding a user-context
> update to the registered whitelist, so widening *who* does not widen *what*.
> The shape rules on the columns refuse a malformed value identically on every
> path, which is the property that made this the small decision rather than
> the large one.
>
> `name` and `image` therefore become editable on the generic path too, not only
> through `/update-user`. That is the real cost of the amendment and it is
> accepted deliberately: **D6** already mirrors better-auth's
> `refreshUserSessions` for exactly those columns, so the session-cache
> coherence the original bullet bought by routing through `/update-user` is
> bought here by the companion hook instead. (`locale` is correctly *excluded*
> from that mirror — better-auth carries no such field on its user model, so
> there is no stale cached copy to repair and merging one would manufacture an
> incoherence rather than fix one.) Both doors stay open; neither is retired.
>
> **Rejected in the same ruling**, recorded so they are not re-proposed:
> a dedicated endpoint (`POST /api/v1/me/locale`, or extending
> `update_my_profile`) writing under system context — the "second stamping
> route" that #14787's own ruling rejected one level up, and the position where
> a shape check is most easily skipped; and `locale` as a better-auth
> `additionalFields` entry, refused on #13881's measurement above.
>
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
> whitelist constant is the enforced one and holds three; reconciling the table
> is tracked separately (#14951).

### D6 — Session-cache invalidation companion hook

An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/adr/0092-sys-user-profile-field-delegation.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist

- **Status:** Accepted
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
- **Deciders:** ObjectStack Protocol Architects
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
Expand DownExpand Up@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
`allowEdit: false`; the standard edit path is therefore **platform-admin only**.
Self-service profile editing stays on better-auth `/update-user`
(the existing `update_my_profile` action).
⚠️ **Amended 2026-09-03** — a member may now edit their OWN row on the generic
data path, bounded by `member_default`'s explicit `sys_user` entry and the
`sys_user_self` RLS carve-out. Read the D5 Amendment below before this bullet.
- **D6** — an `afterUpdate` companion hook invalidates the affected user's cached
session snapshots (secondary storage), keeping better-auth session reads coherent
without delegating the write itself to `internalAdapter.updateUser`.
Expand DownExpand Up@@ -245,6 +248,10 @@ table changes its affordances in this ADR.

### D5 — Who can edit whom: unchanged permission topology

*(As amended 2026-09-03 — see the Amendment below for what changed and why. The
original text is kept verbatim, because the Amendment is only readable against
it and because two of the three bullets still hold.)*

- `member_default` / `viewer_readonly` / `organization_admin`: `allowEdit: false` on
identity tables stays. Nothing about this ADR widens *who* may write.
- Platform admins (`admin_full_access`) become the only principals whose standard-form
Expand All@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
name"), that is a permission-set + RLS decision (`sys_user_org_members` is currently
`select`-only) layered on top of the same guard — a follow-up, not this ADR.

> **Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22,
> verbatim 「同意」).** A rank-and-file member **may** edit their own `sys_user`
> row on the generic data path. The third bullet above no longer holds: an RLS
> self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
> better-auth's door is no longer strictly better — it cannot carry every
> whitelisted column.
>
> **What forced it.** The 2026-07-11 text rests on a premise that a later ruling
> retired: that better-auth `/update-user` can carry everything Tier 1 holds. It
> could, while Tier 1 was `{name, image}`. The 2026-09-03 ruling on #14787
> admitted `locale`, and `locale` is deliberately **not** a better-auth
> `additionalFields` entry — declaring it there would make `getSession` SELECT a
> column an environment that has not run schema-sync does not have (#13881
> measured this; it is the same hazard the `ai_access` note in `auth-manager.ts`
> records). So `/update-user` cannot post the column, and with `member_default`
> denying `allowEdit` the generic path could not either. The column shipped
> reachable by platform admins alone — a *user-stated* preference (#14788 ruled
> the stored value outranks `Accept-Language` precisely because it is the user's
> own statement) that the user could not state. That is ADR-0049's
> declared-but-not-enforceable shape one step removed, and it is why leaving it
> admin-only was rejected rather than deferred.
>
> **What the amendment decides.** Self-service edits of the D1 Tier-1 columns
> route through the **generic data path**, bounded on the two axes that already
> exist and in the shape `sys_api_key` has shipped since #8053:
>
> - **which rows** — `member_default` gains an explicit `sys_user` entry
> (`allowRead` / `allowEdit` true, create / delete **false**), and its
> `sys_user_self` policy (`id == current_user.id`) widens from `select` to
> `all` so it reaches the by-id write pre-image check. `sys_user_org_members`
> stays `select`-only: RLS policies OR-combine, so widening the org-peer
> *visibility* scope would compose `id == me OR id IN <every user in my org>`
> and hand every member their colleagues' profile rows. The org-admin
> follow-up named at the end of the original D5 text is therefore still open,
> and still a separate decision.
> - **which columns** — unchanged. D2's guard keeps bounding a user-context
> update to the registered whitelist, so widening *who* does not widen *what*.
> The shape rules on the columns refuse a malformed value identically on every
> path, which is the property that made this the small decision rather than
> the large one.
>
> `name` and `image` therefore become editable on the generic path too, not only
> through `/update-user`. That is the real cost of the amendment and it is
> accepted deliberately: **D6** already mirrors better-auth's
> `refreshUserSessions` for exactly those columns, so the session-cache
> coherence the original bullet bought by routing through `/update-user` is
> bought here by the companion hook instead. (`locale` is correctly *excluded*
> from that mirror — better-auth carries no such field on its user model, so
> there is no stale cached copy to repair and merging one would manufacture an
> incoherence rather than fix one.) Both doors stay open; neither is retired.
>
> **Rejected in the same ruling**, recorded so they are not re-proposed:
> a dedicated endpoint (`POST /api/v1/me/locale`, or extending
> `update_my_profile`) writing under system context — the "second stamping
> route" that #14787's own ruling rejected one level up, and the position where
> a shape check is most easily skipped; and `locale` as a better-auth
> `additionalFields` entry, refused on #13881's measurement above.
>
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
> whitelist constant is the enforced one and holds three; reconciling the table
> is tracked separately (#14951).

### D6 — Session-cache invalidation companion hook

An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/adr/0092-sys-user-profile-field-delegation.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist

- **Status:** Accepted
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
- **Deciders:** ObjectStack Protocol Architects
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
Expand DownExpand Up@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
`allowEdit: false`; the standard edit path is therefore **platform-admin only**.
Self-service profile editing stays on better-auth `/update-user`
(the existing `update_my_profile` action).
⚠️ **Amended 2026-09-03** — a member may now edit their OWN row on the generic
data path, bounded by `member_default`'s explicit `sys_user` entry and the
`sys_user_self` RLS carve-out. Read the D5 Amendment below before this bullet.
- **D6** — an `afterUpdate` companion hook invalidates the affected user's cached
session snapshots (secondary storage), keeping better-auth session reads coherent
without delegating the write itself to `internalAdapter.updateUser`.
Expand DownExpand Up@@ -245,6 +248,10 @@ table changes its affordances in this ADR.

### D5 — Who can edit whom: unchanged permission topology

*(As amended 2026-09-03 — see the Amendment below for what changed and why. The
original text is kept verbatim, because the Amendment is only readable against
it and because two of the three bullets still hold.)*

- `member_default` / `viewer_readonly` / `organization_admin`: `allowEdit: false` on
identity tables stays. Nothing about this ADR widens *who* may write.
- Platform admins (`admin_full_access`) become the only principals whose standard-form
Expand All@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
name"), that is a permission-set + RLS decision (`sys_user_org_members` is currently
`select`-only) layered on top of the same guard — a follow-up, not this ADR.

> **Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22,
> verbatim 「同意」).** A rank-and-file member **may** edit their own `sys_user`
> row on the generic data path. The third bullet above no longer holds: an RLS
> self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
> better-auth's door is no longer strictly better — it cannot carry every
> whitelisted column.
>
> **What forced it.** The 2026-07-11 text rests on a premise that a later ruling
> retired: that better-auth `/update-user` can carry everything Tier 1 holds. It
> could, while Tier 1 was `{name, image}`. The 2026-09-03 ruling on #14787
> admitted `locale`, and `locale` is deliberately **not** a better-auth
> `additionalFields` entry — declaring it there would make `getSession` SELECT a
> column an environment that has not run schema-sync does not have (#13881
> measured this; it is the same hazard the `ai_access` note in `auth-manager.ts`
> records). So `/update-user` cannot post the column, and with `member_default`
> denying `allowEdit` the generic path could not either. The column shipped
> reachable by platform admins alone — a *user-stated* preference (#14788 ruled
> the stored value outranks `Accept-Language` precisely because it is the user's
> own statement) that the user could not state. That is ADR-0049's
> declared-but-not-enforceable shape one step removed, and it is why leaving it
> admin-only was rejected rather than deferred.
>
> **What the amendment decides.** Self-service edits of the D1 Tier-1 columns
> route through the **generic data path**, bounded on the two axes that already
> exist and in the shape `sys_api_key` has shipped since #8053:
>
> - **which rows** — `member_default` gains an explicit `sys_user` entry
> (`allowRead` / `allowEdit` true, create / delete **false**), and its
> `sys_user_self` policy (`id == current_user.id`) widens from `select` to
> `all` so it reaches the by-id write pre-image check. `sys_user_org_members`
> stays `select`-only: RLS policies OR-combine, so widening the org-peer
> *visibility* scope would compose `id == me OR id IN <every user in my org>`
> and hand every member their colleagues' profile rows. The org-admin
> follow-up named at the end of the original D5 text is therefore still open,
> and still a separate decision.
> - **which columns** — unchanged. D2's guard keeps bounding a user-context
> update to the registered whitelist, so widening *who* does not widen *what*.
> The shape rules on the columns refuse a malformed value identically on every
> path, which is the property that made this the small decision rather than
> the large one.
>
> `name` and `image` therefore become editable on the generic path too, not only
> through `/update-user`. That is the real cost of the amendment and it is
> accepted deliberately: **D6** already mirrors better-auth's
> `refreshUserSessions` for exactly those columns, so the session-cache
> coherence the original bullet bought by routing through `/update-user` is
> bought here by the companion hook instead. (`locale` is correctly *excluded*
> from that mirror — better-auth carries no such field on its user model, so
> there is no stale cached copy to repair and merging one would manufacture an
> incoherence rather than fix one.) Both doors stay open; neither is retired.
>
> **Rejected in the same ruling**, recorded so they are not re-proposed:
> a dedicated endpoint (`POST /api/v1/me/locale`, or extending
> `update_my_profile`) writing under system context — the "second stamping
> route" that #14787's own ruling rejected one level up, and the position where
> a shape check is most easily skipped; and `locale` as a better-auth
> `additionalFields` entry, refused on #13881's measurement above.
>
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
> whitelist constant is the enforced one and holds three; reconciling the table
> is tracked separately (#14951).

### D6 — Session-cache invalidation companion hook

An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/adr/0092-sys-user-profile-field-delegation.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist

- **Status:** Accepted
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
- **Deciders:** ObjectStack Protocol Architects
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
Expand DownExpand Up@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
`allowEdit: false`; the standard edit path is therefore **platform-admin only**.
Self-service profile editing stays on better-auth `/update-user`
(the existing `update_my_profile` action).
⚠️ **Amended 2026-09-03** — a member may now edit their OWN row on the generic
data path, bounded by `member_default`'s explicit `sys_user` entry and the
`sys_user_self` RLS carve-out. Read the D5 Amendment below before this bullet.
- **D6** — an `afterUpdate` companion hook invalidates the affected user's cached
session snapshots (secondary storage), keeping better-auth session reads coherent
without delegating the write itself to `internalAdapter.updateUser`.
Expand DownExpand Up@@ -245,6 +248,10 @@ table changes its affordances in this ADR.

### D5 — Who can edit whom: unchanged permission topology

*(As amended 2026-09-03 — see the Amendment below for what changed and why. The
original text is kept verbatim, because the Amendment is only readable against
it and because two of the three bullets still hold.)*

- `member_default` / `viewer_readonly` / `organization_admin`: `allowEdit: false` on
identity tables stays. Nothing about this ADR widens *who* may write.
- Platform admins (`admin_full_access`) become the only principals whose standard-form
Expand All@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
name"), that is a permission-set + RLS decision (`sys_user_org_members` is currently
`select`-only) layered on top of the same guard — a follow-up, not this ADR.

> **Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22,
> verbatim 「同意」).** A rank-and-file member **may** edit their own `sys_user`
> row on the generic data path. The third bullet above no longer holds: an RLS
> self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
> better-auth's door is no longer strictly better — it cannot carry every
> whitelisted column.
>
> **What forced it.** The 2026-07-11 text rests on a premise that a later ruling
> retired: that better-auth `/update-user` can carry everything Tier 1 holds. It
> could, while Tier 1 was `{name, image}`. The 2026-09-03 ruling on #14787
> admitted `locale`, and `locale` is deliberately **not** a better-auth
> `additionalFields` entry — declaring it there would make `getSession` SELECT a
> column an environment that has not run schema-sync does not have (#13881
> measured this; it is the same hazard the `ai_access` note in `auth-manager.ts`
> records). So `/update-user` cannot post the column, and with `member_default`
> denying `allowEdit` the generic path could not either. The column shipped
> reachable by platform admins alone — a *user-stated* preference (#14788 ruled
> the stored value outranks `Accept-Language` precisely because it is the user's
> own statement) that the user could not state. That is ADR-0049's
> declared-but-not-enforceable shape one step removed, and it is why leaving it
> admin-only was rejected rather than deferred.
>
> **What the amendment decides.** Self-service edits of the D1 Tier-1 columns
> route through the **generic data path**, bounded on the two axes that already
> exist and in the shape `sys_api_key` has shipped since #8053:
>
> - **which rows** — `member_default` gains an explicit `sys_user` entry
> (`allowRead` / `allowEdit` true, create / delete **false**), and its
> `sys_user_self` policy (`id == current_user.id`) widens from `select` to
> `all` so it reaches the by-id write pre-image check. `sys_user_org_members`
> stays `select`-only: RLS policies OR-combine, so widening the org-peer
> *visibility* scope would compose `id == me OR id IN <every user in my org>`
> and hand every member their colleagues' profile rows. The org-admin
> follow-up named at the end of the original D5 text is therefore still open,
> and still a separate decision.
> - **which columns** — unchanged. D2's guard keeps bounding a user-context
> update to the registered whitelist, so widening *who* does not widen *what*.
> The shape rules on the columns refuse a malformed value identically on every
> path, which is the property that made this the small decision rather than
> the large one.
>
> `name` and `image` therefore become editable on the generic path too, not only
> through `/update-user`. That is the real cost of the amendment and it is
> accepted deliberately: **D6** already mirrors better-auth's
> `refreshUserSessions` for exactly those columns, so the session-cache
> coherence the original bullet bought by routing through `/update-user` is
> bought here by the companion hook instead. (`locale` is correctly *excluded*
> from that mirror — better-auth carries no such field on its user model, so
> there is no stale cached copy to repair and merging one would manufacture an
> incoherence rather than fix one.) Both doors stay open; neither is retired.
>
> **Rejected in the same ruling**, recorded so they are not re-proposed:
> a dedicated endpoint (`POST /api/v1/me/locale`, or extending
> `update_my_profile`) writing under system context — the "second stamping
> route" that #14787's own ruling rejected one level up, and the position where
> a shape check is most easily skipped; and `locale` as a better-auth
> `additionalFields` entry, refused on #13881's measurement above.
>
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
> whitelist constant is the enforced one and holds three; reconciling the table
> is tracked separately (#14951).

### D6 — Session-cache invalidation companion hook

An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/adr/0092-sys-user-profile-field-delegation.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist

- **Status:** Accepted
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
- **Deciders:** ObjectStack Protocol Architects
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
Expand DownExpand Up@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
`allowEdit: false`; the standard edit path is therefore **platform-admin only**.
Self-service profile editing stays on better-auth `/update-user`
(the existing `update_my_profile` action).
⚠️ **Amended 2026-09-03** — a member may now edit their OWN row on the generic
data path, bounded by `member_default`'s explicit `sys_user` entry and the
`sys_user_self` RLS carve-out. Read the D5 Amendment below before this bullet.
- **D6** — an `afterUpdate` companion hook invalidates the affected user's cached
session snapshots (secondary storage), keeping better-auth session reads coherent
without delegating the write itself to `internalAdapter.updateUser`.
Expand DownExpand Up@@ -245,6 +248,10 @@ table changes its affordances in this ADR.

### D5 — Who can edit whom: unchanged permission topology

*(As amended 2026-09-03 — see the Amendment below for what changed and why. The
original text is kept verbatim, because the Amendment is only readable against
it and because two of the three bullets still hold.)*

- `member_default` / `viewer_readonly` / `organization_admin`: `allowEdit: false` on
identity tables stays. Nothing about this ADR widens *who* may write.
- Platform admins (`admin_full_access`) become the only principals whose standard-form
Expand All@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
name"), that is a permission-set + RLS decision (`sys_user_org_members` is currently
`select`-only) layered on top of the same guard — a follow-up, not this ADR.

> **Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22,
> verbatim 「同意」).** A rank-and-file member **may** edit their own `sys_user`
> row on the generic data path. The third bullet above no longer holds: an RLS
> self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
> better-auth's door is no longer strictly better — it cannot carry every
> whitelisted column.
>
> **What forced it.** The 2026-07-11 text rests on a premise that a later ruling
> retired: that better-auth `/update-user` can carry everything Tier 1 holds. It
> could, while Tier 1 was `{name, image}`. The 2026-09-03 ruling on #14787
> admitted `locale`, and `locale` is deliberately **not** a better-auth
> `additionalFields` entry — declaring it there would make `getSession` SELECT a
> column an environment that has not run schema-sync does not have (#13881
> measured this; it is the same hazard the `ai_access` note in `auth-manager.ts`
> records). So `/update-user` cannot post the column, and with `member_default`
> denying `allowEdit` the generic path could not either. The column shipped
> reachable by platform admins alone — a *user-stated* preference (#14788 ruled
> the stored value outranks `Accept-Language` precisely because it is the user's
> own statement) that the user could not state. That is ADR-0049's
> declared-but-not-enforceable shape one step removed, and it is why leaving it
> admin-only was rejected rather than deferred.
>
> **What the amendment decides.** Self-service edits of the D1 Tier-1 columns
> route through the **generic data path**, bounded on the two axes that already
> exist and in the shape `sys_api_key` has shipped since #8053:
>
> - **which rows** — `member_default` gains an explicit `sys_user` entry
> (`allowRead` / `allowEdit` true, create / delete **false**), and its
> `sys_user_self` policy (`id == current_user.id`) widens from `select` to
> `all` so it reaches the by-id write pre-image check. `sys_user_org_members`
> stays `select`-only: RLS policies OR-combine, so widening the org-peer
> *visibility* scope would compose `id == me OR id IN <every user in my org>`
> and hand every member their colleagues' profile rows. The org-admin
> follow-up named at the end of the original D5 text is therefore still open,
> and still a separate decision.
> - **which columns** — unchanged. D2's guard keeps bounding a user-context
> update to the registered whitelist, so widening *who* does not widen *what*.
> The shape rules on the columns refuse a malformed value identically on every
> path, which is the property that made this the small decision rather than
> the large one.
>
> `name` and `image` therefore become editable on the generic path too, not only
> through `/update-user`. That is the real cost of the amendment and it is
> accepted deliberately: **D6** already mirrors better-auth's
> `refreshUserSessions` for exactly those columns, so the session-cache
> coherence the original bullet bought by routing through `/update-user` is
> bought here by the companion hook instead. (`locale` is correctly *excluded*
> from that mirror — better-auth carries no such field on its user model, so
> there is no stale cached copy to repair and merging one would manufacture an
> incoherence rather than fix one.) Both doors stay open; neither is retired.
>
> **Rejected in the same ruling**, recorded so they are not re-proposed:
> a dedicated endpoint (`POST /api/v1/me/locale`, or extending
> `update_my_profile`) writing under system context — the "second stamping
> route" that #14787's own ruling rejected one level up, and the position where
> a shape check is most easily skipped; and `locale` as a better-auth
> `additionalFields` entry, refused on #13881's measurement above.
>
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
> whitelist constant is the enforced one and holds three; reconciling the table
> is tracked separately (#14951).

### D6 — Session-cache invalidation companion hook

An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/adr/0092-sys-user-profile-field-delegation.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist

- **Status:** Accepted
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
- **Deciders:** ObjectStack Protocol Architects
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
Expand DownExpand Up@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
`allowEdit: false`; the standard edit path is therefore **platform-admin only**.
Self-service profile editing stays on better-auth `/update-user`
(the existing `update_my_profile` action).
⚠️ **Amended 2026-09-03** — a member may now edit their OWN row on the generic
data path, bounded by `member_default`'s explicit `sys_user` entry and the
`sys_user_self` RLS carve-out. Read the D5 Amendment below before this bullet.
- **D6** — an `afterUpdate` companion hook invalidates the affected user's cached
session snapshots (secondary storage), keeping better-auth session reads coherent
without delegating the write itself to `internalAdapter.updateUser`.
Expand DownExpand Up@@ -245,6 +248,10 @@ table changes its affordances in this ADR.

### D5 — Who can edit whom: unchanged permission topology

*(As amended 2026-09-03 — see the Amendment below for what changed and why. The
original text is kept verbatim, because the Amendment is only readable against
it and because two of the three bullets still hold.)*

- `member_default` / `viewer_readonly` / `organization_admin`: `allowEdit: false` on
identity tables stays. Nothing about this ADR widens *who* may write.
- Platform admins (`admin_full_access`) become the only principals whose standard-form
Expand All@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
name"), that is a permission-set + RLS decision (`sys_user_org_members` is currently
`select`-only) layered on top of the same guard — a follow-up, not this ADR.

> **Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22,
> verbatim 「同意」).** A rank-and-file member **may** edit their own `sys_user`
> row on the generic data path. The third bullet above no longer holds: an RLS
> self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
> better-auth's door is no longer strictly better — it cannot carry every
> whitelisted column.
>
> **What forced it.** The 2026-07-11 text rests on a premise that a later ruling
> retired: that better-auth `/update-user` can carry everything Tier 1 holds. It
> could, while Tier 1 was `{name, image}`. The 2026-09-03 ruling on #14787
> admitted `locale`, and `locale` is deliberately **not** a better-auth
> `additionalFields` entry — declaring it there would make `getSession` SELECT a
> column an environment that has not run schema-sync does not have (#13881
> measured this; it is the same hazard the `ai_access` note in `auth-manager.ts`
> records). So `/update-user` cannot post the column, and with `member_default`
> denying `allowEdit` the generic path could not either. The column shipped
> reachable by platform admins alone — a *user-stated* preference (#14788 ruled
> the stored value outranks `Accept-Language` precisely because it is the user's
> own statement) that the user could not state. That is ADR-0049's
> declared-but-not-enforceable shape one step removed, and it is why leaving it
> admin-only was rejected rather than deferred.
>
> **What the amendment decides.** Self-service edits of the D1 Tier-1 columns
> route through the **generic data path**, bounded on the two axes that already
> exist and in the shape `sys_api_key` has shipped since #8053:
>
> - **which rows** — `member_default` gains an explicit `sys_user` entry
> (`allowRead` / `allowEdit` true, create / delete **false**), and its
> `sys_user_self` policy (`id == current_user.id`) widens from `select` to
> `all` so it reaches the by-id write pre-image check. `sys_user_org_members`
> stays `select`-only: RLS policies OR-combine, so widening the org-peer
> *visibility* scope would compose `id == me OR id IN <every user in my org>`
> and hand every member their colleagues' profile rows. The org-admin
> follow-up named at the end of the original D5 text is therefore still open,
> and still a separate decision.
> - **which columns** — unchanged. D2's guard keeps bounding a user-context
> update to the registered whitelist, so widening *who* does not widen *what*.
> The shape rules on the columns refuse a malformed value identically on every
> path, which is the property that made this the small decision rather than
> the large one.
>
> `name` and `image` therefore become editable on the generic path too, not only
> through `/update-user`. That is the real cost of the amendment and it is
> accepted deliberately: **D6** already mirrors better-auth's
> `refreshUserSessions` for exactly those columns, so the session-cache
> coherence the original bullet bought by routing through `/update-user` is
> bought here by the companion hook instead. (`locale` is correctly *excluded*
> from that mirror — better-auth carries no such field on its user model, so
> there is no stale cached copy to repair and merging one would manufacture an
> incoherence rather than fix one.) Both doors stay open; neither is retired.
>
> **Rejected in the same ruling**, recorded so they are not re-proposed:
> a dedicated endpoint (`POST /api/v1/me/locale`, or extending
> `update_my_profile`) writing under system context — the "second stamping
> route" that #14787's own ruling rejected one level up, and the position where
> a shape check is most easily skipped; and `locale` as a better-auth
> `additionalFields` entry, refused on #13881's measurement above.
>
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
> whitelist constant is the enforced one and holds three; reconciling the table
> is tracked separately (#14951).

### D6 — Session-cache invalidation companion hook

An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the
Expand Down
Loading