Repository navigation
Show which apps each user connected on the admin Users page #573
Description
Parent
Summary
The admin Users page (https://admin.docs.plus/users) does not show which people connected an app to their account over MCP. This issue adds one column for that, and the same field in the CSV export.
The maintainer can then help people who connected Claude or ChatGPT. The maintainer can also check the connector runs in #357 against real accounts.
Terms
- A grant is one row in
auth.oauth_consents. A live grant hasrevoked_at is null, and its client hasdeleted_at is null. - A connected app is what the admin sees: one app name plus its redirect origins. It holds the same grants that the person sees in Settings > Connected apps. Settings groups and names them its own way (
groupAppsand the brand table), so the two lists can look different.
What the admin sees
A new column after Notifications on /users. Its header is Connected apps (Ruling 1). The header tooltip reads "Apps with access: granted and not revoked". DataTable takes a ReactNode header (DataTable.tsx:14), so the header is <span className="tooltip tooltip-bottom" data-tip="Apps with access: granted and not revoked">Connected apps</span>.
| State | When | Cell |
|---|---|---|
| Loading | The map query is pending, with no data yet | skeleton h-5 w-16 bone, the height of a badge-sm (design-system.md §loading). The rest of the row renders at once. |
| Error | The map query failed, and it holds no earlier data | "Could not load", text-[var(--error-ink)] text-xs. Text uses the ink token, not text-error (.cursor/docs/design-system.md:197, --error-ink at apps/admin-dashboard/src/styles/globals.scss:132). |
| Not available | The route answered available: false |
"Not available", text-base-content/60 text-xs. GoTrue v2.196.0 always creates the tables, so this state is untested by design. |
| None | The map loaded, and the user has no entry | "None", text-base-content/60 text-xs, like apps/admin-dashboard/src/components/ui/NotificationBadges.tsx:40-41 |
| Apps | The user has one or more live grants | One badge badge-ghost badge-sm max-w-40 per connected app, with the name in <bdi className="min-w-0 truncate">, inside flex flex-wrap items-center gap-1. truncate on the inline-flex badge itself draws no ellipsis. |
- When a refetch fails but earlier data exists, React Query keeps
data. The cell then shows that data, not "Could not load". The rule is: Error only whenisError && !data. - Each badge sits in a daisyUI
tooltip tooltip-top, likeNotificationBadges.tsx:24. Thedata-tiplists the redirect origins joined with", ", for examplehttps://claude.ai, https://claude.com. - Each badge also holds its origins as
sr-onlytext. A screen reader then reads the origin, which is the trust fact. The precedent badge labels its icon withrole="img"andaria-label(NotificationBadges.tsx:26-27). - Two grants with the same cleaned name and the same origins show as one badge. A borrowed name with a different origin shows as its own badge.
- The column covers only the current page of 20 users (
USERS_PAGE_SIZE = 20,admin-stats.controller.ts:26). It cannot be sorted or filtered. - No token, species or rule changes. The cell uses daisyUI and Tailwind classes only.
A badge means a live grant that the person can see and disconnect in Settings. It shows a grant, not use. A grant can stay live after the app stops calling.
Data source
The source is auth.oauth_consents joined to auth.oauth_clients. An MCP connection is a Supabase OAuth 2.1 server grant.
- The
deleted_atfilter is required. The local image ispublic.ecr.aws/supabase/gotrue:v2.196.0, andauth.oauth_clientshas adeleted_atcolumn. In upstream GoTruev2.196.0, a client delete setsDeletedAtand does not revoke its consents.UserListOAuthGrantsskips a consent whose client lookup filtersdeleted_at IS NULL. These source citations are upstream, not in this repo:internal/api/oauthserver/service.go:323-341,handlers.go:530-575,models/oauth_client.go:225. Without the filter, the column could show apps that Settings no longer lists. - A hard delete needs no code. Both foreign keys cascade. In a rolled-back test, a client delete and a user delete each left 0 consents.
auth.oauth_authorizationsis not a connection. It holds 3-minute codes (expires_at default now() + '00:03:00').
Not the admin API. @supabase/auth-js 2.118.0 (bun.lock) auth.admin.oauth manages clients only: list, create, get, update and delete. Grants are listed only with the person's own token. Settings > Connected apps uses that path.
Not Prisma. In production, Prisma's DATABASE_URL is the DigitalOcean cluster, not Supabase (AGENTS.md:396, apps/hocuspocus.server/prisma/schema.prisma:8, docker-compose.prod.yml:170). Locally they are separate containers too. docsy-postgres-local has no auth schema: the pg_namespace query returned none. Do not use $queryRaw on auth.*.
The server calls a Supabase RPC with the service-role client (getSupabaseClient(), apps/hocuspocus.server/src/api/utils/supabase.ts:5). adminAnalytics.service.ts:15 is the supabase.rpc(...) precedent.
The function is SECURITY DEFINER and owned by postgres. On the local database, has_table_privilege is true for postgres on both tables. It is false for service_role, authenticated and anon. postgres gets SELECT through the default ACL postgres=arwdDxt/supabase_auth_admin, and rolsuper is false for postgres. An invoker version called as service_role failed with ERROR: permission denied for table oauth_consents.
This is new ground. No function in the repo reads auth.* rows today (catalog check: 0 rows). Hosted read access for postgres is unverified, so implementation step 1 checks it first.
SQL function contract
- Name:
public.admin_get_user_connected_apps(). It followsadmin_get_user_notification_subs(07-4-push-notifications-pgmq.sql:489). It sits inpublic, because PostgREST exposes onlypublicandgraphql_public(packages/supabase/config.toml:13). - Returns: one
jsonbvalue,{ "<user_id>": [{ "name": text | null, "redirectUris": text }] }, newest grant first. It returnsNULLwhen either auth table is missing. - Security:
security definer,set search_path = ''(as in31-connected-app-token-gate.sql:15),stable, read-only. - Grants:
EXECUTEforservice_roleonly.
-- Service role only: the admin route checks the admin. Under service_role
-- auth.uid() is null, so an is_admin(auth.uid()) gate would return zero rows.
-- One jsonb value, so the PostgREST max_rows cap (1000) cannot cut the map.
-- deleted_at: GoTrue soft-deletes a client and leaves its consents live.
create or replace function public.admin_get_user_connected_apps()
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
begin
if to_regclass('auth.oauth_consents') is null
or to_regclass('auth.oauth_clients') is null then
return null;
end if;
return (
select coalesce(jsonb_object_agg(per_user.user_id, per_user.apps), '{}'::jsonb)
from (
select oc.user_id,
jsonb_agg(jsonb_build_object(
'name', cl.client_name,
'redirectUris', cl.redirect_uris
) order by oc.granted_at desc) as apps
from auth.oauth_consents oc
join auth.oauth_clients cl on cl.id = oc.client_id
where oc.revoked_at is null
and cl.deleted_at is null
group by oc.user_id
) per_user
);
end;
$$;
revoke execute on function public.admin_get_user_connected_apps() from public, anon, authenticated;
grant execute on function public.admin_get_user_connected_apps() to service_role;
comment on function public.admin_get_user_connected_apps() is
'Admin Users page: live OAuth grants per user. service_role only.';The comment on function line follows the precedent at 07-4-push-notifications-pgmq.sql:508.
Why each choice holds:
plpgsql, notlanguage sql.check_function_bodiesis on locally. Alanguage sqlbody that names a missing table fails atCREATE(ERROR: relation "auth.no_such_table" does not exist). GoTrue creates theauth.oauth_*tables, not the repo. Aplpgsqlbody is checked only when it runs, so the migration applies on any GoTrue. Whether a fresh seed runs before GoTrue creates the tables is unverified.- Self-host guard. The
to_regclasscheck exists only for a GoTrue older than the OAuth tables. It returnsNULL, and the route answersavailable: false. GoTrue v2.196.0 creates the tables even when[auth.oauth_server]is off. In that case the map is empty, notNULL. - One
jsonbvalue. A set-returning RPC loses rows pastmax_rows = 1000with no error (config.toml:18). On local PostgREST v12.2.3,get_signups_per_daywithp_days = 1500returned 1000 rows, HTTP 200 andContent-Range: 0-999/1500. A scalarjsonbRPC returnedContent-Range: 0-0/1. The productionmax_rowsvalue is unverified. - No
is_admin(auth.uid())gate. Underservice_role,auth.uid()is null andis_adminis false (local check).admin-stats.controller.ts:427-429records the same trap. - Grants. As
authenticatedand asanon, the call failed withpermission denied for function admin_get_user_connected_apps.scripts/check-grants.sqlreturned 0 rows. The29-lint-hardening.sql§5 sweep revokes only frompublic,anonandauthenticated, so theservice_rolegrant survives. Keep the name out of the §6user_facing_nameslist. - Verify production grants before the Supabase 2026-10-30 grant change #553 does not apply. That change covers new tables in
public, not functions.20261021120000_default_privileges_close_new_objects.sqlalready closes default functionEXECUTE. redirect_urisis one comma-joinedtextvalue, not an array (\d auth.oauth_clients). Upstream GoTrue splits it on,(oauth_client.go:123, not in this repo). The route splits it the same way.
Route contract
GET /api/admin/users/connected-apps, registered in admin.router.ts beside /users/notification-subs:
admin.get('/users/connected-apps', adminController.getUserConnectedApps)- Guard:
admin.use('*', adminAuthMiddleware)(admin.router.ts:32). It refuses a connected-app token with403(adminAuth.ts:29-31). No other route captures this path. The route takes no input, so it needs nozValidator. - Envelope: house
ok/failfromsrc/http/envelope.ts, likegetMcpUsage. - Controller:
getUserConnectedAppsinadmin.controller.ts, besidegetMcpUsage. It reusesUNKNOWN_APP(admin.controller.ts:182).
Response type, in apps/admin-dashboard/src/types/index.ts beside McpUsage (:542):
export interface UserConnectedApps {
available: boolean
users: Record<string, { name: string; origins: string[] }[]>
}For each grant, the controller:
- Cleans the name with a new helper
cleanClientNamebesideoriginOf. It copies thedisplayClientNamerule (apps/webapp/src/utils/displayClientName.ts:1-8): strip\p{Cc}and the bidi controls, trim, and cut at 80 characters with…. It has one deliberate difference: it returns''for an empty ornullname, not'Unnamed app'. Its comment names the twin file and that difference, asapps/hocuspocus.server/src/modules/document-versions/domain/blockAuthors.ts:6-7does. The server does not import fromapps/webapp. - Maps
''toUNKNOWN_APP('Unknown app'). The MCP page already uses it (admin.controller.ts:233,247). Settings says'Unnamed app'(displayClientName.ts:6), so the two surfaces differ for now. See Later. - Splits
redirectUrison,, trims each part, and turns it into origin text with a new helperoriginTextOf. Do not reuseoriginOfhere. For a custom scheme such ascursor://anysphere.cursor-mcp/oauth/callback,new URL(uri).originis the string"null", notnull, sooriginOfwould print(null).originTextOfcopies the rule inapps/webapp/src/utils/appTrust.ts:48-51:url.origin !== 'null' ? url.origin : \${url.protocol}//${url.host}``. It drops a part that does not parse. Then remove duplicates and sort. - Keeps one entry per cleaned name and origin set. The first one seen is the newest grant.
A failure never becomes an empty map, because an empty map paints "None" on every row. The service-role client has no Database type (createClient at apps/hocuspocus.server/src/lib/supabase.ts:50), so rpc() returns any. Check the shape at run time, and wrap the whole call in try/catch.
| Case | Answer |
|---|---|
getSupabaseClient() returns null |
fail(c, 500, 'CONNECTED_APPS_FAILED', 'Failed to read connected apps') |
RPC error: 42501, PGRST202 (not deployed), timeout |
Log with adminLogger.error, then the same fail |
The call throws, or the result is neither null nor an object whose values are arrays |
Log with adminLogger.error, then the same fail |
RPC returns null (tables missing) |
ok(c, { available: false, users: {} }) |
| RPC returns a map | ok(c, { available: true, users }) |
fetchApi throws on any non-2xx (apps/admin-dashboard/src/services/api.ts:83-95), so React Query sees isError.
UI change
apps/admin-dashboard/src/types/index.ts: addUserConnectedApps(above).apps/admin-dashboard/src/services/api.ts: addfetchUserConnectedApps(): Promise<UserConnectedApps>. Unwrap.dataasfetchMcpUsagedoes (api.ts:300-302).apps/admin-dashboard/src/pages/users.tsx:- Add a query with key
['admin', 'users', 'connected-apps']and the defaultstaleTime, like the other maps (users.tsx:63-76). The key has no page number, so a page turn does not refetch it. - Add the column after Notifications, with the five states above. Render the cell from the query result by
user.id, inside thecolumnsclosure, because the cell needs the list and the query state. - Make the header Refresh refetch this query too. Today
onRefreshrefetches only the users list (users.tsx:283). UseonRefresh={() => { refetch(); refetchConnectedApps() }}andrefreshing={isRefetching || connectedAppsRefetching}. - Add
connected_apps: stringtoUserWithExtras. Only the CSV reads it. - Add
{ key: 'connected_apps', header: 'Connected apps' }to the CSV columns, afterEmail Notif.
- Add a query with key
- CSV format. Each entry is the name plus its origins, for example
Claude (https://claude.ai, https://claude.com). Origins inside one entry are joined with", ". Entries are joined with"; ". A borrowed name then reads asClaude (https://evil.test), not as a secondClaude. With no grants, the field isNone. On an error with no earlier data, it isCould not load. Whenavailableis false, it isNot available. While the query is pending, it is an empty string, becausehandleExportdoes not wait for the side queries (users.tsx:107-126). - The CSV field must be a string.
exportToCSVneutralizes formulas and escapes quotes only for strings (apps/admin-dashboard/src/utils/export.ts:27-31). In a scratch run, an array cell kept=HYPERLINK(...)live and split the row. apps/hocuspocus.server/src/modules/openapi/domain/paths/admin.ts: add the path, idadminGetUserConnectedApps, groupDashboard & users, with a500CONNECTED_APPS_FAILEDresponse.apps/hocuspocus.server/API.md§Dashboard & users: add one row and one sentence onavailable.apps/hocuspocus.server/CHANGELOG.md§[Unreleased]: one entry for the new route and the new SQL function.
Performance
Every run was on the local database, inside BEGIN; ... ROLLBACK;, on 2026-10-23.
| Run | Data | Shape per grant | Time | RPC result size |
|---|---|---|---|---|
| Empty tables | 0 rows | flat rows | 0.033 ms | — |
| Realistic | 5,000 users, 200 clients, 10,000 consents, 20% revoked (8,035 live) | name, redirectUris, grantedAt |
Execution Time: 16.368 ms, call 18.130 ms |
1150 kB |
| Final shape, heavy | 23 users × 4,000 clients, two URIs per client (82,832 live) | name, redirectUris |
Execution Time: 108.970 ms, calls 118 to 137 ms |
9,815,952 B |
- Production scale. About 8 OAuth clients on 2026-10-20 (memory note, not re-read here).
- Locks. The read takes only
AccessShareLock, so it does not block GoTrue's consent writes or revokes.authenticatorhasstatement_timeout=8s. The server's Supabase deadline isSUPABASE_FETCH_TIMEOUT_MS = 10_000(apps/hocuspocus.server/src/lib/supabase.ts:9). - Requests. One more HTTP request per page load, in parallel with the four queries that the page already fires. It costs two PostgREST calls: the admin check and the RPC.
- Cadence.
_app.tsx:10-17setsstaleTime: 1000 * 60andrefetchOnWindowFocus: false, with norefetchInterval. The map refetches on mount once it is stale, on reconnect, and on Refresh. Nothing polls. - Size against the page today. The
notification-subsmap writes about 104 B for every user (local measure). The connected-apps map holds only connected users. - Not measured. The HTTP response size after grouping, and anything on production.
Conclusion. No new index, no cache and no paging. At 82,832 live grants, the RPC result is about 9.8 MB and the query takes about 109 ms. Payload size is the first limit to watch, long before query time.
Reliability notes
- Freshness. A revoke shows on the next fetch, or on Refresh after this change. An access token that the app already holds stays valid until it expires (lifetime unverified).
- The name is self-declared.
client_namecomes from Dynamic Client Registration, so anyone can call an app "Claude" (apps/webapp/src/utils/appTrust.ts:5-7). The origin in the tooltip, thesr-onlytext and the CSV is the trust fact. A rolled-back test had two clients namedClaude. One usedhttps://claude.ai/..., and the other usedhttps://evil.test/cb. Both came back, and the grouping rule shows them as two badges. Anullname also came back. - Name length.
client_namecan hold up to 1024 characters (oauth_clients_client_name_length). The 80-character cut andtruncatekeep the cell short. - Deploy order. Ruling 3 puts the migration first. If the route still ships before the migration, the RPC answers
PGRST202. The column then shows "Could not load", and nothing else breaks. - Tests. No committed test meets
AGENTS.md§Test Policy. The PR runs one rolled-back SQL probe instead (Acceptance criteria).
Rulings
All three were ruled on 2026-10-23.
Ruling 1. Column and CSV header. Ruled 2026-10-23: B, "Connected apps". The names in this issue already use it.
- A: "MCP", as asked. It matches "MCP Usage" in the admin nav (
apps/admin-dashboard/src/constants/navigation.ts:31). - B: "Connected apps". People see that name in Settings, and the column is the same list. B also renames the code to match:
public.admin_get_user_connected_apps(),/users/connected-appsand the CSV keyconnected_apps. - Recommendation: B. It keeps one name per thing. Ruled: B.
Ruling 2. Privacy and the <5 mask. Ruled 2026-10-23: B. The MCP page masks 1 to 4 callers as '<5', because a small count could point at one person (admin.controller.ts:216). This column names each person's apps. An admin can then tie a small "By app" count to one person. The privacy page says each tool call records the account id and the app (apps/webapp/src/pages/privacy.tsx:101). It does not say that admins can see connected apps.
- A: Ship. Change no policy text. Reword the
maskSmallCountcomment so it no longer promises that no per-person view exists. - B: As A, and add one line under "Connected AI apps" in
privacy.tsx: "Our admins can see which apps you connected." - C: Show only yes or no per user, with no app names. This drops most of the value.
- Recommendation: B. It is one sentence, and it keeps the policy accurate. It does not block the build. Ruled: B.
Ruling 3. Deploy order. Ruled 2026-10-23: A.
- A: Apply the migration first, then deploy back, then front. "Front" means both the admin dashboard and the webapp. The webapp, which holds the privacy sentence, ships no later than the admin dashboard.
- B: Ship everything together and accept a short "Could not load".
- Recommendation: A. The data then exists before the code that reads it. Ruled: A.
Preconditions
- A clean Supabase tree. Steps 3 and 4 regenerate
seed.sqlandapps/webapp/src/types/supabase.tsfrom the whole tree and the local database. Uncommitted work for another issue would then leak into this diff. On 2026-10-23, the tree held uncommitted Member faces and roster miss documents whose workspace slug is the human slug #574 work inseed.sql,supabase.ts,scripts/10-8-func-workspace_members.sqlandmigrations/20261023120000_key_member_rpcs_on_document_id.sql. Start only after that work is committed, or after its owner sets it aside. - Migration name. 14 digits plus
_snake_name.sql. The timestamp must sort after the newest file inpackages/supabase/migrations/, for example20261023130000_admin_user_connected_apps.sqlif20261023120000is still the newest.
Implementation steps
Steps marked [maintainer] need production access. An implementer does every other step locally.
- [maintainer] A repo comment says the opposite of this design: "the auth schema is inaccessible to PostgREST / standard RPC calls" (
packages/supabase/scripts/28-ghost-accounts-audit.sql:6-8). Locally, a definer function owned bypostgresreads the rows. Before the migration goes up, run this read-only check in Dashboard > SQL Editor:Both privilege columns must beselect current_user, has_table_privilege('postgres','auth.oauth_consents','select') as consents, has_table_privilege('postgres','auth.oauth_clients','select') as clients;
true. If either isfalse, stop and redesign. Never grant onauth.*from the repo. - Add
packages/supabase/scripts/33-admin-connected-apps.sqland a paired migration with the same body. - Regenerate the seed:
bun run --filter @docs.plus/supabase_back seed. Apply the migration locally without a reset:docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<file>.sql. - Regenerate types:
bun run --filter @docs.plus/supabase_back types. Then run the gates:bun run --filter @docs.plus/supabase_back check. - Add the route, the controller (with
cleanClientNameandoriginTextOf), the OpenAPI entry, theAPI.mdrow and the back-end CHANGELOG entry. - Add the type, the fetch helper, the query, the column, the Refresh change and the CSV field.
- Apply Ruling 2:
- Reword the
maskSmallCountcomment (admin.controller.ts:216) to:// The usage chart hides counts of 1 to 4, so it cannot point at one person. The Users page names each person's connected apps on purpose. - In
apps/webapp/src/pages/privacy.tsx, add a new<p>right after the</ul>of "Connected AI apps" (:104). It goes before the "To stop an app" paragraph. Its text: "Our admins can see which apps you connected." - Set
LEGAL_UPDATEDinapps/webapp/src/components/pages/legal/legalMetadata.ts(now'12 October 2026') to the ship date. It is shared with/terms. - Add an
apps/webapp/CHANGELOG.md§[Unreleased]entry for the privacy change. Commit18c8e19f5is the precedent for both of these lines.
- Reword the
- Run only the gates for touched files:
bun run --filter @docs.plus/supabase_back checkbun run --filter @docs.plus/hocuspocus typecheckbun test specCoverageinapps/hocuspocus.server, so OpenAPI operation ids stay unique. Its route-coverage test does not cover the admin router, so check the OpenAPI entry by hand.bun run --filter @docs.plus/admin-dashboard typecheckbun run --filter @docs.plus/webapp typecheck- eslint and prettier on the touched files, including the
API.mdtable alignment.
- [maintainer] Push the migration to production. Follow
packages/supabase/CLAUDE.md§Remotedb push: runbunx supabase migration listfirst, and set[db.migrations] enabledtotrueonly while pushing. If another unpushed migration (such as Member faces and roster miss documents whose workspace slug is the human slug #574's) is in the list, decide first whether it ships in the same push. - [maintainer] Run
packages/supabase/scripts/check-grants.sqlread-only against production (Dashboard SQL Editor, orpsqlwithPGOPTIONS='-c default_transaction_read_only=on'). Zero rows means pass. - [maintainer] Deploy in the order of Ruling 3: the back end, then the webapp and the admin dashboard.
Do not run reset for this change. It wipes the shared local state of other worktrees (packages/supabase/CLAUDE.md), and the function has no cross-file SQL dependency.
Acceptance criteria
[maintainer], on production:
- Step 1 returned
truefor both tables. - The migration reached production before the back-end deploy (Ruling 3).
- After the migration, this query returns
postgres:select pg_get_userbyid(proowner) from pg_proc where proname = 'admin_get_user_connected_apps'; -
check-grants.sqlreturned zero rows after the push (step 10). - The privacy sentence went live no later than the column.
Implementer, locally:
-
public.admin_get_user_connected_apps()exists in the script, the migration,seed.sqlandapps/webapp/src/types/supabase.ts. The gates in step 8 pass. -
service_rolecan call the function.authenticatedandanongetpermission denied for function admin_get_user_connected_apps. - A rolled-back SQL probe seeds six cases. They are a revoked consent, a soft-deleted client, a
nullname, two "Claude" clients with different origins, a custom-scheme redirect URI, and a user with 3 apps. The result leaves out the revoked and deleted grants. - Browser data that stays: insert rows into
auth.oauth_clientsandauth.oauth_consentsfor a local test user. Required client columns areid,registration_type,redirect_uris,grant_typesandtoken_endpoint_auth_method. Use the six cases above, plus one client named=HYPERLINK("x"). When done, delete those clients. The cascade removes their consents. - With that data, the two "Claude" grants show as two badges. The custom-scheme client shows an origin like
cursor://anysphere.cursor-mcp, nevernull. Each badge has its origins assr-onlytext. A long name ends in an ellipsis. - In a browser, the column shows the Loading, Error, None and Apps states. For Error, block
/api/admin/users/connected-appswith DevTools request blocking. - Refresh refetches the column, and the refresh spinner covers it.
- The CSV
connected_appsfield reads likeClaude (https://claude.ai); Claude (https://evil.test)for a user who holds both probe clients. The=HYPERLINK("x")name comes out with a leading'. -
GET /api/admin/users/connected-appsanswers the house envelope. The connected-app refusal comes fromadmin.use('*', adminAuthMiddleware)(admin.router.ts:32), so confirm in review that the route is registered on that router. A live403check needs a local connected-app token (docs/mcp/README.md, "Connect in Claude Code"), and it is optional. - The
maskSmallCountcomment, the privacy sentence,LEGAL_UPDATEDand both CHANGELOG entries match step 5 and step 7.
Files to touch
packages/supabase/scripts/33-admin-connected-apps.sql(new)packages/supabase/migrations/<timestamp>_admin_user_connected_apps.sql(new)packages/supabase/seed.sql(regenerated, never hand-edited)apps/webapp/src/types/supabase.ts(regenerated, never hand-edited)apps/hocuspocus.server/src/api/routers/admin.router.tsapps/hocuspocus.server/src/api/controllers/admin.controller.ts(also themaskSmallCountcomment, Ruling 2)apps/hocuspocus.server/src/modules/openapi/domain/paths/admin.tsapps/hocuspocus.server/API.mdapps/hocuspocus.server/CHANGELOG.mdapps/admin-dashboard/src/types/index.tsapps/admin-dashboard/src/services/api.tsapps/admin-dashboard/src/pages/users.tsxapps/webapp/src/pages/privacy.tsx(Ruling 2)apps/webapp/src/components/pages/legal/legalMetadata.ts(Ruling 2)apps/webapp/CHANGELOG.md(Ruling 2)
Later
- A "last used" time per app from
auth.sessions. It is usage data, so it needs its own privacy ruling (apps/webapp/CLAUDE.md§Settings Takeover). - A count per badge ("Claude ×3"), and a "+N" overflow after two badges.
- Filter or sort the Users table by connected app. This needs a server-side join, because the table pages on the server.
- Scope the map to the visible page's user ids if the payload grows past a few MB.
- Use one name for a nameless app on every surface:
'Unknown app'on the admin pages,'Unnamed app'in Settings. - Fix the existing silent errors on the Users page (
users.tsx:97-101). A failed document-counts fetch shows 0 docs. A failed notification-subs fetch shows every channel off. - Clean the raw
client_nameon the MCP page (apps/admin-dashboard/src/pages/mcp.tsx:77-83) with the same helper. - Use
originTextOffor the MCP page'sregisteredAppstoo. It still callsoriginOf, so a custom-scheme client shows the origin"null"there. - Revoke OAuth consents in
softDeleteUsers, so an erased account keeps no live grants. - Revoke one person's grant from the admin dashboard. GoTrue's admin API has no endpoint for it today.
Activity
HMarzban commentedon Oct 23, 2026
Ruling 1: B. The column and the CSV header are "Connected apps". The code uses the same name:
public.admin_get_user_connected_apps(),GET /api/admin/users/connected-apps,getUserConnectedApps,fetchUserConnectedApps, the CSV keyconnected_appsand the error codeCONNECTED_APPS_FAILED. The issue body is updated to match.Rulings 2 and 3 are still open.
HMarzban commentedon Oct 23, 2026
Ruling 2: B. Reword the
maskSmallCountcomment inadmin.controller.ts, so it no longer says that no per-person view exists. Add one sentence to the "Connected AI apps" section of the privacy page: "Our admins can see which apps you connected." The sentence goes live no later than the column.Ruling 3: A. Apply the migration on production first, then deploy the back end, then the front end.
All three rulings are now made. The issue body is updated with the exact comment text, a new step 8 and the matching acceptance criteria. Step 1, the read-only privilege check on hosted Supabase, is still open before the migration.
HMarzban commentedon Oct 23, 2026
A cold-read review dry-ran this issue with no other context. It found no blocker. It also created the SQL function in a rolled-back local test, and the Supabase gates passed. The body is updated with these fixes:
cursor://…,originOfreturns the string"null". A neworiginTextOfcopies the rule inappTrust.ts:48-51. A Later item covers the same bug on the MCP page.text-[var(--error-ink)], nottext-error. The loading bone ish-5, the height of abadge-sm.LEGAL_UPDATEDand a webapp CHANGELOG entry for the privacy line, and a back-end CHANGELOG entry. The type goes inadmin-dashboard/src/types/index.ts.seed.sqlandsupabase.tschanges stay out of this diff. There is also a rule for the migration name.db pushprocedure and the post-pushcheck-grants.sqlrun. Every production step is now marked [maintainer].31-connected-app-token-gate.sql:15(was:14), and auth-js2.118.0(was2.116.0).