Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions packages/app-shell/src/components/SuggestedBindingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { useCallback, useEffect, useState } from 'react';
import { ShieldQuestion } from 'lucide-react';
import { Button, cn } from '@object-ui/components';
import { toast } from 'sonner';
import {
listSuggestedBindings,
confirmSuggestedBinding,
dismissSuggestedBinding,
type SuggestedBinding,
} from '../services/suggestedBindingsApi';

/**
* SuggestedBindingsPanel — pending audience-binding suggestions
* (ADR-0090 D5/D9) with per-row Confirm / Dismiss.
*
* A package permission set shipped with `isDefault: true` asks the admin to
* bind it to the built-in `everyone` position (default grants for signed-in
* users). The server NEVER auto-binds; this panel is the "admin confirms"
* moment. Confirm runs under the server-side anchor gates — a 403 carries
* the human-readable reason (e.g. the set carries high-privilege bits) and
* is surfaced verbatim.
*
* Renders nothing while loading, when there are no pending suggestions, or
* when the caller is not a tenant admin (403 on list) — safe to mount
* unconditionally in the install dialog and the Studio Access pillar.
*/
export interface SuggestedBindingsStrings {
/** One-line prompt per suggestion, e.g. `CRM suggests granting 'crm_readonly' to everyone`. */
describe: (s: SuggestedBinding) => string;
confirm: string;
dismiss: string;
confirming: string;
confirmedToast: (s: SuggestedBinding) => string;
dismissedToast: (s: SuggestedBinding) => string;
}

