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
778 changes: 778 additions & 0 deletions research/permission-manifest-design.md

Large diffs are not rendered by default.

370 changes: 370 additions & 0 deletions research/swarm-app-permission-manifest.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ const { registerSwarmProviderIpc } = require('./swarm/swarm-provider-ipc');
const { registerRadiclePermissionsIpc } = require('./radicle/radicle-permissions');
const { registerRadicleProviderIpc } = require('./radicle/radicle-provider-ipc');
const { registerFeedStoreIpc } = require('./swarm/feed-store');
const { registerPermissionManifestIpc } = require('./swarm/permission-manifests');
const { registerGithubBridgeIpc, cleanupTempDirs } = require('./github-bridge');
const { registerServiceRegistryIpc } = require('./service-registry');
const { promptForDefaultExternalCandidates } = require('./profile-external-candidates');
Expand Down Expand Up @@ -311,6 +312,7 @@ async function bootstrap() {
registerRadiclePermissionsIpc();
registerRadicleProviderIpc();
registerFeedStoreIpc();
registerPermissionManifestIpc();

// Resolve any pending broadcast txs that didn't get a final receipt
// before the previous run exited. Fire-and-forget — the wallet stack
Expand Down
9 changes: 9 additions & 0 deletions src/main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -564,9 +564,18 @@ contextBridge.exposeInMainWorld('swarmPermissions', {
setAutoApprove: (origin, type, enabled) =>
ipcRenderer.invoke('swarm:set-auto-approve', origin, type, enabled),
grantMessaging: (origin) => ipcRenderer.invoke('swarm:grant-messaging', origin),
revokeMessaging: (origin) => ipcRenderer.invoke('swarm:revoke-messaging', origin),
hasMessagingGrant: (origin) => ipcRenderer.invoke('swarm:has-messaging-grant', origin),
});

contextBridge.exposeInMainWorld('swarmManifest', {
check: (request) => ipcRenderer.invoke('swarm:manifest-check', request),
decide: (token, outcome) => ipcRenderer.invoke('swarm:manifest-decide', { token, outcome }),
get: (origin) => ipcRenderer.invoke('swarm:manifest-get', origin),
useIndividual: (origin, capability) => ipcRenderer.invoke('swarm:manifest-use-individual', { origin, capability }),
disconnect: (origin) => ipcRenderer.invoke('swarm:manifest-disconnect', origin),
});

contextBridge.exposeInMainWorld('swarmProvider', {
// meta carries renderer-only routing info (e.g. the subscribing
// webview's webContentsId for swarm_subscribe message delivery).
Expand Down
9 changes: 8 additions & 1 deletion src/main/preload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe('preload', () => {
beeApiEnv: 'http://127.0.0.1:1700',
});

expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(24);
expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(25);
expect(Object.keys(exposures)).toEqual([
'nodeConfig',
'internalPages',
Expand All @@ -105,6 +105,7 @@ describe('preload', () => {
'sitePermissions',
'dappPermissions',
'swarmPermissions',
'swarmManifest',
'swarmProvider',
'radiclePermissions',
'radicleProvider',
Expand Down Expand Up @@ -170,6 +171,12 @@ describe('preload', () => {
[exposures.githubBridge, 'validateUrl', ['https://github.com/openai/project'], IPC.GITHUB_BRIDGE_VALIDATE_URL, ['https://github.com/openai/project']],
[exposures.githubBridge, 'checkExisting', ['https://github.com/openai/project'], IPC.GITHUB_BRIDGE_CHECK_EXISTING, ['https://github.com/openai/project']],
[exposures.serviceRegistry, 'getRegistry', [], IPC.SERVICE_REGISTRY_GET, []],
[exposures.swarmPermissions, 'revokeMessaging', ['origin.eth'], IPC.SWARM_REVOKE_MESSAGING, ['origin.eth']],
[exposures.swarmManifest, 'check', [{ origin: 'origin.eth', committedUrl: 'bzz://origin.eth/' }], IPC.SWARM_MANIFEST_CHECK, [{ origin: 'origin.eth', committedUrl: 'bzz://origin.eth/' }]],
[exposures.swarmManifest, 'decide', ['token', 'allow'], IPC.SWARM_MANIFEST_DECIDE, [{ token: 'token', outcome: 'allow' }]],
[exposures.swarmManifest, 'get', ['origin.eth'], IPC.SWARM_MANIFEST_GET, ['origin.eth']],
[exposures.swarmManifest, 'useIndividual', ['origin.eth', 'feeds'], IPC.SWARM_MANIFEST_USE_INDIVIDUAL, [{ origin: 'origin.eth', capability: 'feeds' }]],
[exposures.swarmManifest, 'disconnect', ['origin.eth'], IPC.SWARM_MANIFEST_DISCONNECT, ['origin.eth']],
[exposures.swarmFeedStore, 'previewAppScopedIdentity', ['origin.eth', { label: 'Draft' }], IPC.SWARM_PREVIEW_APP_SCOPED_IDENTITY, ['origin.eth', { label: 'Draft' }]],
[exposures.swarmFeedStore, 'ensureEthereumWalletIdentity', ['origin.eth', 2, { activate: true }], IPC.SWARM_ENSURE_ETHEREUM_WALLET_IDENTITY, ['origin.eth', 2, { activate: true }]],
[exposures.sitePermissions, 'respondToPrompt', [{ id: 1, decision: 'allow', remember: true }], IPC.PERMISSIONS_PROMPT_RESPONSE, [{ id: 1, decision: 'allow', remember: true }]],
Expand Down
113 changes: 86 additions & 27 deletions src/main/swarm/feed-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const path = require('path');
const fs = require('fs');
const IPC = require('../../shared/ipc-channels');
const { normalizeOrigin } = require('../../shared/origin-utils');
const { withOriginLock } = require('./origin-mutation-lock');
const {
getDerivedKeys,
getPublisherKey,
Expand All @@ -46,6 +47,23 @@ const ETHEREUM_WALLET_ID_PREFIX = 'ethereum-wallet';

let feedsCache = null;

// Manifest-ownership hook, mirroring swarm-permissions.js. A permission
// manifest can own the feed grant and the publisher identity of an origin;
// when the *user* changes either by hand, that ownership has to be detached
// or the manifest record keeps claiming the flag (Settings still reads
// "feeds · allowed by manifest") and a later projection silently re-asserts
// it. Manifest-sourced calls pass `{ source: 'manifest' }` and notify
// nothing — they are the projection, not a user mutation.
let manifestMutationListener = null;

function onManifestMutation(listener) {
manifestMutationListener = listener;
}

function notifyManifestMutation(origin, projectionKey) {
manifestMutationListener?.(normalizeOrigin(origin), projectionKey);
}

class PreserveFeedStoreError extends Error {
constructor(message, backupSuffix = 'unsupported') {
super(message);
Expand Down Expand Up @@ -383,6 +401,8 @@ function saveFeeds() {
fs.renameSync(tempPath, filePath);
} catch (err) {
log.error('[FeedStore] Failed to save feeds:', err.message);
feedsCache = null;
throw err;
}
}

Expand Down Expand Up @@ -618,10 +638,22 @@ async function previewAppScopedIdentity(origin, options = {}) {
};
}

// A manifest can own an origin's publisher identity (the `feeds`/`signing`
// capabilities project it). Any user-driven change of the *active* identity
// makes that ownership claim untrue, so it is reported like the other user
// mutations. Only an actual switch counts — re-ensuring the identity that is
// already active changes nothing to detach.
function notifyIdentityChange(key, previousActiveId, source) {
if (source === 'manifest') return;
if (loadFeeds().origins[key]?.activeIdentityId === previousActiveId) return;
notifyManifestMutation(key, 'identity');
}

function createAppScopedIdentity(origin, options = {}) {
const store = loadFeeds();
const key = normalizeOrigin(origin);
const entry = store.origins[key] || createOriginShell();
const previousActiveId = entry.activeIdentityId;
const publisherKeyIndex = allocatePublisherKeyIndexInStore(store);
const identity = createIdentity('app-scoped', publisherKeyIndex, Date.now(), options.label);

Expand All @@ -640,13 +672,15 @@ function createAppScopedIdentity(origin, options = {}) {
store.origins[key] = entry;
saveFeeds();
log.info(`[FeedStore] Created app-scoped identity ${identity.id} for ${key}`);
notifyIdentityChange(key, previousActiveId, options.source);
return getOriginEntry(origin);
}

function ensureAntWalletIdentity(origin, options = {}) {
const store = loadFeeds();
const key = normalizeOrigin(origin);
const entry = store.origins[key] || createOriginShell();
const previousActiveId = entry.activeIdentityId;
const existing = entry.identities?.[BEE_WALLET_IDENTITY_ID];
const identity = existing || createIdentity('bee-wallet', null, Date.now(), options.label);

Expand All @@ -665,6 +699,7 @@ function ensureAntWalletIdentity(origin, options = {}) {
store.origins[key] = entry;
saveFeeds();
log.info(`[FeedStore] Ensured Ant wallet identity for ${key}`);
notifyIdentityChange(key, previousActiveId, options.source);
return getOriginEntry(origin);
}

Expand All @@ -687,6 +722,7 @@ async function ensureEthereumWalletIdentity(origin, walletIndex, options = {}) {
const store = loadFeeds();
const key = normalizeOrigin(origin);
const entry = store.origins[key] || createOriginShell();
const previousActiveId = entry.activeIdentityId;
const identityId = getIdentityId('ethereum-wallet', null, walletIndex);
const existing = entry.identities?.[identityId];
const identity = existing || createIdentity('ethereum-wallet', null, Date.now(), wallet.name, walletIndex);
Expand All @@ -709,10 +745,11 @@ async function ensureEthereumWalletIdentity(origin, walletIndex, options = {}) {
store.origins[key] = entry;
saveFeeds();
log.info(`[FeedStore] Ensured Ethereum wallet identity ${identity.id} for ${key}`);
notifyIdentityChange(key, previousActiveId, options.source);
return getOriginEntry(origin);
}

function activateIdentity(origin, identityId) {
function activateIdentity(origin, identityId, { source = 'user' } = {}) {
const store = loadFeeds();
const key = normalizeOrigin(origin);
const entry = store.origins[key];
Expand All @@ -723,10 +760,12 @@ function activateIdentity(origin, identityId) {
throw new Error(`Publisher identity not found: ${identityId}`);
}

const previousActiveId = entry.activeIdentityId;
entry.activeIdentityId = identityId;
entry.identities[identityId].lastUsedAt = Date.now();
saveFeeds();
log.info(`[FeedStore] Activated identity ${identityId} for ${key}`);
notifyIdentityChange(key, previousActiveId, source);
return getOriginEntry(origin);
}

Expand Down Expand Up @@ -913,26 +952,30 @@ function hasFeedGrant(origin) {
/**
* Grant feed access for an origin. Called after the feed approval prompt.
* @param {string} origin
* @param {{source?: string}} [options]
*/
function grantFeedAccess(origin) {
function grantFeedAccess(origin, { source = 'user' } = {}) {
const store = loadFeeds();
const key = normalizeOrigin(origin);
if (!store.origins[key]) return;
store.origins[key].feedGranted = true;
saveFeeds();
if (source === 'user') notifyManifestMutation(key, 'feedGrant');
}

/**
* Revoke feed access for an origin. Called on disconnect.
* Identity metadata (identityMode, publisherKeyIndex, feeds) is preserved.
* @param {string} origin
* @param {{source?: string}} [options]
*/
function revokeFeedAccess(origin) {
function revokeFeedAccess(origin, { source = 'user' } = {}) {
const store = loadFeeds();
const key = normalizeOrigin(origin);
if (!store.origins[key]) return;
store.origins[key].feedGranted = false;
saveFeeds();
if (source === 'user') notifyManifestMutation(key, 'feedGrant');
}

/**
Expand Down Expand Up @@ -964,52 +1007,67 @@ function registerFeedStoreIpc() {
return previewAppScopedIdentity(origin, options);
});

// Everything arriving over IPC is a user action by definition: `source` is
// forced here so a renderer cannot pass `{ source: 'manifest' }` and mutate
// an origin's identity without detaching the manifest's claim on it.
ipcMain.handle(IPC.SWARM_CREATE_APP_SCOPED_IDENTITY, async (_event, origin, options = {}) => {
createAppScopedIdentity(origin, options);
return getOriginIdentityStateWithOwners(origin);
return withOriginLock(origin, async () => {
createAppScopedIdentity(origin, { ...options, source: 'user' });
return getOriginIdentityStateWithOwners(origin);
});
});

ipcMain.handle(IPC.SWARM_ENSURE_ANT_WALLET_IDENTITY, async (_event, origin, options = {}) => {
ensureAntWalletIdentity(origin, options);
return getOriginIdentityStateWithOwners(origin);
return withOriginLock(origin, async () => {
ensureAntWalletIdentity(origin, { ...options, source: 'user' });
return getOriginIdentityStateWithOwners(origin);
});
});

ipcMain.handle(IPC.SWARM_ENSURE_ETHEREUM_WALLET_IDENTITY, async (_event, origin, walletIndex, options = {}) => {
await ensureEthereumWalletIdentity(origin, walletIndex, options);
return getOriginIdentityStateWithOwners(origin);
return withOriginLock(origin, async () => {
await ensureEthereumWalletIdentity(origin, walletIndex, { ...options, source: 'user' });
return getOriginIdentityStateWithOwners(origin);
});
});

ipcMain.handle(IPC.SWARM_ACTIVATE_FEED_IDENTITY, async (_event, origin, identityId) => {
activateIdentity(origin, identityId);
return getOriginIdentityStateWithOwners(origin);
return withOriginLock(origin, async () => {
activateIdentity(origin, identityId);
return getOriginIdentityStateWithOwners(origin);
});
});

// Idempotent for identity: if the origin already has an identity mode set,
// return the existing entry without allocating a new key index.
// Always grants feed access (feedGranted = true).
ipcMain.handle(IPC.SWARM_SET_FEED_IDENTITY, (_event, origin, identityMode) => {
if (!VALID_IDENTITY_MODES.includes(identityMode)) {
throw new Error(`Invalid identity mode: ${identityMode}. Must be one of: ${VALID_IDENTITY_MODES.join(', ')}`);
}
return withOriginLock(origin, () => {
if (!VALID_IDENTITY_MODES.includes(identityMode)) {
throw new Error(`Invalid identity mode: ${identityMode}. Must be one of: ${VALID_IDENTITY_MODES.join(', ')}`);
}

const existing = getOriginEntry(origin);
if (identityMode === 'ethereum-wallet' && !existing?.activeIdentityId) {
throw new Error('Use ensureEthereumWalletIdentity(origin, walletIndex) before setting ethereum-wallet feed identity');
}
if (existing && existing.activeIdentityId) {
// Identity already set — just re-grant feed access
if (!existing.feedGranted) {
grantFeedAccess(origin);
const existing = getOriginEntry(origin);
if (identityMode === 'ethereum-wallet' && !existing?.activeIdentityId) {
throw new Error('Use ensureEthereumWalletIdentity(origin, walletIndex) before setting ethereum-wallet feed identity');
}
if (existing && existing.activeIdentityId) {
// Identity already set — just re-grant feed access
if (!existing.feedGranted) {
grantFeedAccess(origin);
}
return getOriginEntry(origin);
}
return getOriginEntry(origin);
}

return setOriginEntry(origin, { identityMode, feedGranted: true });
return setOriginEntry(origin, { identityMode, feedGranted: true });
});
});

ipcMain.handle(IPC.SWARM_REVOKE_FEED_ACCESS, (_event, origin) => {
revokeFeedAccess(origin);
return true;
return withOriginLock(origin, () => {
revokeFeedAccess(origin);
return true;
});
});

log.info('[FeedStore] IPC handlers registered');
Expand Down Expand Up @@ -1040,6 +1098,7 @@ module.exports = {
hasFeedGrant,
grantFeedAccess,
revokeFeedAccess,
onManifestMutation,
registerFeedStoreIpc,
VALID_IDENTITY_MODES,
_resetCache,
Expand Down
Loading
Loading