Skip to content
Closed
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
15 changes: 11 additions & 4 deletions TESTING_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,17 @@ it; that `integrationConnections` must map platforms to id strings; and that a
server-side JS run gets `ctx.app` and `ctx.connections` from the Installation,
over whatever the caller sent. `browser/lib/src/plugin-install.test.ts` checks
`installRelease` records a fresh `atomic:agent:` in the genesis with no key
material. Not covered: the page reading the runtime child and calling
`POST /runtimes`, the page writing `integrationConnections` when it delegates,
a second node publishing its own runtime child, and syncing those children
between nodes.
material. `data-browser/src/helpers/installationRuntimes.test.ts` checks the
page's side against a fake proxy that mirrors atomic-plugins#122's routes and
verifies every v2 signature: `POST /runtimes {app, agent, label}` for each
runtime child whose genesis the named agent signed, nothing posted again in
the same page or when the proxy already lists it, a re-post when the label
changes, `DELETE /runtimes/{agent}` on revoke, and a retry after a proxy error.
`chunks/AppPage/appAgent.test.ts` checks that `appAgentOf` prefers the
Installation's `integrationAppAgent` and falls back to `GET /app-agent`. Not
covered: the page writing `integrationConnections` when it delegates, a real
proxy accepting the page's calls, a second node publishing its own runtime
child, and syncing those children between nodes.