export function SuggestedBindingsPanel({
packageId,
strings,
className,
onResolved,
}: {
/** Limit to one package's suggestions (install dialog); omit for all (Access pillar). */
packageId?: string;
strings: SuggestedBindingsStrings;
className?: string;
/** Called after any suggestion is confirmed or dismissed. */
onResolved?: (s: SuggestedBinding) => void;
}) {
const [pending, setPending] = useState<SuggestedBinding[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);

const reload = useCallback(() => {
let cancelled = false;
listSuggestedBindings({ status: 'pending', ...(packageId ? { packageId } : {}) })
.then((rows) => { if (!cancelled) setPending(rows); })
.catch(() => { /* non-admin (403) or surface unavailable → render nothing */ });
return () => { cancelled = true; };
}, [packageId]);

useEffect(() => reload(), [reload]);

const resolve = async (s: SuggestedBinding, action: 'confirm' | 'dismiss') => {
setBusyId(s.id);
try {
const resolved = action === 'confirm'
? await confirmSuggestedBinding(s.id)
: await dismissSuggestedBinding(s.id);
setPending((rows) => rows.filter((r) => r.id !== s.id));
toast.success(action === 'confirm' ? strings.confirmedToast(s) : strings.dismissedToast(s));
onResolved?.(resolved ?? s);
} catch (err: any) {
// The gate's message is the explanation ("carries view-all…") — show it.
toast.error(err?.message ?? String(err));
} finally {
setBusyId(null);
}
};

if (pending.length === 0) return null;

return (
<div
data-testid="suggested-bindings-panel"
className={cn(
'rounded-lg border border-amber-300/60 dark:border-amber-700/50 bg-amber-50 dark:bg-amber-950/30 px-3 py-2 space-y-2',
className,
)}
>
{pending.map((s) => (
<div key={s.id} className="flex items-center gap-2">
<ShieldQuestion className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="flex-1 min-w-0 text-sm text-amber-900 dark:text-amber-200">
{strings.describe(s)}
</p>
<Button
size="sm"
disabled={busyId === s.id}
onClick={() => resolve(s, 'confirm')}
data-testid={`suggested-binding-confirm-${s.permission_set_name}`}
>
{busyId === s.id ? strings.confirming : strings.confirm}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busyId === s.id}
onClick={() => resolve(s, 'dismiss')}
data-testid={`suggested-binding-dismiss-${s.permission_set_name}`}
>
{strings.dismiss}
</Button>
</div>
))}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,29 @@ import {
import { getRuntimeConfig } from '../../runtime-config';
import { emitMetadataRefresh } from '../../assistant/assistantBus';
import { useMetadata } from '../../providers/MetadataProvider';
import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from '../../components/SuggestedBindingsPanel';
import type { SuggestedBinding } from '../../services/suggestedBindingsApi';

export function MarketplacePackagePage() {
const navigate = useNavigate();
const { packageId, appName } = useParams<{ packageId?: string; appName?: string }>();
const isAdmin = useIsWorkspaceAdmin();
const { t, language } = useObjectTranslation();
// ADR-0090 D5 — install-time suggested audience bindings ("this app
// suggests granting <set> to Everyone"), surfaced right after a
// successful install into THIS runtime. Confirm/dismiss is admin-gated
// server-side; the panel renders nothing for non-admins.
const suggestionStrings: SuggestedBindingsStrings = {
describe: (s: SuggestedBinding) =>
t(s.anchor === 'guest' ? 'marketplace.suggestedBindings.promptGuest' : 'marketplace.suggestedBindings.promptEveryone', { set: s.permission_set_name }),
confirm: t('marketplace.suggestedBindings.confirm'),
confirming: t('marketplace.suggestedBindings.confirming'),
dismiss: t('marketplace.suggestedBindings.dismiss'),
confirmedToast: (s: SuggestedBinding) =>
t('marketplace.suggestedBindings.confirmedToast', { set: s.permission_set_name, anchor: s.anchor }),
dismissedToast: (s: SuggestedBinding) =>
t('marketplace.suggestedBindings.dismissedToast', { set: s.permission_set_name }),
};
const basePath = appName ? `/apps/${appName}` : '';
const { refresh: refreshMetadata } = useMetadata();

Expand Down Expand Up @@ -706,7 +723,14 @@ export function MarketplacePackagePage() {
className={`flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${localResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
>
{localResult.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
<div className="flex-1">{localResult.message}</div>
<div className="flex-1">
{localResult.message}
{/* ADR-0090 D5 — local installs land in this runtime's kernel, so
its suggested audience bindings are confirmable right here. */}
{localResult.ok && (
<SuggestedBindingsPanel packageId={pkg.manifest_id} strings={suggestionStrings} className="mt-2" />
)}
</div>
<button
type="button"
className="text-xs underline opacity-60 hover:opacity-100"
Expand Down Expand Up @@ -870,6 +894,16 @@ export function MarketplacePackagePage() {
</div>
)}

{/* ADR-0090 D5 — after a successful install into THIS runtime,
surface the package's suggested audience bindings for the admin
to confirm or dismiss (the server never auto-binds). Cross-env
installs are resolved from that env's own Studio instead. */}
{installResult?.ok === true
&& getRuntimeConfig().defaultEnvironmentId
&& getRuntimeConfig().defaultEnvironmentId === selectedEnv && (
<SuggestedBindingsPanel packageId={pkg.manifest_id} strings={suggestionStrings} />
)}

<DialogFooter>
<Button variant="outline" onClick={() => setInstallOpen(false)}>{t('marketplace.action.close')}</Button>
{!envsError && (
Expand Down
97 changes: 97 additions & 0 deletions packages/app-shell/src/services/suggestedBindingsApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Suggested audience bindings (ADR-0090 D5/D9) — client for the framework's
* `/api/v1/security/suggested-bindings` surface.
*
* A package permission set declaring `isDefault: true` is an install-time
* SUGGESTION to bind the set to the built-in `everyone` position (default
* grants for authenticated users). The server never auto-binds: a tenant
* admin confirms or dismisses each suggestion. These calls are ordinary
* same-origin admin API calls (not object CRUD), so they follow the
* marketplaceApi raw-fetch pattern: Bearer token + `credentials: 'include'`.
*/

import { TokenStorage } from '@object-ui/auth';

const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
const API_BASE = `${SERVER_URL}/api/v1/security/suggested-bindings`;

export interface SuggestedBinding {
id: string;
package_id: string;
permission_set_name: string;
anchor: 'everyone' | 'guest';
status: 'pending' | 'confirmed' | 'dismissed';
resolved_by?: string | null;
resolved_at?: string | null;
created_at?: string;
}

function authHeaders(extra: Record<string, string> = {}): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json', ...extra };
const token = TokenStorage.get();
return token ? { ...headers, Authorization: `Bearer ${token}` } : headers;
}

async function parseOrThrow(res: Response): Promise<any> {
let payload: any = null;
try { payload = await res.json(); } catch { /* empty body */ }
if (!res.ok) {
const code = payload?.error?.code ?? `HTTP_${res.status}`;
const message = payload?.error?.message ?? payload?.error ?? res.statusText;
const err = new Error(typeof message === 'string' ? message : String(code));
(err as any).code = code;
(err as any).status = res.status;
throw err;
}
return payload?.data ?? payload;
}

/**
* List suggestions. The server reconciles first (installed manifests →
* pending rows), so calling this right after an install already sees the
* new package's suggestions. Requires a tenant-level admin; non-admin
* callers get a 403 the UI should treat as "surface hidden".
*/
export async function listSuggestedBindings(filter: {
status?: 'pending' | 'confirmed' | 'dismissed';
packageId?: string;
} = {}): Promise<SuggestedBinding[]> {
const params = new URLSearchParams();
if (filter.status) params.set('status', filter.status);
if (filter.packageId) params.set('packageId', filter.packageId);
const qs = params.toString();
const res = await fetch(`${API_BASE}${qs ? `?${qs}` : ''}`, {
credentials: 'include',
headers: authHeaders(),
});
const data = await parseOrThrow(res);
return Array.isArray(data?.suggestions) ? data.suggestions : [];
}

/**
* Confirm a pending suggestion — the server creates the anchor binding under
* the ADR-0090 gates (a high-privilege set is refused with a 403 whose
* message explains the offending bits; surface it verbatim).
*/
export async function confirmSuggestedBinding(id: string): Promise<SuggestedBinding> {
const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/confirm`, {
method: 'POST',
credentials: 'include',
headers: authHeaders({ 'Content-Type': 'application/json' }),
});
const data = await parseOrThrow(res);
return data?.suggestion as SuggestedBinding;
}

/** Dismiss a pending suggestion (the set stays bindable by hand later). */
export async function dismissSuggestedBinding(id: string): Promise<SuggestedBinding> {
const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/dismiss`, {
method: 'POST',
credentials: 'include',
headers: authHeaders({ 'Content-Type': 'application/json' }),
});
const data = await parseOrThrow(res);
return data?.suggestion as SuggestedBinding;
}
17 changes: 17 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,15 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.access.bannerTitle':
'This matrix lists only the objects this package declares, and “Save” merges just that slice — grants contributed by other packages are preserved. Edits are saved as package drafts and go live when you Publish the package (top bar), exactly like Data and Interfaces.',
'engine.studio.access.banner': 'This package’s objects · saved as draft',
// ADR-0090 D5/D9 — pending suggested audience bindings (isDefault sets
// awaiting the admin's confirm; the server never auto-binds).
'engine.studio.access.suggestPromptEveryone': 'This package suggests granting "{set}" to all signed-in users (Everyone).',
'engine.studio.access.suggestPromptGuest': 'This package suggests granting "{set}" to unauthenticated visitors (Guest).',
'engine.studio.access.suggestConfirm': 'Grant',
'engine.studio.access.suggestConfirming': 'Granting…',
'engine.studio.access.suggestDismiss': 'Dismiss',
'engine.studio.access.suggestConfirmedToast': '"{set}" is now granted to {anchor}.',
'engine.studio.access.suggestDismissedToast': 'Suggestion for "{set}" dismissed.',
'engine.studio.access.heading': 'Permission sets',
'engine.studio.access.search': 'Search permissions…',
'engine.studio.access.none': 'No permission sets yet — create one below',
Expand Down Expand Up @@ -2322,6 +2331,14 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.access.bannerTitle':
'此矩阵仅列出本包声明的对象,「Save」只合并本包切片 —— 其他包贡献的授权原样保留。编辑保存为软件包草稿,点击顶栏「发布」后随整个包一起生效(与数据、界面一致)。',
'engine.studio.access.banner': '仅本包对象 · 保存为草稿',
// ADR-0090 D5/D9 — 待确认的受众绑定建议(isDefault 权限集,管理员确认后生效,服务端绝不自动绑定)
'engine.studio.access.suggestPromptEveryone': '此包建议将「{set}」授予所有已登录用户(Everyone 岗位)。',
'engine.studio.access.suggestPromptGuest': '此包建议将「{set}」授予未登录访客(Guest 岗位)。',
'engine.studio.access.suggestConfirm': '授予',
'engine.studio.access.suggestConfirming': '授予中…',
'engine.studio.access.suggestDismiss': '忽略',
'engine.studio.access.suggestConfirmedToast': '「{set}」已授予 {anchor}。',
'engine.studio.access.suggestDismissedToast': '已忽略「{set}」的建议。',
'engine.studio.access.heading': '权限集',
'engine.studio.access.search': '搜索权限…',
'engine.studio.access.none': '还没有权限集 — 在下方新建一个',
Expand Down
32 changes: 32 additions & 0 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { formatMetadataError, formatPublishFailures, type PublishFailure } from
import { loadPackageSurfaces } from './packageSurfaces';
import { buildObjectSkeleton, buildFlowSkeleton, buildAppSkeleton, buildPermissionSkeleton } from './skeletons';
import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n';
import { SuggestedBindingsPanel } from '../../components/SuggestedBindingsPanel';
import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas';
import {
readFields,
Expand Down Expand Up @@ -2982,6 +2983,37 @@ function AccessPillar({
</span>
</div>

{/* ADR-0090 D5/D9 — this package's pending suggested audience bindings
(isDefault sets shipped by the package, awaiting the admin's
confirm). Renders nothing when there are none or for non-admins;
confirm is enforced server-side by the anchor gates. */}
{!readOnly && (
<SuggestedBindingsPanel
packageId={packageId}
className="mx-3 mt-2"
strings={{
describe: (s) =>
tFormat(
s.anchor === 'guest'
? 'engine.studio.access.suggestPromptGuest'
: 'engine.studio.access.suggestPromptEveryone',
locale,
{ set: s.permission_set_name },
),
confirm: t('engine.studio.access.suggestConfirm', locale),
confirming: t('engine.studio.access.suggestConfirming', locale),
dismiss: t('engine.studio.access.suggestDismiss', locale),
confirmedToast: (s) =>
tFormat('engine.studio.access.suggestConfirmedToast', locale, {
set: s.permission_set_name,
anchor: s.anchor,
}),
dismissedToast: (s) =>
tFormat('engine.studio.access.suggestDismissedToast', locale, { set: s.permission_set_name }),
}}
/>
)}

<div className="relative flex min-h-0 flex-1">
{isMobile && railOpen && (
<div
Expand Down
12 changes: 12 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2216,6 +2216,18 @@ const en = {
localUnauthorized: 'Sign in to this runtime first, then try again.',
localMarketplaceUnavailable: 'This runtime has no OS_CLOUD_URL configured, so the marketplace catalog is unreachable.',
},
// ADR-0090 D5/D9 — a package's isDefault permission set is an
// install-time suggestion to bind it to the everyone/guest position;
// the admin confirms here, the server never auto-binds.
suggestedBindings: {
promptEveryone: 'This app suggests granting "{{set}}" to all signed-in users (the Everyone position).',
promptGuest: 'This app suggests granting "{{set}}" to unauthenticated visitors (the Guest position).',
confirm: 'Grant',
confirming: 'Granting…',
dismiss: 'Dismiss',
confirmedToast: '"{{set}}" is now granted to {{anchor}}.',
dismissedToast: 'Suggestion for "{{set}}" dismissed.',
},
uninstall: {
confirm: 'Uninstall {{manifestId}} v{{version}} from this runtime?\n\nThe cached manifest will be removed. The app will remain loaded in the running kernel until the next restart.',
successInList: 'Removed {{manifestId}}. Restart the runtime to fully unload it from the running kernel.',
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2294,6 +2294,17 @@ const zh = {
localUnauthorized: '请先登录本运行时再试。',
localMarketplaceUnavailable: '本运行时未配置 OS_CLOUD_URL,无法访问应用市场目录。',
},
// ADR-0090 D5/D9 — 安装时建议:包声明 isDefault 的权限集建议绑定到
// everyone/guest 岗位,由管理员在此确认,服务端绝不自动绑定。
suggestedBindings: {
promptEveryone: '此应用建议将「{{set}}」授予所有已登录用户(Everyone 岗位)。',
promptGuest: '此应用建议将「{{set}}」授予未登录访客(Guest 岗位)。',
confirm: '授予',
confirming: '授予中…',
dismiss: '忽略',
confirmedToast: '「{{set}}」已授予 {{anchor}}。',
dismissedToast: '已忽略「{{set}}」的建议。',
},
uninstall: {
confirm: '从本运行时卸载 {{manifestId}} v{{version}}?\n\n磁盘上的清单缓存将被移除。运行中的内核仍会保留该应用,直到下次重启运行时才会真正卸载。',
successInList: '已移除 {{manifestId}}。重启运行时即可从内核中彻底卸载。',
Expand Down
Loading