diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a192aae..85ad3bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +3.0.29 || 12.08.2026 +fix(api): /v3/groups/manages parses list-format roles + cleanup stale app contracts + gate contract endpoints — `get_groups_manages` expected roles as a dict but all groups store roles as a list. Now parses both formats. Added `/v3/app-contracts/cleanup` to tombstone stale contracts where `allowed_origin` is a service name. Gated `/v3/app-contracts/add` and `/revoke` to authenticator origins only (CORS_SERVICE_MANAGERS) — apps cannot directly create or revoke contracts, they must go through the popup consent flow. + 3.0.28 || 12.08.2026 fix(api): deduplicate group_contracts in get_user_groups and get_groups_manages — ReplacingMergeTree background merges may not have run, causing duplicate groups and duplicate memberships in the authenticator UI. Both queries now use window functions to deduplicate group_members AND group_contracts by latest updated_at, with a Python set safety net. Also fixed member_count subqueries to deduplicate. diff --git a/api/app/v3/endpoints/contracts.py b/api/app/v3/endpoints/contracts.py index 12322824..fac7371a 100644 --- a/api/app/v3/endpoints/contracts.py +++ b/api/app/v3/endpoints/contracts.py @@ -1,5 +1,6 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, Request +from app import settings from app.v3.endpoints.auth_helper import user as _user from app.v3.models import AddAppContract, RevokeAppContract from app.v3.models.common import TokenOnly @@ -8,9 +9,30 @@ router = APIRouter(tags=["app-contracts"]) +def _is_authenticator_origin(request: Request) -> bool: + """Check if the request comes from an authenticator host.""" + origin = request.headers.get("origin", "") + if not origin: + return True # same-origin or direct API call — allow + try: + host = origin.replace("https://", "").replace("http://", "").split("/")[0].split(":")[0] + except Exception: + return False + return host in settings.CORS_SERVICE_MANAGERS + + @router.post("/add") -async def add_app_contract(data: AddAppContract): - """Add an app contract (one per app, permissions is JSON).""" +async def add_app_contract(request: Request, data: AddAppContract): + """Add an app contract (one per app, permissions is JSON). + + Only callable from an authenticator origin — apps must go through + the popup consent flow, they cannot create contracts directly. + """ + if not _is_authenticator_origin(request): + raise HTTPException( + status_code=403, + detail="App contract creation is only allowed from the authenticator. Apps must go through the consent popup.", + ) user = _user(data) result = ch.add_app_contract(user, data.allowed_origin, data.permissions) return result @@ -24,11 +46,36 @@ async def get_app_contracts(data: TokenOnly): @router.post("/revoke") -async def revoke_app_contract(data: RevokeAppContract): - """Revoke one app contract (by origin) or all.""" +async def revoke_app_contract(request: Request, data: RevokeAppContract): + """Revoke one app contract (by origin) or all. + + Only callable from an authenticator origin — apps cannot revoke + contracts directly. + """ + if not _is_authenticator_origin(request): + raise HTTPException( + status_code=403, + detail="App contract revocation is only allowed from the authenticator.", + ) user = _user(data) if data.allowed_origin: ch.revoke_app_contract(user, data.allowed_origin) else: ch.revoke_all_app_contracts(user) return {"status": "revoked"} + + +@router.post("/cleanup") +async def cleanup_stale_contracts(request: Request, data: TokenOnly): + """Tombstone stale app contracts where allowed_origin is not a URL. + + Only callable from an authenticator origin. + """ + if not _is_authenticator_origin(request): + raise HTTPException( + status_code=403, + detail="Contract cleanup is only allowed from the authenticator.", + ) + user = _user(data) + count = ch.cleanup_stale_app_contracts(user) + return {"status": "cleaned", "revoked": count} diff --git a/api/app/v3/services/clickhouse.py b/api/app/v3/services/clickhouse.py index 69b15c47..edeac79a 100644 --- a/api/app/v3/services/clickhouse.py +++ b/api/app/v3/services/clickhouse.py @@ -556,6 +556,23 @@ def revoke_all_app_contracts(user_key: str): ) +def cleanup_stale_app_contracts(user_key: str) -> int: + """Tombstone app contracts where allowed_origin is not a URL (stale service-name entries). + + Legitimate contracts always have allowed_origin starting with http:// or https://. + Returns the number of contracts tombstoned. + """ + result = client.command( + "INSERT INTO app_contracts (user_key, allowed_origin, permissions, created_at, updated_at, deleted) " + "SELECT user_key, allowed_origin, permissions, created_at, now(), 1 " + "FROM app_contracts " + "WHERE user_key = %(user_key)s AND deleted = 0 " + "AND allowed_origin NOT LIKE 'http://%%' AND allowed_origin NOT LIKE 'https://%%'", + {"user_key": user_key}, + ) + return result.written_rows if hasattr(result, "written_rows") else 0 + + # --------------------------------------------------------------------------- # Blacklists # --------------------------------------------------------------------------- @@ -796,8 +813,12 @@ def get_groups_manages(member_key: str) -> list[dict]: continue seen.add(group_id) # Check if the user's role has manageRoles permission - roles = _parse_json(roles_json) if roles_json else {} - role_def = roles.get(my_role, {}) + # roles_json is a list of {name, services, permissions} — find matching role + roles_list = _parse_json(roles_json) if roles_json else [] + if isinstance(roles_list, list): + role_def = next((r for r in roles_list if r.get("name") == my_role), {}) + else: + role_def = roles_list.get(my_role, {}) if isinstance(role_def, dict) and "manageRoles" in role_def.get("permissions", []): out.append( { diff --git a/api/tests/test_v3_clickhouse.py b/api/tests/test_v3_clickhouse.py index 5d9cabae..2f9717a0 100644 --- a/api/tests/test_v3_clickhouse.py +++ b/api/tests/test_v3_clickhouse.py @@ -564,7 +564,15 @@ class TestGetGroupsManages: def test_has_manage(self): with _patch_client() as mock_client: mock_client.query.return_value = _mock_result_rows( - [("g1", "open", '{"admin": {"permissions": ["manageRoles"]}}', "admin", 5)] + [ + ( + "g1", + "open", + '[{"name": "admin", "services": ["*"], "permissions": ["readAll", "manageRoles"]}]', + "admin", + 5, + ) + ] ) groups = ch.get_groups_manages("alice") assert len(groups) == 1 @@ -573,7 +581,7 @@ def test_has_manage(self): def test_no_manage(self): with _patch_client() as mock_client: mock_client.query.return_value = _mock_result_rows( - [("g1", "open", '{"admin": {"permissions": ["readAll"]}}', "admin", 5)] + [("g1", "open", '[{"name": "admin", "services": ["*"], "permissions": ["readAll"]}]', "admin", 5)] ) groups = ch.get_groups_manages("alice") assert len(groups) == 0 @@ -584,6 +592,16 @@ def test_empty(self): groups = ch.get_groups_manages("alice") assert len(groups) == 0 + def test_legacy_dict_format(self): + """Backward compat: old dict-style roles still work.""" + with _patch_client() as mock_client: + mock_client.query.return_value = _mock_result_rows( + [("g1", "open", '{"admin": {"permissions": ["manageRoles"]}}', "admin", 5)] + ) + groups = ch.get_groups_manages("alice") + assert len(groups) == 1 + assert groups[0]["group_id"] == "g1" + # --------------------------------------------------------------------------- # Provider service contracts diff --git a/api/tests/test_v3_endpoints.py b/api/tests/test_v3_endpoints.py index eb14f616..d81fe812 100644 --- a/api/tests/test_v3_endpoints.py +++ b/api/tests/test_v3_endpoints.py @@ -513,6 +513,7 @@ def test_add(self, client, token): "allowed_origin": "myapp.com", "permissions": {"posts": ["readAll", "create"], "playlists": ["readAll"]}, }, + headers={"Origin": "https://auth.localhost"}, ) assert resp.status_code == 200 assert resp.json()["allowed_origin"] == "myapp.com" @@ -525,9 +526,22 @@ def test_add_missing_permissions(self, client, token): "token": token, "allowed_origin": "myapp.com", }, + headers={"Origin": "https://auth.localhost"}, ) assert resp.status_code == 422 + def test_add_rejected_non_authenticator(self, client, token): + resp = client.post( + "/v3/app-contracts/add", + json={ + "token": token, + "allowed_origin": "myapp.com", + "permissions": {"posts": ["readAll"]}, + }, + headers={"Origin": "https://malicious.example.com"}, + ) + assert resp.status_code == 403 + def test_list(self, client, token): mock_rows = [("myapp.com", '{"posts": ["readAll"]}')] with patch("app.v3.services.clickhouse.client") as mock_ch: @@ -541,12 +555,17 @@ def test_revoke_by_origin(self, client, token): resp = client.post( "/v3/app-contracts/revoke", json={"token": token, "allowed_origin": "myapp.com"}, + headers={"Origin": "https://auth.localhost"}, ) assert resp.status_code == 200 assert resp.json()["status"] == "revoked" def test_revoke_all(self, client, token): - resp = client.post("/v3/app-contracts/revoke", json={"token": token}) + resp = client.post( + "/v3/app-contracts/revoke", + json={"token": token}, + headers={"Origin": "https://auth.localhost"}, + ) assert resp.status_code == 200 assert resp.json()["status"] == "revoked" @@ -642,7 +661,15 @@ def test_read_by_id_not_found(self, client, token): class TestGroupsManages: def test_manages(self, client, token): - mock_rows = [("g1", "open", '{"admin": {"permissions": ["manageRoles"]}}', "admin", 5)] + mock_rows = [ + ( + "g1", + "open", + '[{"name": "admin", "services": ["*"], "permissions": ["readAll", "manageRoles"]}]', + "admin", + 5, + ) + ] with patch("app.v3.services.clickhouse.client") as mock_ch: mock_ch.query.return_value = MagicMock(result_rows=mock_rows) resp = client.post("/v3/groups/manages", json={"token": token}) diff --git a/marketing/marketing-ui/public/docs/messages/script.js b/marketing/marketing-ui/public/docs/messages/script.js index cfca40c7..5c5798e6 100644 --- a/marketing/marketing-ui/public/docs/messages/script.js +++ b/marketing/marketing-ui/public/docs/messages/script.js @@ -31,26 +31,6 @@ async function v3Post(action, params = {}) { return res.json() } -// v3: add an app contract so this origin can read/write the service -async function ensureAppContract() { - const origin = window.location.origin - try { - const token = document.cookie.match(/token=([^;]+)/)?.[1] - if (!token) return - await fetch(`${API_ORIGIN}/v3/app-contracts/add`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - token, - allowed_origin: origin, - permissions: { [SERVICE]: ['readAll', 'create', 'updateOwn', 'deleteOwn'] }, - }), - }) - } catch { - // Contract might already exist — not an error - } -} - // v3: ensure a DM group exists between two users, create if not // Both users are admins (owner role) of the group async function ensureDmGroup(myUsername, theirUsername, provider) { @@ -106,8 +86,8 @@ function initApp() { toUsername.value = t.username toProvider.value = t.provider - // v3: ensure the app contract exists, then load messages - ensureAppContract().then(() => readMessages()).catch(() => readMessages()) + // v3: load messages + readMessages() } // Self-register in the app store (no auth required) diff --git a/marketing/marketing-ui/public/docs/notes/script.js b/marketing/marketing-ui/public/docs/notes/script.js index 79ebf73f..897a14ed 100644 --- a/marketing/marketing-ui/public/docs/notes/script.js +++ b/marketing/marketing-ui/public/docs/notes/script.js @@ -34,26 +34,6 @@ async function v3Post(action, params = {}) { return res.json() } -// v3: add an app contract so this origin can read/write the service -async function ensureAppContract() { - const origin = window.location.origin - try { - const token = document.cookie.match(/token=([^;]+)/)?.[1] - if (!token) return - await fetch(`${API_ORIGIN}/v3/app-contracts/add`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - token, - allowed_origin: origin, - permissions: { [SERVICE]: ['readAll', 'create', 'updateOwn', 'deleteOwn'] }, - }), - }) - } catch { - // Contract might already exist — not an error - } -} - // v3: ensure a personal notes group exists, create if not async function ensureNotesGroup(username, provider) { const groupName = `notes-${username}` @@ -105,9 +85,8 @@ function initApp() { message.innerHTML = `Signed in as ${t["provider"]}/${t["username"]}` editor.style.display = "block" - // v3: ensure the app contract and personal notes group exist, then load notes - ensureAppContract() - .then(() => ensureNotesGroup(t.username, t.provider)) + // v3: ensure the personal notes group exists, then load notes + ensureNotesGroup(t.username, t.provider) .then(() => readNotes()) .catch(() => readNotes()) } diff --git a/marketing/marketing-ui/public/docs/tasks/script.js b/marketing/marketing-ui/public/docs/tasks/script.js index 363cf983..8e0f3d86 100644 --- a/marketing/marketing-ui/public/docs/tasks/script.js +++ b/marketing/marketing-ui/public/docs/tasks/script.js @@ -34,25 +34,6 @@ async function v3Post(action, params = {}) { return res.json() } -async function ensureAppContract() { - const origin = window.location.origin - try { - const token = document.cookie.match(/token=([^;]+)/)?.[1] - if (!token) return - await fetch(`${API_ORIGIN}/v3/app-contracts/add`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - token, - allowed_origin: origin, - permissions: { [SERVICE]: ['readAll', 'create', 'updateOwn', 'deleteOwn'] }, - }), - }) - } catch { - // Contract might already exist - } -} - let currentGroupId = null authButton.onclick = () => window.web10.openAuthPortal(AUTH_ORIGIN) @@ -68,7 +49,7 @@ function initApp() { message.innerHTML = `Signed in as ${t["provider"]}/${t["username"]}` app.style.display = "block" - ensureAppContract().then(() => readTasks()).catch(() => readTasks()) + readTasks() } // Self-register in the app store diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 9cdd1d00..43b15555 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -17,6 +17,9 @@ export type { V3InviteResponse, V3JoinRequest, V3ServiceContract, + V3ACR, + V3GCR, + V3ContractRequest, V3User, V3LoginResponse, } from './v3' diff --git a/sdk/src/v3.ts b/sdk/src/v3.ts index 2143c199..35fcd259 100644 --- a/sdk/src/v3.ts +++ b/sdk/src/v3.ts @@ -103,6 +103,25 @@ export interface V3ServiceContract { permissions: Record } +// Contract request types — sent together in one postMessage to the authenticator +export interface V3ACR { + /** Website origin requesting access */ + allowed_origin: string + /** Per-service permissions */ + permissions: Record +} + +export interface V3GCR { + /** Website origin requesting the group operation */ + app_origin: string + /** Group operation: create_group, join_group, invite_member, etc. */ + action: string + /** Parameters for the operation */ + params: Record +} + +export type V3ContractRequest = V3ACR | V3GCR + export interface V3User { username: string phone?: string @@ -504,6 +523,33 @@ export function createV3Client(options: V3ClientOptions = {}): V3Client { async getAppRatings(appId: string): Promise<{ author: string; rating: number; provider: string; created_at: string }[]> { return v3Post<{ author: string; rating: number; provider: string; created_at: string }[]>('apps/ratings', { body: { target_app_id: appId } }) }, + + // ── Contract requests (ACR + GCR together) ───────────────────────────── + + contractOnReady( + contracts: V3ContractRequest[], + callback?: (response: { status: string; errors?: string[] }) => void, + ): void { + if (typeof window === 'undefined' || !window.opener) { + if (callback) callback({ status: 'error', errors: ['No opener window — not in a popup'] }) + return + } + // Listen for the response from the authenticator + if (callback) { + const handler = (e: MessageEvent) => { + if (e.data?.type === 'contract_response') { + window.removeEventListener('message', handler) + callback(e.data) + } + } + window.addEventListener('message', handler) + } + // Send contracts to the opener (authenticator) + window.opener.postMessage( + { type: 'contract', contracts }, + '*', + ) + }, } return client @@ -546,6 +592,9 @@ export interface V3Client { listAppContracts(): Promise revokeAppContract(allowedOrigin?: string): Promise<{ status: string }> + // Contract requests — sends ACR + GCR together to the authenticator via postMessage + contractOnReady(contracts: V3ContractRequest[], callback?: (response: { status: string; errors?: string[] }) => void): void + // Groups createGroup(name: string, joinPolicy: string, roles: Record[], members: { member_key: string; role?: string }[]): Promise<{ group_id: string }> getGroup(groupId: string): Promise @@ -593,4 +642,7 @@ export interface V3Client { getApps(): Promise<{ url: string; name: string; description: string; icon_url: string; screenshots: unknown[]; review_state: string; metadata_version: number }[]> rateApp(appId: string, rating: number): Promise<{ author: string; target_app_id: string; rating: number }> getAppRatings(appId: string): Promise<{ author: string; rating: number; provider: string; created_at: string }[]> + + // Contract requests — sends ACR + GCR together to the authenticator via postMessage + contractOnReady(contracts: V3ContractRequest[], callback?: (response: { status: string; errors?: string[] }) => void): void } \ No newline at end of file diff --git a/ui/src/components/Consent/ConsentView.tsx b/ui/src/components/Consent/ConsentView.tsx index 6a2ee6fa..793e754f 100644 --- a/ui/src/components/Consent/ConsentView.tsx +++ b/ui/src/components/Consent/ConsentView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Globe, ShieldCheck, Check, X, ChevronDown, ChevronRight, ArrowRight, Plus, Minus } from 'lucide-react'; +import { Globe, ShieldCheck, Check, X, ChevronDown, ChevronRight, ArrowRight, Plus, Minus, Users } from 'lucide-react'; import Branding from '../shared/Branding'; import LoginForm from '../CredentialPage/LoginForm'; import SignupForm from '../CredentialPage/SignupForm'; @@ -46,7 +46,7 @@ function Chip({ tone = 'default', children }: { tone?: 'default' | 'add' | 'remo ); } -// Derive a readable origin label from the ACR's allowed_origin. +// Derive a readable origin label function originLabel(origin: string): string { try { return new URL(`https://${origin}`).hostname; } catch { return origin; } } @@ -61,27 +61,39 @@ function summarizeACR(acr: any): string { return `${verbs} on ${services.join(', ')}`; } +// Build a one-line summary of what a GCR requests. +function summarizeGCR(gcr: any): string { + const action = gcr.action || 'group operation'; + const params = gcr.params || {}; + const name = params.name || ''; + return `${action}${name ? ` "${name}"` : ''}`; +} + function RequestRow({ - acr, + contract, current, onApprove, onDeny, idx, }: { - acr: any; + contract: any; current: any | undefined; onApprove: () => void; onDeny: () => void; idx: number; }) { const [open, setOpen] = React.useState(false); - const origin = acr.allowed_origin; - const perms = acr.permissions || {}; + const isACR = contract.kind === 'acr'; + const isGCR = contract.kind === 'gcr'; + const origin = isACR ? contract.allowed_origin : contract.app_origin; + const perms = isACR ? (contract.permissions || {}) : {}; const services = Object.keys(perms); + const action = isGCR ? (contract.action || '') : ''; + const params = isGCR ? (contract.params || {}) : {}; - // Diff permissions against existing contract for each service + // Diff permissions against existing contract for each service (ACR only) const permDiffs: Record = {}; - if (current) { + if (isACR && current) { const currentPerms = current.permissions || {}; for (const svc of services) { const currentOps = currentPerms[svc] || []; @@ -107,10 +119,12 @@ function RequestRow({ {originLabel(origin)} - access request + {isACR ? 'access request' : 'group request'} - {summarizeACR(acr)} + + {isACR ? summarizeACR(contract) : summarizeGCR(contract)} +
@@ -137,7 +151,7 @@ function RequestRow({ {origin} - {services.map((svc) => ( + {isACR && services.map((svc) => ( {current && permDiffs[svc] ? ( <> @@ -161,6 +175,24 @@ function RequestRow({ )} ))} + + {isGCR && ( + <> + + {action} + + {params.name && ( + + {params.name} + + )} + {params.join_policy && ( + + {params.join_policy} + + )} + + )}
)} @@ -179,11 +211,15 @@ function ConsentView({ I }: { I: Record }) { const authed = I.isAuthenticated?.(); const v3Contracts: any[] = I.v3Contracts || []; const grantedOrigins = new Set(v3Contracts.map((c: any) => c.allowed_origin)); - const pendingACRs: any[] = I.pendingACRs || []; + // Unified pending list — ACRs and GCRs together + const pendingContracts: any[] = I.pendingContracts || []; - // Filter out ACRs whose origin already has a contract. - const alreadyGrantedACRs = pendingACRs.filter((acr: any) => grantedOrigins.has(acr.allowed_origin)); - const displayACRs = pendingACRs.filter((acr: any) => !grantedOrigins.has(acr.allowed_origin)); + // Filter out ACRs whose origin already has a contract (GCRs always show) + const alreadyGrantedACRs = pendingContracts.filter((c: any) => c.kind === 'acr' && grantedOrigins.has(c.allowed_origin)); + const displayContracts = pendingContracts.filter((c: any) => { + if (c.kind === 'acr') return !grantedOrigins.has(c.allowed_origin); + return true; // GCRs always display + }); const username = I.wapi?.readToken?.()?.username as string | undefined; return ( @@ -228,7 +264,7 @@ function ConsentView({ I }: { I: Record }) { )} - ) : displayACRs.length === 0 ? ( + ) : displayContracts.length === 0 ? (
@@ -256,20 +292,20 @@ function ConsentView({ I }: { I: Record }) { Connect {host}

- Requesting {displayACRs.length} access {displayACRs.length === 1 ? 'request' : 'requests'}. Tap any to see details. + Requesting {displayContracts.length} access {displayContracts.length === 1 ? 'request' : 'requests'}. Tap any to see details.

{/* request list — scrolls internally so the actions stay visible */}
- {displayACRs.map((acr: any, idx: number) => ( + {displayContracts.map((contract: any, idx: number) => ( c.allowed_origin === acr.allowed_origin)} - onApprove={() => I.approveACR(acr)} - onDeny={() => I.denyACR(acr)} + current={contract.kind === 'acr' ? v3Contracts.find((c: any) => c.allowed_origin === contract.allowed_origin) : undefined} + onApprove={() => I.approveContract(contract)} + onDeny={() => I.denyContract(contract)} /> ))}
diff --git a/ui/src/interfaces/Interface.tsx b/ui/src/interfaces/Interface.tsx index ba946ce1..e8a4a460 100644 --- a/ui/src/interfaces/Interface.tsx +++ b/ui/src/interfaces/Interface.tsx @@ -88,10 +88,11 @@ function useInterface() { [I.isAdmin, I.setIsAdmin] = React.useState(false); [I.verified, I.setVerified] = React.useState(false); [I.status, I.setStatus] = React.useState(null); - // Pending contract requests (ACRs). Unified list — no distinction - // between "new" and "change". Each ACR is { allowed_origin, permissions }. - // contractListen delivers { contracts } which we normalize here. - [I.pendingACRs, I.setPendingACRs] = React.useState([]); + // Pending contract requests — unified list of ACRs and GCRs. + // One popup, one consent screen, both types together. + // ACR: { allowed_origin, permissions } — app contract (what can this app do) + // GCR: { app_origin, action, params } — group contract (who can see my content) + [I.pendingContracts, I.setPendingContracts] = React.useState([]); // v3 service contracts (ClickHouse-backed — simpler model: origin + service) [I.v3Contracts, I.setV3Contracts] = React.useState([]); @@ -104,28 +105,37 @@ function useInterface() { I.v3 = v3; - // Normalize contract requests into a unified ACR list. - // contractListen delivers { contracts } where each is { allowed_origin, permissions } (ACR) - // or { app_origin, action, params } (GCR). We keep ACRs as-is and skip GCRs - // (those go to the group requests flow). - function normalizeContractsToACRs(cData: any): any[] { - const acrs: any[] = []; + // Normalize contract requests into a unified list (ACR + GCR). + // contractListen delivers { contracts } where each is either: + // ACR: { allowed_origin, permissions } + // GCR: { app_origin, action, params } + function normalizeContracts(cData: any): any[] { + const contracts: any[] = []; // Handle new contractListen format: { contracts: [...] } if (Array.isArray(cData?.contracts)) { for (const cr of cData.contracts) { if (cr.allowed_origin && cr.permissions) { // ACR — app contract - acrs.push({ + contracts.push({ + kind: 'acr', allowed_origin: cr.allowed_origin, permissions: cr.permissions, _source: cr, }); + } else if (cr.app_origin && cr.action) { + // GCR — group contract + contracts.push({ + kind: 'gcr', + app_origin: cr.app_origin, + action: cr.action, + params: cr.params, + _source: cr, + }); } - // GCR — group contract, handled by group_requests flow } - return acrs; + return contracts; } - // Fallback: legacy SMR { sirs, scrs } format + // Fallback: legacy SMR { sirs, scrs } format (ACR only) const allRequests = [ ...(Array.isArray(cData?.sirs) ? cData.sirs : []), ...(Array.isArray(cData?.scrs) ? cData.scrs : []), @@ -137,18 +147,18 @@ function useInterface() { continue; } const perms = whitelistToPermissions(req.whitelist || []); - // Default to readAll if no permissions derived if (perms.length === 0) perms.push('readAll'); const permissions: Record = { [req.service]: perms }; for (const origin of origins) { - acrs.push({ + contracts.push({ + kind: 'acr', allowed_origin: origin, permissions, - _source: req, // keep reference to the original SIR/SCR for removal + _source: req, }); } } - return acrs; + return contracts; } // v3 contract listening — direct postMessage, no wapiAuth wrapper @@ -157,11 +167,11 @@ function useInterface() { const authOrigin = window.location.origin; window.addEventListener('message', (e) => { if (e.origin !== authOrigin) return; - if (e.data?.type === 'acr') { - I.setPendingACRs(normalizeContractsToACRs(e.data)); + if (e.data?.type === 'acr' || e.data?.type === 'contract') { + I.setPendingContracts(normalizeContracts(e.data)); } }); - // Request ACRs from opener + // Request contracts from opener if (window.opener) { window.opener.postMessage({ type: 'ACRListen' }, authOrigin); } @@ -530,63 +540,115 @@ function useInterface() { }); } - // Approve a single ACR (one origin). No distinction between "new" and - // "change" — both replace the existing contract for that origin. - I.approveACR = function (acr: any) { - const origin = acr.allowed_origin; - const label = (() => { - try { return new URL(`https://${origin}`).hostname; } catch { return origin; } - })(); + // Approve a GCR — create the group from the authenticator (the trusted party). + function applyGCR(gcr: any) { + const params = gcr.params || {}; + const decoded = I.wapi?.readToken?.(); + const username = decoded?.username || decoded?.sub || ''; + const provider = decoded?.provider || ''; + + // Build group ID from params + const name = params.name || `group-${Date.now()}`; + const groupId = `${provider}/groups/users/${username}/${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + + const roles = params.roles || [ + { name: 'owner', services: ['*'], permissions: ['readAll', 'create', 'updateOwn', 'updateAll', 'deleteOwn', 'deleteAll', 'hideAll', 'manageRoles', 'assignRoles', 'revokeRoles', 'deleteGroup'] }, + { name: 'member', services: ['posts', 'comments'], permissions: ['readAll', 'create', 'updateOwn', 'deleteOwn'] }, + ]; + + const members = params.members || [{ member_key: username, role: 'owner' }]; + + return I.v3CreateGroup( + name, + params.join_policy || 'invite_only', + roles, + members + ).then(() => { + I.v3GroupsLoad?.(); + I.v3GroupsManagesLoad?.(); + }); + } + + // Approve a single contract (ACR or GCR). + I.approveContract = function (contract: any) { + if (contract.kind === 'gcr') { + I.setStatus("Creating group..."); + applyGCR(contract) + .then(() => { + I.setStatus("Group created!"); + I.removePendingContract(contract); + setTimeout(() => I.setStatus(null), 2000); + }) + .catch((e) => I.setStatus("Failed to create group: " + (e.message || String(e)))); + return; + } + // ACR + const origin = contract.allowed_origin; const alreadyGranted = I.hasV3Contract?.(origin); if (alreadyGranted) { - I.removePendingACR(acr); + I.removePendingContract(contract); return; } I.setStatus("Approving contract..."); - applyACR(acr) + applyACR(contract) .then(() => { I.setStatus("Contract granted!"); I.v3ContractsLoad?.(); - I.removePendingACR(acr); + I.removePendingContract(contract); setTimeout(() => I.setStatus(null), 2000); }) .catch((e) => I.setStatus("Failed to approve: " + (e.message || String(e)))); } - // Remove a single ACR from the pending list (after approve or deny). - I.removePendingACR = function (acr: any) { - const source = acr._source; - I.setPendingACRs((prev: any[]) => { + // Remove a single contract from the pending list (after approve or deny). + I.removePendingContract = function (contract: any) { + const source = contract._source; + I.setPendingContracts((prev: any[]) => { if (source) { - return prev.filter((a: any) => a._source !== source); + return prev.filter((c: any) => c._source !== source); + } + if (contract.kind === 'acr') { + return prev.filter((c: any) => c.allowed_origin !== contract.allowed_origin); } - return prev.filter((a: any) => a.allowed_origin !== acr.allowed_origin); + return prev.filter((c: any) => c.action !== contract.action || c.app_origin !== contract.app_origin); }); } - // Deny an ACR — just remove it from the pending list. - I.denyACR = function (acr: any) { - I.removePendingACR(acr); + // Deny a contract — just remove it from the pending list. + I.denyContract = function (contract: any) { + I.removePendingContract(contract); I.setStatus("Request denied."); } - // Approve every pending ACR in one shot, then return to the app. + // Approve every pending contract in one shot, then return to the app. I.approveAll = function () { - if (I.pendingACRs.length === 0) { I.goToApp(); return; } + if (!I.pendingContracts || I.pendingContracts.length === 0) { I.goToApp(); return; } I.setStatus("Approving all…"); - const ops: Promise[] = I.pendingACRs.map((acr: any) => applyACR(acr)); + const ops: Promise[] = I.pendingContracts.map((c: any) => { + if (c.kind === 'gcr') return applyGCR(c); + return applyACR(c); + }); Promise.allSettled(ops) .then(() => { I.v3ContractsLoad?.(); - I.setPendingACRs([]); + I.v3GroupsLoad?.(); + I.v3GroupsManagesLoad?.(); + I.setPendingContracts([]); I.setStatus(null); I.goToApp(); }) .catch((e: any) => I.setStatus("Failed to approve all: " + (e.message || String(e)))); } + // Legacy aliases — keep old names working for tests + existing callers + I.pendingACRs = I.pendingContracts; + I.setPendingACRs = I.setPendingContracts; + I.approveACR = (c: any) => I.approveContract({ ...c, kind: c.kind || 'acr' }); + I.denyACR = (c: any) => I.denyContract({ ...c, kind: c.kind || 'acr' }); + I.removePendingACR = (c: any) => I.removePendingContract({ ...c, kind: c.kind || 'acr' }); + // Return to the requesting app, logging it in. Send the current v3 token // directly to the opener — no tiered token needed for v3. I.goToApp = function () {