`atomic-proxy:` URLs (#1700, answer 4): the shared fixtures in
`testdata/plugin-manifest/` (Rust `shared_manifest_conformance` and the
Expand Down
2 changes: 2 additions & 0 deletions browser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This changelog covers all five packages, as they are (for now) updated as a whol

## UNRELEASED

- The page registers an Installation's runtimes with the integration proxy (#1700): each node's agent, published on an `InstallationRuntime` child, becomes a runtime of the Installation's app id via `POST /runtimes {app, agent, label}`, signed v2 with the user key. It happens when an Installation page or app frame opens and when a runtime child appears, and after a delegation. It is idempotent and does not hold up the UI; failures show as a toast. Revoking or uninstalling calls `DELETE /runtimes/{agent}`. A child counts only when its genesis was signed by the agent it names. Delegations and frame capabilities now name an Installation's `integrationAppAgent` rather than the node's agent from `GET /app-agent`, which stays the fallback for `createApp` apps and older Installations.
- App frames call the integration proxy directly, with a capability instead of a relay (ontola/atomic-plugins#54; replaces #1657's `proxy` relay and LocalThought's rotating connection codes, which the new proxy no longer issues). A frame makes its own non-extractable Ed25519 key in memory; the page checks that the connection is delegated to the app and signs a capability bound to that key (`integration-proxy-capability-v2`, 10 minutes); each request is signed by the frame key with a version 2 request signature. `store.proxy.request(...)` keeps its shape; it mints a new capability shortly before expiry and once more on a `capability_expired` refusal. View op `proxy` is gone, `proxyCapability` is new. Connecting no longer logs in at the proxy: the page redeems the handoff signed with the user key, which makes the user the connection's owner, then delegates it to the app (also signed). The connect bar offers an existing connection for the same platform, which only adds a delegation. Only apps with their own agent (made with `createApp`) can be given a connection for now; others are told so. Where WebCrypto has no Ed25519 the frame fails with a message saying so. Existing connections are not migrated: connect again.
- `@tomic/lib`: `signRequest(url, agent, headers, { method, body })` makes a version 2 request signature (ontola/atomic-plugins#54), which also covers the method and the SHA-256 of the body and sends `x-atomic-signature-version: 2`. Without `method` it signs version 1 as before, and a string fourth argument is still the legacy subject. New exports `requestSignatureMessageV2`, `sha256Hex` and `SIGNATURE_VERSION_HEADER`. `Agent.generateNonExtractable()` makes an agent whose Ed25519 key is a non-extractable WebCrypto key, with subject `atomic:agent:<public key>`; where WebCrypto has no Ed25519 it throws instead of falling back to an extractable key.
- Plugins moved to [atomic-plugins](https://github.com/ontola/atomic-plugins). The Integrations page lists what that catalog publishes (`catalog.json`, fetched at runtime; the URL is configurable under Settings > Integration) instead of plugins compiled into the app and server. The bundled GitHub issues, Notion, Pets, MT940/camt.053, Clockify and Google Calendar (Devonian) integrations, the `devonian` dependency and the `/app/devonian-demo` page are removed. The Todoist lens and the Moneybird administration picker are removed too. LocalThought connect and sync, and the "Show API plugins" toggle, are removed from the app: plugins will run in their own iframe and make proxy calls through the host.
- Tracker tables get an Issues view: open and closed issues split by the status column (or a boolean column), filtering by title or `#number`, New issue, and Close/Reopen per row. The Issue Tracker template opens it as a tab.
Expand Down
185 changes: 150 additions & 35 deletions browser/data-browser/src/chunks/AppPage/AppFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ import type { AIAtomicResourceMessageContext } from '@chunks/AI/types';
import resetCss from '../../reset.css?raw';
import { useCreateThemeVars } from '@views/PluginView/useCreateThemeVars';
import { getIntegrationProxy } from '@helpers/integrationProxy';
import { isPlatformId, ProxyConnections } from '@helpers/proxyConnections';
import {
isPlatformId,
ProxyConnections,
type ProxyConnection,
} from '@helpers/proxyConnections';
import { appAgentOf } from './appAgent';
import { registerRuntimesInBackground } from '@helpers/useInstallationRuntimes';

/** Changing installation or destination must discard source tokens and pending replies. */
export function AppFrame(props: Parameters<typeof AppFrameSession>[0]) {
Expand Down Expand Up @@ -110,6 +116,14 @@ function AppFrameSession({
};
}, [store, drive, app]);

// An Installation's nodes act for its app id at the proxy only once they
// are registered as its runtimes. Covers coming back from a connect
// handoff, which delegates and then reloads this page. A no-op for
// `createApp` apps.
useEffect(() => {
registerRuntimesInBackground(store, app);
}, [store, app]);

useEffect(() => {
if (!entrypoint) return;

Expand Down Expand Up @@ -188,14 +202,25 @@ function AppFrameSession({
// One question at a time; a second ask answers the first.
const previous = connectAskRef.current;
previous?.reply({ id: previous.id, result: { status: 'cancelled' } });
const ask = {
const ask: ConnectAsk = {
id: data.id,
platform: data.platform!,
reply: session.post,
};
connectAskRef.current = ask;
setConnectAsk(ask);

// Offer a connection the person already has for this platform, so
// using it for one more app needs no second trip through OAuth.
existingConnections(store, data.platform!)
.then(existing => {
if (connectAskRef.current !== ask || existing.length === 0) return;
const withExisting = { ...ask, existing };
connectAskRef.current = withExisting;
setConnectAsk(withExisting);
})
.catch(() => undefined);

return;
}

Expand Down Expand Up @@ -264,35 +289,65 @@ function AppFrameSession({
return <LoaderBlock />;
}

const finishAsk = (reply: HostReply) => {
connectAsk?.reply(reply);
connectAskRef.current = undefined;
setConnectAsk(undefined);
};

const connect = () => {
if (!connectAsk) return;
const actor = store.getAgent()?.subject;

if (!actor) {
connectAsk.reply({
id: connectAsk.id,
error: 'Sign in to connect an account.',
});
connectAskRef.current = undefined;
setConnectAsk(undefined);
if (!store.getAgent()) {
finishAsk({ id: connectAsk.id, error: 'Sign in to connect an account.' });

return;
}

new ProxyConnections(localStorage, getIntegrationProxy())
.start({ drive, actor, app }, connectAsk.platform, location.href)
(async () => {
const appAgent = await appAgentOf(store, { drive, app });

return proxyConnections(store).start(
{ drive, app, appAgent },
connectAsk.platform,
location.href,
await appLabel(store, app),
);
})()
.then(url => location.assign(url))
.catch((e: Error) => {
connectAsk.reply({ id: connectAsk.id, error: e.message });
connectAskRef.current = undefined;
setConnectAsk(undefined);
});
.catch((e: Error) => finishAsk({ id: connectAsk.id, error: e.message }));
};

const pickExisting = (connection: ProxyConnection) => {
if (!connectAsk) return;

(async () => {
const appAgent = await appAgentOf(store, { drive, app });
await proxyConnections(store).delegate(
connection.connection_id,
appAgent,
await appLabel(store, app),
);
})()
.then(() => {
// An Installation's nodes act for its app id only once registered
// as runtimes; not waited on, failures are toasted.
registerRuntimesInBackground(store, app);
finishAsk({
id: connectAsk.id,
result: {
status: 'connected',
connectionId: connection.connection_id,
platform: connection.platform,
},
});
})
.catch((e: Error) => finishAsk({ id: connectAsk.id, error: e.message }));
};

const cancelConnect = () => {
connectAsk?.reply({ id: connectAsk.id, result: { status: 'cancelled' } });
connectAskRef.current = undefined;
setConnectAsk(undefined);
if (!connectAsk) return;
finishAsk({ id: connectAsk.id, result: { status: 'cancelled' } });
};

const fixIt = () => {
Expand Down Expand Up @@ -335,13 +390,20 @@ function AppFrameSession({
{connectAsk && (
<ConnectBar role='group' aria-label='Connect an account'>
<ErrorText>
This app wants to connect your{' '}
This app wants to use your{' '}
<strong>{platformName(connectAsk.platform)}</strong> account through{' '}
{getIntegrationProxy()}. The connection stays in this browser; the
app can only make requests through it.
{getIntegrationProxy()}. The proxy keeps the connection under your
account; this app may use it until you revoke that.
</ErrorText>
<Row gap='0.5rem'>
<Button onClick={connect}>Connect</Button>
{connectAsk.existing?.[0] && (
<Button onClick={() => pickExisting(connectAsk.existing![0])}>
Use existing connection
</Button>
)}
<Button subtle={!!connectAsk.existing?.length} onClick={connect}>
Connect
</Button>
<Button subtle onClick={cancelConnect}>
Cancel
</Button>
Expand Down Expand Up @@ -397,31 +459,82 @@ async function answer(
drive,
request,
table,
proxyRelay(store, app, drive),
proxyHost(store, app, drive),
),
});
} catch (e) {
post({ id: request.id, error: (e as Error).message });
}
}

/** This app's proxy connections in this page, or none when signed out. */
function proxyRelay(
/** The configured proxy, managed with the signed-in user's key. */
function proxyConnections(store: ReturnType<typeof useStore>) {
return new ProxyConnections(localStorage, getIntegrationProxy(), () =>
store.getAgent(),
);
}

/**
* Each app's agent, looked up once per page. A failed lookup is forgotten so
* the next request tries again (the app may get an identity meanwhile).
*/
const appAgents = new Map<string, Promise<string>>();

function cachedAppAgent(
store: ReturnType<typeof useStore>,
drive: string,
app: string,
): Promise<string> {
const key = JSON.stringify([store.getServerUrl(), drive, app]);
let found = appAgents.get(key);

if (!found) {
found = appAgentOf(store, { drive, app });
found.catch(() => appAgents.delete(key));
appAgents.set(key, found);
}

return found;
}

/** This app's integration-proxy access, or none when signed out. */
function proxyHost(
store: ReturnType<typeof useStore>,
app: string,
drive: string,
) {
const actor = store.getAgent()?.subject;

return actor
? new ProxyConnections(localStorage, getIntegrationProxy()).relay({
drive,
actor,
app,
})
return store.getAgent()
? proxyConnections(store).host(() => cachedAppAgent(store, drive, app))
: undefined;
}

/** The person's own connections for `platform`, most recently used first. */
async function existingConnections(
store: ReturnType<typeof useStore>,
platform: string,
): Promise<ProxyConnection[]> {
if (!store.getAgent()) return [];
const rows = await proxyConnections(store).list(platform);

return rows.sort((a, b) =>
String(b.last_used_at ?? b.created_at ?? '').localeCompare(
String(a.last_used_at ?? a.created_at ?? ''),
),
);
}

/** What a delegation is labelled with at the proxy: the app's name. */
async function appLabel(
store: ReturnType<typeof useStore>,
app: string,
): Promise<string> {
try {
return (await store.getResource(app)).title || app;
} catch {
return app;
}
}

/** `pets` -> `Pets`, `github-issues` -> `Github Issues`. */
function platformName(id: string) {
return id
Expand All @@ -434,6 +547,8 @@ interface ConnectAsk {
id: number | string;
platform: string;
reply: (reply: HostReply) => void;
/** Connections the person already has for this platform. */
existing?: ProxyConnection[];
}

type MintResult = { ok: true; token: string } | { ok: false; error: string };
Expand Down
57 changes: 47 additions & 10 deletions browser/data-browser/src/chunks/AppPage/ProxyConnectReturn.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react';
import { StoreEvents, useStore, type Agent } from '@tomic/react';
import { getIntegrationProxy } from '@helpers/integrationProxy';
import { ProxyConnections } from '@helpers/proxyConnections';

Expand All @@ -8,23 +9,28 @@ import { ProxyConnections } from '@helpers/proxyConnections';
* The integration proxy only returns to `/app/integrations`, with
* `integration_state`. When that state is a handoff this browser started for
* an app (`AppFrame`'s connect bar), this takes over the page before any route
* renders — so no route's own return handling sees it — redeems the code,
* keeps the connection in this page's storage, and goes back to the app,
* which then finds the connection by reference. Any other page load renders
* `children` untouched.
* renders — so no route's own return handling sees it — redeems the code
* signed with the user key (which makes the user the connection's owner),
* delegates the connection to the app, and goes back to the app, which then
* finds the connection by reference. Any other page load renders `children`
* untouched.
*/
export function ProxyConnectReturn({
children,
}: {
children: ReactNode;
}): React.JSX.Element {
const store = useStore();
const [pending] = useState(() => {
const params = new URLSearchParams(location.search);

// Settings can hold a proxy value that no longer validates; that is never
// a return this browser started.
const connections = safely(
() => new ProxyConnections(localStorage, getIntegrationProxy()),
() =>
new ProxyConnections(localStorage, getIntegrationProxy(), () =>
store.getAgent(),
),
);

return connections?.isReturn(params) ? { connections, params } : undefined;
Expand All @@ -34,11 +40,23 @@ export function ProxyConnectReturn({
useEffect(() => {
if (!pending) return;
history.replaceState(history.state, '', location.pathname);
pending.connections
.finish(pending.params)
.then(({ returnTo }) => location.replace(returnTo))
.catch((e: Error) => setError(e.message));
}, [pending]);
let cancelled = false;

// Redeeming is signed with the user key, which may still be loading this
// early in a page load.
waitForAgent(store)
.then(() => pending.connections.finish(pending.params))
.then(({ returnTo }) => {
if (!cancelled) location.replace(returnTo);
})
.catch((e: Error) => {
if (!cancelled) setError(e.message);
});

return () => {
cancelled = true;
};
}, [pending, store]);

if (!pending) return <>{children}</>;

Expand All @@ -49,6 +67,25 @@ export function ProxyConnectReturn({
);
}

/** The signed-in agent, once there is one; gives up after 30 s. */
function waitForAgent(store: ReturnType<typeof useStore>): Promise<Agent> {
const now = store.getAgent();
if (now) return Promise.resolve(now);

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
off();
reject(new Error('Sign in to finish connecting this account.'));
}, 30_000);
const off = store.on(StoreEvents.AgentChanged, agent => {
if (!agent) return;
clearTimeout(timer);
off();
resolve(agent);
});
});
}

function safely<T>(make: () => T): T | undefined {
try {
return make();
Expand Down
Loading
Loading