feat(acl): add bot security policy presets

Initialize new bots with preset ACL templates and an allow-by-default fallback so common access setups can be selected during bot creation instead of being configured manually afterward.
This commit is contained in:
BBQ
2026-04-14 04:38:53 +08:00
committed by 晨苒
parent 0e6c8ca451
commit 60517bc2a6
24 changed files with 1033 additions and 93 deletions
+42
View File
@@ -0,0 +1,42 @@
export type AclPresetKey =
| 'allow_all'
| 'private_only'
| 'group_only'
| 'group_and_thread_only'
| 'deny_all'
export interface AclPresetOption {
value: AclPresetKey
titleKey: string
descriptionKey: string
}
export const defaultAclPreset: AclPresetKey = 'allow_all'
export const aclPresetOptions: AclPresetOption[] = [
{
value: 'allow_all',
titleKey: 'bots.aclPresets.allowAll.title',
descriptionKey: 'bots.aclPresets.allowAll.description',
},
{
value: 'private_only',
titleKey: 'bots.aclPresets.privateOnly.title',
descriptionKey: 'bots.aclPresets.privateOnly.description',
},
{
value: 'group_only',
titleKey: 'bots.aclPresets.groupOnly.title',
descriptionKey: 'bots.aclPresets.groupOnly.description',
},
{
value: 'group_and_thread_only',
titleKey: 'bots.aclPresets.groupAndThreadOnly.title',
descriptionKey: 'bots.aclPresets.groupAndThreadOnly.description',
},
{
value: 'deny_all',
titleKey: 'bots.aclPresets.denyAll.title',
descriptionKey: 'bots.aclPresets.denyAll.description',
},
]
+26
View File
@@ -614,6 +614,32 @@
"timezonePlaceholder": "e.g. America/New_York",
"timezoneInherited": "Inherited from user/system",
"timezoneInheritedHint": "Leave empty to inherit the user's timezone, or fall back to the system timezone.",
"aclPreset": "Security Policy",
"aclPresetDescription": "Choose a common ACL starting point during creation. You can fine-tune it later in Access Control.",
"aclPresetHint": "Recommendation: use Private Only for personal assistants, Group Only or Group and Thread for community bots, and Allow All when you want the broadest default.",
"aclPresetHelp": "This initializes the bot ACL default effect and preset rules at creation time. You can still edit them later in Access Control.",
"aclPresets": {
"allowAll": {
"title": "Allow All",
"description": "Adds no extra restriction and allows all conversation types by default."
},
"privateOnly": {
"title": "Private Only",
"description": "Defaults to deny and only allows private conversations."
},
"groupOnly": {
"title": "Group Only",
"description": "Defaults to deny and only allows group conversations."
},
"groupAndThreadOnly": {
"title": "Group and Thread",
"description": "Defaults to deny and allows group conversations plus threads."
},
"denyAll": {
"title": "Deny All",
"description": "Defaults to deny and adds no rules, so you can configure access manually later."
}
},
"editAvatar": "Edit Avatar",
"editAvatarDescription": "Set the image URL for the bot avatar.",
"editTimezone": "Edit Timezone",
+32 -6
View File
@@ -610,6 +610,32 @@
"timezonePlaceholder": "例如 Asia/Shanghai",
"timezoneInherited": "继承用户或系统时区",
"timezoneInheritedHint": "留空则继承用户时区,若用户未设置则回退到系统时区。",
"aclPreset": "安全策略",
"aclPresetDescription": "创建时选择一个常见 ACL 场景,后续仍可在访问控制中继续微调。",
"aclPresetHint": "建议:个人助手用“仅私聊”,群机器人用“仅群聊”或“群聊和话题”,不确定时保留“允许全部”。",
"aclPresetHelp": "创建时会按所选策略初始化 ACL 默认行为和预设规则,后续仍可在访问控制页面修改。",
"aclPresets": {
"allowAll": {
"title": "允许全部",
"description": "不额外添加限制,默认允许所有会话类型。"
},
"privateOnly": {
"title": "仅私聊",
"description": "默认拒绝,仅放行私聊。"
},
"groupOnly": {
"title": "仅群聊",
"description": "默认拒绝,仅放行群聊。"
},
"groupAndThreadOnly": {
"title": "群聊和话题",
"description": "默认拒绝,放行群聊和话题。"
},
"denyAll": {
"title": "全部拒绝",
"description": "默认拒绝且不添加规则,需要后续手动配置。"
}
},
"editAvatar": "编辑头像",
"editAvatarDescription": "设置 Bot 头像图片的链接地址",
"editTimezone": "编辑时区",
@@ -962,28 +988,28 @@
"deleteSuccess": "规则已删除",
"deleteFailed": "删除规则失败",
"sourceScopeTitle": "来源范围",
"sourceScopeDescription": "可选地将规则限制到某个平台、会话类型或指定会话/线程。",
"sourceScopeDescription": "可选地将规则限制到某个平台、会话类型或指定会话/话题。",
"sourceChannel": "来源平台",
"anyChannel": "任意平台",
"conversationType": "会话类型",
"anyConversationType": "任意会话类型",
"privateConversationType": "私聊",
"groupConversationType": "群聊",
"threadConversationType": "线程",
"threadConversationType": "话题",
"conversationSource": "会话",
"conversationSourceDescription": "按名称、平台或会话 ID 搜索。列表为本 Bot 曾出现过的群聊/线程路由:选「平台身份」时按该身份筛选;选「平台类型」时按该平台上的全部会话。",
"conversationSourceDescription": "按名称、平台或会话 ID 搜索。列表为本 Bot 曾出现过的群聊/话题路由:选「平台身份」时按该身份筛选;选「平台类型」时按该平台上的全部会话。",
"selectConversationSource": "搜索或选择会话",
"searchConversationSource": "搜索会话",
"noObservedConversations": "暂无符合的历史会话。可手动填写 ID,或确认该 Bot 在对应会话中已有消息(含私聊)。",
"manualConversationIds": "手动填写会话 ID",
"manualConversationIdsHint": "若列表中没有目标会话,请从平台复制原始会话 ID、线程 ID。",
"manualConversationIdsHint": "若列表中没有目标会话,请从平台复制原始会话 ID、话题 ID。",
"pickIdentityForConversationSearch": "请先在上方选择平台身份,才能按历史记录搜索会话。",
"pickChannelTypeForConversationSearch": "请先在上方填写或选择平台类型,才能按该平台上的历史会话搜索。",
"conversationIdManualHint": "当前仅能手动填写。搜索会话需先选择平台身份。",
"conversationId": "会话 ID",
"conversationIdPlaceholder": "输入会话 ID",
"threadId": "线程 ID",
"threadIdPlaceholder": "输入线程 ID",
"threadId": "话题 ID",
"threadIdPlaceholder": "输入话题 ID",
"clearScope": "清空范围",
"lastObserved": "最近出现"
},
@@ -833,7 +833,7 @@ function onConversationSourceChange(routeId: string) {
// ---- default effect ----
const defaultEffectDraft = ref('deny')
const defaultEffectDraft = ref('allow')
const isSavingDefaultEffect = ref(false)
watch(defaultEffectData, (data) => {
@@ -843,7 +843,7 @@ watch(defaultEffectData, (data) => {
}, { immediate: true })
const hasDefaultEffectChanges = computed(
() => defaultEffectDraft.value !== (defaultEffectData.value?.default_effect ?? 'deny'),
() => defaultEffectDraft.value !== (defaultEffectData.value?.default_effect ?? 'allow'),
)
async function handleSaveDefaultEffect() {
@@ -77,6 +77,57 @@
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ value, handleChange }"
name="acl_preset"
>
<FormItem>
<div class="mb-2 flex items-center gap-2">
<Label>{{ $t('bots.aclPreset') }}</Label>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="size-5 text-muted-foreground hover:text-foreground"
:aria-label="$t('bots.aclPresetHelp')"
>
<CircleHelp class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-64 text-left">
{{ $t('bots.aclPresetHelp') }}
</TooltipContent>
</Tooltip>
</div>
<FormControl>
<Select
:model-value="value || defaultAclPreset"
@update:model-value="(nextValue) => handleChange(nextValue)"
>
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('bots.aclPreset')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="preset in aclPresetOptions"
:key="preset.value"
:value="preset.value"
>
{{ $t(preset.titleKey) }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<p
v-if="getAclPresetDescription(value || defaultAclPreset)"
class="text-xs text-muted-foreground"
>
{{ getAclPresetDescription(value || defaultAclPreset) }}
</p>
</FormItem>
</FormField>
<div class="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
{{ $t('bots.createBotWaitHint') }}
</div>
@@ -117,10 +168,18 @@ import {
FormControl,
FormItem,
Separator,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Label,
Spinner,
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@memohai/ui'
import { Plus } from 'lucide-vue-next'
import { CircleHelp, Plus } from 'lucide-vue-next'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
@@ -129,6 +188,7 @@ import { useMutation, useQueryCache } from '@pinia/colada'
import { postBotsMutation, getBotsQueryKey } from '@memohai/sdk/colada'
import { useI18n } from 'vue-i18n'
import { useDialogMutation } from '@/composables/useDialogMutation'
import { aclPresetOptions, defaultAclPreset } from '@/constants/acl-presets'
import { emptyTimezoneValue } from '@/utils/timezones'
import TimezoneSelect from '@/components/timezone-select/index.vue'
@@ -140,6 +200,7 @@ const formSchema = toTypedSchema(z.object({
display_name: z.string().min(1),
avatar_url: z.string().optional(),
timezone: z.string().optional(),
acl_preset: z.string().min(1),
}))
const form = useForm({
@@ -148,6 +209,7 @@ const form = useForm({
display_name: '',
avatar_url: '',
timezone: '',
acl_preset: defaultAclPreset,
},
})
@@ -157,6 +219,20 @@ const { mutateAsync: createBot, isLoading: submitLoading } = useMutation({
onSettled: () => queryCache.invalidateQueries({ key: getBotsQueryKey() }),
})
function getAclPresetOption(value?: string) {
const presetValue = value || defaultAclPreset
return aclPresetOptions.find(option => option.value === presetValue)
}
function getAclPresetDescriptionKey(value?: string) {
return getAclPresetOption(value)?.descriptionKey
}
function getAclPresetDescription(value?: string) {
const descriptionKey = getAclPresetDescriptionKey(value)
return descriptionKey ? t(descriptionKey) : ''
}
watch(open, (val) => {
if (val) {
form.resetForm({
@@ -164,6 +240,7 @@ watch(open, (val) => {
display_name: '',
avatar_url: '',
timezone: '',
acl_preset: defaultAclPreset,
},
})
} else {
@@ -179,6 +256,7 @@ const handleSubmit = form.handleSubmit(async (values) => {
avatar_url: values.avatar_url || undefined,
timezone: values.timezone || undefined,
is_active: true,
acl_preset: values.acl_preset,
},
}),
{
@@ -0,0 +1,14 @@
-- 0066_acl_default_allow
-- Restore the bot ACL default effect to deny for newly created bots.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'bots' AND column_name = 'acl_default_effect'
) THEN
ALTER TABLE bots
ALTER COLUMN acl_default_effect SET DEFAULT 'deny';
END IF;
END $$;
@@ -0,0 +1,14 @@
-- 0066_acl_default_allow
-- Change the bot ACL default effect to allow for newly created bots.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'bots' AND column_name = 'acl_default_effect'
) THEN
ALTER TABLE bots
ALTER COLUMN acl_default_effect SET DEFAULT 'allow';
END IF;
END $$;
+194
View File
@@ -0,0 +1,194 @@
package acl
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/memohai/memoh/internal/db"
"github.com/memohai/memoh/internal/db/sqlc"
)
const (
PresetAllowAll = "allow_all"
PresetPrivateOnly = "private_only"
PresetGroupOnly = "group_only"
PresetGroupAndThreadOnly = "group_and_thread_only"
PresetDenyAll = "deny_all"
)
var ErrUnknownPreset = errors.New("unknown acl preset")
type Preset struct {
Key string
DefaultEffect string
Rules []CreateRuleRequest
}
func DefaultPresetKey() string {
return PresetAllowAll
}
func NormalizePresetKey(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return DefaultPresetKey()
}
return value
}
func ResolvePreset(raw string) (Preset, error) {
switch NormalizePresetKey(raw) {
case PresetAllowAll:
return Preset{
Key: PresetAllowAll,
DefaultEffect: EffectAllow,
}, nil
case PresetPrivateOnly:
return Preset{
Key: PresetPrivateOnly,
DefaultEffect: EffectDeny,
Rules: []CreateRuleRequest{
{
Priority: 100,
Enabled: true,
Effect: EffectAllow,
SubjectKind: SubjectKindAll,
SourceScope: &SourceScope{ConversationType: "private"},
},
},
}, nil
case PresetGroupOnly:
return Preset{
Key: PresetGroupOnly,
DefaultEffect: EffectDeny,
Rules: []CreateRuleRequest{
{
Priority: 100,
Enabled: true,
Effect: EffectAllow,
SubjectKind: SubjectKindAll,
SourceScope: &SourceScope{ConversationType: "group"},
},
},
}, nil
case PresetGroupAndThreadOnly:
return Preset{
Key: PresetGroupAndThreadOnly,
DefaultEffect: EffectDeny,
Rules: []CreateRuleRequest{
{
Priority: 100,
Enabled: true,
Effect: EffectAllow,
SubjectKind: SubjectKindAll,
SourceScope: &SourceScope{ConversationType: "group"},
},
{
Priority: 110,
Enabled: true,
Effect: EffectAllow,
SubjectKind: SubjectKindAll,
SourceScope: &SourceScope{ConversationType: "thread"},
},
},
}, nil
case PresetDenyAll:
return Preset{
Key: PresetDenyAll,
DefaultEffect: EffectDeny,
}, nil
default:
return Preset{}, ErrUnknownPreset
}
}
func ApplyPreset(ctx context.Context, queries *sqlc.Queries, botID, createdByUserID, rawPreset string) error {
if queries == nil {
return errors.New("acl queries not configured")
}
preset, err := ResolvePreset(rawPreset)
if err != nil {
return err
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return err
}
if err := queries.SetBotACLDefaultEffect(ctx, sqlc.SetBotACLDefaultEffectParams{
ID: pgBotID,
AclDefaultEffect: preset.DefaultEffect,
}); err != nil {
return err
}
for _, rule := range preset.Rules {
if err := applyPresetRule(ctx, queries, pgBotID, createdByUserID, rule); err != nil {
return err
}
}
return nil
}
func applyPresetRule(ctx context.Context, queries *sqlc.Queries, botID pgtype.UUID, createdByUserID string, rule CreateRuleRequest) error {
if err := validateEffect(rule.Effect); err != nil {
return err
}
if err := validateSubject(rule.SubjectKind, rule.ChannelIdentityID, rule.SubjectChannelType); err != nil {
return err
}
sourceScope, err := normalizeOptionalSourceScope(rule.SourceScope)
if err != nil {
return err
}
sourceChannel, err := presetSourceChannel(rule.SubjectKind, rule.SubjectChannelType, sourceScope)
if err != nil {
return err
}
_, err = queries.CreateBotACLRule(ctx, sqlc.CreateBotACLRuleParams{
BotID: botID,
Priority: rule.Priority,
Enabled: rule.Enabled,
Description: optionalText(rule.Description),
Effect: rule.Effect,
SubjectKind: rule.SubjectKind,
ChannelIdentityID: optionalUUID(rule.ChannelIdentityID),
SubjectChannelType: optionalText(rule.SubjectChannelType),
SourceChannel: optionalText(sourceChannel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
CreatedByUserID: optionalUUID(createdByUserID),
})
return err
}
func presetSourceChannel(subjectKind, subjectChannelType string, sourceScope SourceScope) (string, error) {
if sourceScope.IsZero() {
return "", nil
}
if sourceScope.ConversationID == "" && sourceScope.ThreadID == "" {
return "", nil
}
switch strings.TrimSpace(subjectKind) {
case SubjectKindChannelType:
return strings.TrimSpace(subjectChannelType), nil
case SubjectKindAll:
return "", fmt.Errorf("acl preset rule cannot scope subject_kind=%q to a concrete conversation without source channel", SubjectKindAll)
case SubjectKindChannelIdentity:
return "", fmt.Errorf("acl preset rule cannot scope subject_kind=%q to a concrete conversation without source channel", SubjectKindChannelIdentity)
default:
return "", ErrInvalidRuleSubject
}
}
+144
View File
@@ -0,0 +1,144 @@
package acl
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/memohai/memoh/internal/db/sqlc"
)
func TestResolvePreset(t *testing.T) {
tests := []struct {
name string
key string
wantKey string
wantEffect string
wantRuleCount int
wantFirstType string
wantErr error
}{
{
name: "empty falls back to allow all",
key: "",
wantKey: PresetAllowAll,
wantEffect: EffectAllow,
wantRuleCount: 0,
},
{
name: "private only",
key: PresetPrivateOnly,
wantKey: PresetPrivateOnly,
wantEffect: EffectDeny,
wantRuleCount: 1,
wantFirstType: "private",
},
{
name: "group and thread only",
key: PresetGroupAndThreadOnly,
wantKey: PresetGroupAndThreadOnly,
wantEffect: EffectDeny,
wantRuleCount: 2,
wantFirstType: "group",
},
{
name: "invalid preset",
key: "nope",
wantErr: ErrUnknownPreset,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
preset, err := ResolvePreset(tt.key)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if preset.Key != tt.wantKey {
t.Fatalf("expected key %q, got %q", tt.wantKey, preset.Key)
}
if preset.DefaultEffect != tt.wantEffect {
t.Fatalf("expected default effect %q, got %q", tt.wantEffect, preset.DefaultEffect)
}
if len(preset.Rules) != tt.wantRuleCount {
t.Fatalf("expected %d rules, got %d", tt.wantRuleCount, len(preset.Rules))
}
if tt.wantFirstType != "" {
got := preset.Rules[0].SourceScope.ConversationType
if got != tt.wantFirstType {
t.Fatalf("expected first conversation type %q, got %q", tt.wantFirstType, got)
}
}
})
}
}
func TestApplyPreset(t *testing.T) {
botUUID := pgtype.UUID{Bytes: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Valid: true}
type createdRule struct {
priority int32
effect string
subjectKind string
conversationType string
}
var defaultEffect string
var createdRules []createdRule
db := &fakeDBTX{
execFunc: func(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
if strings.Contains(sql, "acl_default_effect") {
defaultEffect = args[1].(string)
}
return pgconn.CommandTag{}, nil
},
queryRowFunc: func(_ context.Context, sql string, args ...any) pgx.Row {
if strings.Contains(sql, "INSERT INTO bot_acl_rules") {
createdRules = append(createdRules, createdRule{
priority: args[1].(int32),
effect: args[3].(string),
subjectKind: args[4].(string),
conversationType: textFromArg(args[10]),
})
return &fakeRow{scanFunc: func(_ ...any) error { return nil }}
}
return noRule()
},
}
err := ApplyPreset(context.Background(), sqlc.New(db), botUUID.String(), "", PresetGroupAndThreadOnly)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if defaultEffect != EffectDeny {
t.Fatalf("expected default effect %q, got %q", EffectDeny, defaultEffect)
}
if len(createdRules) != 2 {
t.Fatalf("expected 2 created rules, got %d", len(createdRules))
}
if createdRules[0].priority != 100 || createdRules[0].conversationType != "group" {
t.Fatalf("unexpected first rule: %+v", createdRules[0])
}
if createdRules[1].priority != 110 || createdRules[1].conversationType != "thread" {
t.Fatalf("unexpected second rule: %+v", createdRules[1])
}
for _, rule := range createdRules {
if rule.effect != EffectAllow || rule.subjectKind != SubjectKindAll {
t.Fatalf("unexpected rule contents: %+v", rule)
}
}
}
+2 -18
View File
@@ -12,7 +12,6 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/db"
"github.com/memohai/memoh/internal/db/sqlc"
)
@@ -25,17 +24,15 @@ var (
type Service struct {
queries *sqlc.Queries
bots *bots.Service
logger *slog.Logger
}
func NewService(log *slog.Logger, queries *sqlc.Queries, botService *bots.Service) *Service {
func NewService(log *slog.Logger, queries *sqlc.Queries) *Service {
if log == nil {
log = slog.Default()
}
return &Service{
queries: queries,
bots: botService,
logger: log.With(slog.String("service", "acl")),
}
}
@@ -43,7 +40,6 @@ func NewService(log *slog.Logger, queries *sqlc.Queries, botService *bots.Servic
// Evaluate checks whether the given request is allowed to perform chat.trigger.
// It uses a single first-match-wins query over priority-ordered enabled rules,
// falling back to the bot's acl_default_effect if no rule matches.
// The bot owner is always allowed without consulting the rule table.
func (s *Service) Evaluate(ctx context.Context, req EvaluateRequest) (bool, error) {
// Validate scope before any service nil checks so callers get meaningful errors.
sourceScope, err := normalizeSourceScope(req.SourceScope)
@@ -51,7 +47,7 @@ func (s *Service) Evaluate(ctx context.Context, req EvaluateRequest) (bool, erro
return false, err
}
if s == nil || s.queries == nil || s.bots == nil {
if s == nil || s.queries == nil {
return false, errors.New("acl service not configured")
}
@@ -59,18 +55,6 @@ func (s *Service) Evaluate(ctx context.Context, req EvaluateRequest) (bool, erro
channelIdentityID := strings.TrimSpace(req.ChannelIdentityID)
channelType := strings.TrimSpace(req.ChannelType)
bot, err := s.bots.Get(ctx, botID)
if err != nil {
return false, err
}
// Owner always bypasses ACL.
// Note: ChannelIdentityID here is the resolved Memoh user ID (set only when logged in).
// The owner bypass was historically keyed on UserID; callers that pass the
// ownerUserID via ChannelIdentityID will naturally not get bypassed here.
// The inbound processor passes the resolved UserID separately — see the
// comments in internal/channel/inbound/channel.go.
_ = bot // currently unused after the user_id removal; keep the Get() for owner check wiring if re-added
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return false, err
+5 -44
View File
@@ -12,7 +12,6 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/db/sqlc"
)
@@ -90,40 +89,6 @@ func (*fakeRows) Conn() *pgx.Conn { return nil }
// ---- helpers ----
func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
return &fakeRow{
scanFunc: func(dest ...any) error {
if len(dest) < 22 {
return pgx.ErrNoRows
}
*dest[0].(*pgtype.UUID) = botID
*dest[1].(*pgtype.UUID) = ownerUserID
*dest[2].(*pgtype.Text) = pgtype.Text{String: "bot", Valid: true}
*dest[3].(*pgtype.Text) = pgtype.Text{}
*dest[4].(*pgtype.Text) = pgtype.Text{}
*dest[5].(*bool) = true
*dest[6].(*string) = bots.BotStatusReady
*dest[7].(*string) = "" // Language
*dest[8].(*bool) = false // ReasoningEnabled
*dest[9].(*string) = "medium" // ReasoningEffort
*dest[10].(*pgtype.UUID) = pgtype.UUID{} // ChatModelID
*dest[11].(*pgtype.UUID) = pgtype.UUID{} // SearchProviderID
*dest[12].(*pgtype.UUID) = pgtype.UUID{} // MemoryProviderID
*dest[13].(*bool) = false // HeartbeatEnabled
*dest[14].(*int32) = 30 // HeartbeatInterval
*dest[15].(*string) = "" // HeartbeatPrompt
*dest[16].(*bool) = false // CompactionEnabled
*dest[17].(*int32) = 100000 // CompactionThreshold
*dest[18].(*int32) = 80 // CompactionRatio
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[20].(*[]byte) = []byte(`{}`)
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
},
}
}
func makeStringRow(value string) *fakeRow {
return &fakeRow{
scanFunc: func(dest ...any) error {
@@ -163,7 +128,6 @@ func noRule() *fakeRow {
func TestEvaluate(t *testing.T) {
botUUID := pgtype.UUID{Bytes: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Valid: true}
ownerUUID := pgtype.UUID{Bytes: uuid.MustParse("22222222-2222-2222-2222-222222222222"), Valid: true}
tests := []struct {
name string
@@ -202,8 +166,6 @@ func TestEvaluate(t *testing.T) {
db := &fakeDBTX{
queryRowFunc: func(_ context.Context, sql string, _ ...any) pgx.Row {
switch {
case strings.Contains(sql, "FROM bots") && strings.Contains(sql, "owner_user_id"):
return makeBotRow(botUUID, ownerUUID)
case strings.Contains(sql, "FROM bot_acl_rules") && strings.Contains(sql, "LIMIT 1"):
// Evaluate query
if tt.matchedEffect == "" {
@@ -218,8 +180,7 @@ func TestEvaluate(t *testing.T) {
},
}
queries := sqlc.New(db)
botService := bots.NewService(nil, queries)
service := NewService(nil, queries, botService)
service := NewService(nil, queries)
allowed, err := service.Evaluate(context.Background(), EvaluateRequest{
BotID: botUUID.String(),
@@ -241,7 +202,7 @@ func TestEvaluate(t *testing.T) {
}
func TestEvaluateRejectsInvalidScope(t *testing.T) {
service := NewService(nil, nil, nil)
service := NewService(nil, nil)
_, err := service.Evaluate(context.Background(), EvaluateRequest{
BotID: "11111111-1111-1111-1111-111111111111",
SourceScope: SourceScope{
@@ -306,7 +267,7 @@ func TestSetDefaultEffect(t *testing.T) {
return pgconn.CommandTag{}, nil
},
}
service := NewService(nil, sqlc.New(db), nil)
service := NewService(nil, sqlc.New(db))
if err := service.SetDefaultEffect(context.Background(), botUUID.String(), EffectAllow); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -346,7 +307,7 @@ func TestListObservedConversationsByChannelIdentity(t *testing.T) {
},
}
service := NewService(nil, sqlc.New(db), nil)
service := NewService(nil, sqlc.New(db))
items, err := service.ListObservedConversationsByChannelIdentity(context.Background(), botUUID.String(), channelIdentityUUID.String())
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -373,7 +334,7 @@ func TestReorderRules(t *testing.T) {
return pgconn.CommandTag{}, nil
},
}
service := NewService(nil, sqlc.New(db), nil)
service := NewService(nil, sqlc.New(db))
err := service.ReorderRules(context.Background(), []ReorderItem{
{ID: ruleUUID.String(), Priority: 42},
})
+14
View File
@@ -13,6 +13,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/memohai/memoh/internal/acl"
"github.com/memohai/memoh/internal/db"
"github.com/memohai/memoh/internal/db/sqlc"
tzutil "github.com/memohai/memoh/internal/timezone"
@@ -100,6 +101,10 @@ func (s *Service) Create(ctx context.Context, ownerUserID string, req CreateBotR
if err := s.ensureUserExists(ctx, ownerUUID); err != nil {
return Bot{}, err
}
aclPresetKey := acl.NormalizePresetKey(req.AclPreset)
if _, err := acl.ResolvePreset(aclPresetKey); err != nil {
return Bot{}, err
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = "bot-" + uuid.NewString()
@@ -137,6 +142,15 @@ func (s *Service) Create(ctx context.Context, ownerUserID string, req CreateBotR
if err != nil {
return Bot{}, err
}
if err := acl.ApplyPreset(ctx, s.queries, bot.ID, ownerID, aclPresetKey); err != nil {
if cleanupErr := s.queries.DeleteBotByID(ctx, row.ID); cleanupErr != nil {
return Bot{}, errors.Join(
fmt.Errorf("apply acl preset: %w", err),
fmt.Errorf("cleanup bot after acl preset failure: %w", cleanupErr),
)
}
return Bot{}, fmt.Errorf("apply acl preset: %w", err)
}
if err := s.attachCheckSummary(ctx, &bot, asSQLCBot(row)); err != nil {
return Bot{}, err
}
+34
View File
@@ -2,12 +2,15 @@ package bots
import (
"context"
"errors"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/memohai/memoh/internal/acl"
"github.com/memohai/memoh/internal/db/sqlc"
)
@@ -145,3 +148,34 @@ func TestAuthorizeAccess(t *testing.T) {
})
}
}
func TestCreateRejectsUnknownACLPreset(t *testing.T) {
ownerUUID := mustParseUUID("00000000-0000-0000-0000-000000000001")
createCalled := false
db := &fakeDBTX{
queryRowFunc: func(_ context.Context, sql string, _ ...any) pgx.Row {
switch {
case strings.Contains(sql, "FROM users") && strings.Contains(sql, "WHERE id = $1"):
return &fakeRow{scanFunc: func(_ ...any) error { return nil }}
case strings.Contains(sql, "INSERT INTO bots"):
createCalled = true
return &fakeRow{scanFunc: func(_ ...any) error { return nil }}
default:
return &fakeRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }}
}
},
}
svc := NewService(nil, sqlc.New(db))
_, err := svc.Create(context.Background(), ownerUUID.String(), CreateBotRequest{
DisplayName: "test-bot",
AclPreset: "not_a_real_preset",
})
if !errors.Is(err, acl.ErrUnknownPreset) {
t.Fatalf("expected ErrUnknownPreset, got %v", err)
}
if createCalled {
t.Fatal("bot row should not be created when acl preset is invalid")
}
}
+1
View File
@@ -39,6 +39,7 @@ type CreateBotRequest struct {
AvatarURL string `json:"avatar_url,omitempty"`
Timezone *string `json:"timezone,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
AclPreset string `json:"acl_preset,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
+4
View File
@@ -11,6 +11,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/accounts"
"github.com/memohai/memoh/internal/acl"
"github.com/memohai/memoh/internal/auth"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/channel"
@@ -442,6 +443,9 @@ func (h *UsersHandler) CreateBot(c echo.Context) error {
}
return echo.NewHTTPError(http.StatusBadRequest, "owner user not found")
}
if errors.Is(err, acl.ErrUnknownPreset) {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusCreated, resp)
+2 -2
View File
@@ -263,7 +263,7 @@ func normalizeBotSetting(language string, aclDefaultEffect string, reasoningEnab
settings.Language = DefaultLanguage
}
if settings.AclDefaultEffect == "" {
settings.AclDefaultEffect = "deny"
settings.AclDefaultEffect = "allow"
}
if !isValidReasoningEffort(settings.ReasoningEffort) {
settings.ReasoningEffort = DefaultReasoningEffort
@@ -401,7 +401,7 @@ func normalizeBotSettingsFields(
func (s *Service) getDefaultEffect(ctx context.Context, botID string) (string, error) {
if s.acl == nil {
return "deny", nil
return "allow", nil
}
return s.acl.GetDefaultEffect(ctx, botID)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+67 -3
View File
@@ -341,6 +341,7 @@ export type BotsBotCheck = {
};
export type BotsCreateBotRequest = {
acl_preset?: string;
avatar_url?: string;
display_name?: string;
is_active?: boolean;
@@ -1393,12 +1394,38 @@ export type ProvidersImportModelsResponse = {
skipped?: number;
};
export type ProvidersOAuthAccount = {
avatar_url?: string;
email?: string;
label?: string;
login?: string;
name?: string;
profile_url?: string;
};
export type ProvidersOAuthAuthorizeResponse = {
auth_url?: string;
device?: ProvidersOAuthDeviceStatus;
mode?: string;
};
export type ProvidersOAuthDeviceStatus = {
expires_at?: string;
interval_seconds?: number;
pending?: boolean;
user_code?: string;
verification_uri?: string;
};
export type ProvidersOAuthStatus = {
account?: ProvidersOAuthAccount;
callback_url?: string;
configured?: boolean;
device?: ProvidersOAuthDeviceStatus;
expired?: boolean;
expires_at?: string;
has_token?: boolean;
mode?: string;
};
export type ProvidersTestResponse = {
@@ -1558,6 +1585,7 @@ export type SettingsSettings = {
compaction_model_id?: string;
compaction_ratio?: number;
compaction_threshold?: number;
context_token_budget?: number;
discuss_probe_model_id?: string;
heartbeat_enabled?: boolean;
heartbeat_interval?: number;
@@ -1565,9 +1593,11 @@ export type SettingsSettings = {
image_model_id?: string;
language?: string;
memory_provider_id?: string;
persist_full_tool_results?: boolean;
reasoning_effort?: string;
reasoning_enabled?: boolean;
search_provider_id?: string;
timezone?: string;
title_model_id?: string;
tts_model_id?: string;
};
@@ -1580,6 +1610,7 @@ export type SettingsUpsertRequest = {
compaction_model_id?: string;
compaction_ratio?: number;
compaction_threshold?: number;
context_token_budget?: number;
discuss_probe_model_id?: string;
heartbeat_enabled?: boolean;
heartbeat_interval?: number;
@@ -1587,6 +1618,7 @@ export type SettingsUpsertRequest = {
image_model_id?: string;
language?: string;
memory_provider_id?: string;
persist_full_tool_results?: boolean;
reasoning_effort?: string;
reasoning_enabled?: boolean;
search_provider_id?: string;
@@ -7720,13 +7752,45 @@ export type GetProvidersByIdOauthAuthorizeResponses = {
/**
* OK
*/
200: {
[key: string]: string;
};
200: ProvidersOAuthAuthorizeResponse;
};
export type GetProvidersByIdOauthAuthorizeResponse = GetProvidersByIdOauthAuthorizeResponses[keyof GetProvidersByIdOauthAuthorizeResponses];
export type PostProvidersByIdOauthPollData = {
body?: never;
path: {
/**
* Provider ID (UUID)
*/
id: string;
};
query?: never;
url: '/providers/{id}/oauth/poll';
};
export type PostProvidersByIdOauthPollErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Not Found
*/
404: HandlersErrorResponse;
};
export type PostProvidersByIdOauthPollError = PostProvidersByIdOauthPollErrors[keyof PostProvidersByIdOauthPollErrors];
export type PostProvidersByIdOauthPollResponses = {
/**
* OK
*/
200: ProvidersOAuthStatus;
};
export type PostProvidersByIdOauthPollResponse = PostProvidersByIdOauthPollResponses[keyof PostProvidersByIdOauthPollResponses];
export type GetProvidersByIdOauthStatusData = {
body?: never;
path: {
@@ -25,7 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="cn(
'z-50 w-fit rounded-lg px-3 py-1.5 text-xs text-balance',
'z-50 w-fit rounded-lg px-3 py-1.5 text-xs whitespace-normal break-words',
'bg-primary text-primary-foreground shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
+125 -4
View File
@@ -7387,10 +7387,44 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/providers.OAuthAuthorizeResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/providers/{id}/oauth/poll": {
"post": {
"tags": [
"providers-oauth"
],
"summary": "Poll OAuth device authorization for an LLM provider",
"parameters": [
{
"type": "string",
"description": "Provider ID (UUID)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/providers.OAuthStatus"
}
},
"400": {
@@ -9458,6 +9492,9 @@ const docTemplate = `{
"bots.CreateBotRequest": {
"type": "object",
"properties": {
"acl_preset": {
"type": "string"
},
"avatar_url": {
"type": "string"
},
@@ -12156,15 +12193,78 @@ const docTemplate = `{
}
}
},
"providers.OAuthAccount": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"email": {
"type": "string"
},
"label": {
"type": "string"
},
"login": {
"type": "string"
},
"name": {
"type": "string"
},
"profile_url": {
"type": "string"
}
}
},
"providers.OAuthAuthorizeResponse": {
"type": "object",
"properties": {
"auth_url": {
"type": "string"
},
"device": {
"$ref": "#/definitions/providers.OAuthDeviceStatus"
},
"mode": {
"type": "string"
}
}
},
"providers.OAuthDeviceStatus": {
"type": "object",
"properties": {
"expires_at": {
"type": "string"
},
"interval_seconds": {
"type": "integer"
},
"pending": {
"type": "boolean"
},
"user_code": {
"type": "string"
},
"verification_uri": {
"type": "string"
}
}
},
"providers.OAuthStatus": {
"type": "object",
"properties": {
"account": {
"$ref": "#/definitions/providers.OAuthAccount"
},
"callback_url": {
"type": "string"
},
"configured": {
"type": "boolean"
},
"device": {
"$ref": "#/definitions/providers.OAuthDeviceStatus"
},
"expired": {
"type": "boolean"
},
@@ -12173,6 +12273,9 @@ const docTemplate = `{
},
"has_token": {
"type": "boolean"
},
"mode": {
"type": "string"
}
}
},
@@ -12575,6 +12678,9 @@ const docTemplate = `{
"compaction_threshold": {
"type": "integer"
},
"context_token_budget": {
"type": "integer"
},
"discuss_probe_model_id": {
"type": "string"
},
@@ -12596,6 +12702,9 @@ const docTemplate = `{
"memory_provider_id": {
"type": "string"
},
"persist_full_tool_results": {
"type": "boolean"
},
"reasoning_effort": {
"type": "string"
},
@@ -12605,6 +12714,9 @@ const docTemplate = `{
"search_provider_id": {
"type": "string"
},
"timezone": {
"type": "string"
},
"title_model_id": {
"type": "string"
},
@@ -12637,6 +12749,9 @@ const docTemplate = `{
"compaction_threshold": {
"type": "integer"
},
"context_token_budget": {
"type": "integer"
},
"discuss_probe_model_id": {
"type": "string"
},
@@ -12658,6 +12773,9 @@ const docTemplate = `{
"memory_provider_id": {
"type": "string"
},
"persist_full_tool_results": {
"type": "boolean"
},
"reasoning_effort": {
"type": "string"
},
@@ -12667,6 +12785,9 @@ const docTemplate = `{
"search_provider_id": {
"type": "string"
},
"timezone": {
"type": "string"
},
"title_model_id": {
"type": "string"
},
+125 -4
View File
@@ -7378,10 +7378,44 @@
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/providers.OAuthAuthorizeResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/providers/{id}/oauth/poll": {
"post": {
"tags": [
"providers-oauth"
],
"summary": "Poll OAuth device authorization for an LLM provider",
"parameters": [
{
"type": "string",
"description": "Provider ID (UUID)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/providers.OAuthStatus"
}
},
"400": {
@@ -9449,6 +9483,9 @@
"bots.CreateBotRequest": {
"type": "object",
"properties": {
"acl_preset": {
"type": "string"
},
"avatar_url": {
"type": "string"
},
@@ -12147,15 +12184,78 @@
}
}
},
"providers.OAuthAccount": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"email": {
"type": "string"
},
"label": {
"type": "string"
},
"login": {
"type": "string"
},
"name": {
"type": "string"
},
"profile_url": {
"type": "string"
}
}
},
"providers.OAuthAuthorizeResponse": {
"type": "object",
"properties": {
"auth_url": {
"type": "string"
},
"device": {
"$ref": "#/definitions/providers.OAuthDeviceStatus"
},
"mode": {
"type": "string"
}
}
},
"providers.OAuthDeviceStatus": {
"type": "object",
"properties": {
"expires_at": {
"type": "string"
},
"interval_seconds": {
"type": "integer"
},
"pending": {
"type": "boolean"
},
"user_code": {
"type": "string"
},
"verification_uri": {
"type": "string"
}
}
},
"providers.OAuthStatus": {
"type": "object",
"properties": {
"account": {
"$ref": "#/definitions/providers.OAuthAccount"
},
"callback_url": {
"type": "string"
},
"configured": {
"type": "boolean"
},
"device": {
"$ref": "#/definitions/providers.OAuthDeviceStatus"
},
"expired": {
"type": "boolean"
},
@@ -12164,6 +12264,9 @@
},
"has_token": {
"type": "boolean"
},
"mode": {
"type": "string"
}
}
},
@@ -12566,6 +12669,9 @@
"compaction_threshold": {
"type": "integer"
},
"context_token_budget": {
"type": "integer"
},
"discuss_probe_model_id": {
"type": "string"
},
@@ -12587,6 +12693,9 @@
"memory_provider_id": {
"type": "string"
},
"persist_full_tool_results": {
"type": "boolean"
},
"reasoning_effort": {
"type": "string"
},
@@ -12596,6 +12705,9 @@
"search_provider_id": {
"type": "string"
},
"timezone": {
"type": "string"
},
"title_model_id": {
"type": "string"
},
@@ -12628,6 +12740,9 @@
"compaction_threshold": {
"type": "integer"
},
"context_token_budget": {
"type": "integer"
},
"discuss_probe_model_id": {
"type": "string"
},
@@ -12649,6 +12764,9 @@
"memory_provider_id": {
"type": "string"
},
"persist_full_tool_results": {
"type": "boolean"
},
"reasoning_effort": {
"type": "string"
},
@@ -12658,6 +12776,9 @@
"search_provider_id": {
"type": "string"
},
"timezone": {
"type": "string"
},
"title_model_id": {
"type": "string"
},
+82 -3
View File
@@ -539,6 +539,8 @@ definitions:
type: object
bots.CreateBotRequest:
properties:
acl_preset:
type: string
avatar_url:
type: string
display_name:
@@ -2330,18 +2332,61 @@ definitions:
skipped:
type: integer
type: object
providers.OAuthAccount:
properties:
avatar_url:
type: string
email:
type: string
label:
type: string
login:
type: string
name:
type: string
profile_url:
type: string
type: object
providers.OAuthAuthorizeResponse:
properties:
auth_url:
type: string
device:
$ref: '#/definitions/providers.OAuthDeviceStatus'
mode:
type: string
type: object
providers.OAuthDeviceStatus:
properties:
expires_at:
type: string
interval_seconds:
type: integer
pending:
type: boolean
user_code:
type: string
verification_uri:
type: string
type: object
providers.OAuthStatus:
properties:
account:
$ref: '#/definitions/providers.OAuthAccount'
callback_url:
type: string
configured:
type: boolean
device:
$ref: '#/definitions/providers.OAuthDeviceStatus'
expired:
type: boolean
expires_at:
type: string
has_token:
type: boolean
mode:
type: string
type: object
providers.TestResponse:
properties:
@@ -2614,6 +2659,8 @@ definitions:
type: integer
compaction_threshold:
type: integer
context_token_budget:
type: integer
discuss_probe_model_id:
type: string
heartbeat_enabled:
@@ -2628,12 +2675,16 @@ definitions:
type: string
memory_provider_id:
type: string
persist_full_tool_results:
type: boolean
reasoning_effort:
type: string
reasoning_enabled:
type: boolean
search_provider_id:
type: string
timezone:
type: string
title_model_id:
type: string
tts_model_id:
@@ -2655,6 +2706,8 @@ definitions:
type: integer
compaction_threshold:
type: integer
context_token_budget:
type: integer
discuss_probe_model_id:
type: string
heartbeat_enabled:
@@ -2669,12 +2722,16 @@ definitions:
type: string
memory_provider_id:
type: string
persist_full_tool_results:
type: boolean
reasoning_effort:
type: string
reasoning_enabled:
type: boolean
search_provider_id:
type: string
timezone:
type: string
title_model_id:
type: string
tts_model_id:
@@ -7615,9 +7672,7 @@ paths:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/providers.OAuthAuthorizeResponse'
"400":
description: Bad Request
schema:
@@ -7629,6 +7684,30 @@ paths:
summary: Start OAuth2 authorization for an LLM provider
tags:
- providers-oauth
/providers/{id}/oauth/poll:
post:
parameters:
- description: Provider ID (UUID)
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/providers.OAuthStatus'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Poll OAuth device authorization for an LLM provider
tags:
- providers-oauth
/providers/{id}/oauth/status:
get:
parameters: