This commit is contained in:
Timothy Jaeryang Baek 2026-10-12 18:23:50 +04:00
parent 774007d728
commit 2ba35625b3
5 changed files with 30 additions and 31 deletions

View file

@ -47,7 +47,7 @@ class AppInvitation(StrictModel):
app_id: str
Scope = Literal['mentions:read', 'messages:read', 'messages:write', 'channels:read']
Scope = Literal['channels:mentions:read', 'channels:messages:read', 'channels:messages:write', 'channels:read']
class Manifest(StrictModel):
@ -55,7 +55,7 @@ class Manifest(StrictModel):
name: str = Field(min_length=1, max_length=100)
description: str = Field(default='', max_length=2000)
public_key: str = Field(max_length=2000)
requested_scopes: list[Scope] = Field(max_length=4)
requested_scopes: list[Scope]
@field_validator('public_key')
@classmethod
@ -109,7 +109,7 @@ class AppForm(StrictModel):
url: str = Field(max_length=2048)
api_key: SecretStr = Field(min_length=32, max_length=4096)
slug: str = Field(pattern=r'^[a-z][a-z0-9-]{0,39}$')
granted_scopes: list[str]
granted_scopes: list[Scope]
access_grants: list[dict] = Field(
default_factory=lambda: [{'principal_type': 'user', 'principal_id': '*', 'permission': 'read'}]
)
@ -120,7 +120,7 @@ class AppUpdateForm(StrictModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
settings: dict | None = None
manifest: Manifest | None = None
granted_scopes: list[str] | None = None
granted_scopes: list[Scope] | None = None
access_grants: list[dict] | None = None

View file

@ -216,7 +216,7 @@ async def verify_app_event(
):
value = decode_app_context_token(app, 'event', form_data.token)
await check_app_channel_membership(app, value['channel']['id'], db=db)
require_scope(app, 'messages:read')
require_scope(app, 'channels:messages:read')
return {k: v for k, v in value.items() if k not in ('purpose', 'exp', 'nonce')}
@ -235,7 +235,7 @@ async def get_app_channel_messages(
db: AsyncSession = Depends(get_async_session),
):
await check_app_channel_membership(app, channel_id, db=db)
require_scope(app, 'messages:read')
require_scope(app, 'channels:messages:read')
query = select(Message).where(Message.channel_id == channel_id, Message.parent_id == parent_id)
if parent_id:
parent = await db.get(Message, parent_id)
@ -343,7 +343,9 @@ async def update_app_by_id(
}
data['is_active'] = False
elif form_data.granted_scopes is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='An updated manifest is required')
if not set(form_data.granted_scopes) <= set(app.data['manifest']['requested_scopes']):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Unrequested scope')
data['data'] = {**data.get('data', app.data), 'granted_scopes': form_data.granted_scopes}
app = await Apps.update_app_by_id(app_id, data, access_grants=form_data.access_grants, db=db)
return get_app_response(app)

View file

@ -1118,7 +1118,9 @@ async def app_channel_reply_handler(channel_id, message_id, user_id):
{'text': text, 'message_id': message_id},
operation_id=message_id,
db=db,
scope='messages:read' if 'messages:read' in app.data['granted_scopes'] else 'mentions:read',
scope='channels:messages:read'
if 'channels:messages:read' in app.data['granted_scopes']
else 'channels:mentions:read',
)
except Exception as exc:
log.warning('App mention delivery failed: %s', type(exc).__name__)

View file

@ -359,7 +359,7 @@ async def send_app_message_event(message, kind, created_at):
try:
async with get_async_db() as db:
app = await Apps.get_app_by_id(app_id, db=db)
if not app or 'messages:read' not in app.data.get('granted_scopes', []):
if not app or 'channels:messages:read' not in app.data.get('granted_scopes', []):
continue
if not app.data.get('webui_url'):
continue
@ -406,7 +406,7 @@ async def emit_app_message(message, channel, kind='message'):
async def create_app_message(db, app, channel_id, form):
channel = await check_app_channel_membership(app, channel_id, db=db)
require_scope(app, 'messages:write')
require_scope(app, 'channels:messages:write')
for target in (form.parent_id, form.reply_to_id):
if target:
reference = await Messages.get_message_by_id(target, include_thread_replies=False, db=db)
@ -435,7 +435,7 @@ async def update_app_message(db, app, message_id, form, if_match):
if not message or message.app_id != app.id:
raise HTTPException(404, 'App message not found')
channel = await check_app_channel_membership(app, message.channel_id, db=db)
require_scope(app, 'messages:write')
require_scope(app, 'channels:messages:write')
if if_match != f'"{message.updated_at}"':
raise HTTPException(412, 'Message changed')
updated = await Messages.update_app_message_by_id(

View file

@ -53,10 +53,10 @@
const buttonClass =
'flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white';
const scopeLabels: Record<string, string> = {
'mentions:read': 'Read app mentions',
'messages:read': 'Read messages and history in joined channels',
'messages:write': 'Send and update messages',
'channels:read': 'Read joined channels'
'channels:mentions:read': 'Read app mentions in joined channels',
'channels:messages:read': 'Read messages and history in joined channels',
'channels:messages:write': 'Send messages and edit own messages in joined channels',
'channels:read': 'Read joined channel details'
};
async function run(task: () => Promise<void>) {
busy = true;
@ -107,7 +107,8 @@
app = await updateAppById(localStorage.token, app.id, {
name,
access_grants: grants,
...(manifestUpdated ? { manifest, granted_scopes: scopes } : {})
granted_scopes: scopes,
...(manifestUpdated ? { manifest } : {})
});
manifestUpdated = false;
}
@ -242,19 +243,13 @@
{scope}
</div>{/if}
</div>
{#if app && !manifestUpdated}
<span class="whitespace-nowrap text-[0.6875rem] text-gray-400 dark:text-gray-600"
>{scopes.includes(scope) ? $i18n.t('Granted') : $i18n.t('Not granted')}</span
>
{:else}
<Switch
state={scopes.includes(scope)}
ariaLabelledbyId={labelId}
on:change={(event) => {
scopes = event.detail ? [...scopes, scope] : scopes.filter((s) => s !== scope);
}}
/>
{/if}
<Switch
state={scopes.includes(scope)}
ariaLabelledbyId={labelId}
on:change={(event) => {
scopes = event.detail ? [...scopes, scope] : scopes.filter((s) => s !== scope);
}}
/>
</AdminSettingRow>
{:else}
<p class="py-3 text-xs text-gray-400 dark:text-gray-600">
@ -389,7 +384,7 @@
<AdminSettingRow label={$i18n.t('Scopes')}>
<div class="flex items-center gap-3 text-xs">
<span class="text-gray-400 dark:text-gray-600"
>{$i18n.t(app ? '{{COUNT}} granted' : '{{COUNT}} selected', {
>{$i18n.t('{{COUNT}} selected', {
COUNT: scopes.length
})}</span
>
@ -401,7 +396,7 @@
showScopes = true;
}}
>
{app && !manifestUpdated ? $i18n.t('View') : $i18n.t('Review')}
{app ? $i18n.t('Edit') : $i18n.t('Review')}
</button>
</div>
</AdminSettingRow>