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
5 changes: 5 additions & 0 deletions .changeset/blossom-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kernlize/core": minor
---

Applets can upload files (NAP-UPLOAD) through Blossom. `kernl.files` follows the account's file-server list (kind 10063) with well-known defaults. `createBlossomUploader` uses `blossom-client-sdk` to upload to the first server and mirror to the rest, succeeds when any server stores the file, and asks the person before each file leaves with a new `public` consent severity. The shell offers the `upload` domain.
15 changes: 15 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,18 @@ NZB, Soulseek and mail stay hand-off. Running them inside kernl needs something
**Consequences.** Each docs page exists twice in the artifact (37 pages today), with the same canonical address. The copy at `/docs/<page>/`, with the trailing slash, loads, but relative links inside it resolve one level too deep; nothing links to that form, and the gate scans the original so it does not report the copy. A pull request can be blocked by an outbound page that was removed since the last run; the fix is to update or drop the link. The live check runs after the deploy, so it reports a bad deploy rather than preventing one; the response is the rollback in D-019. If bunny.net later offers extension fallback or an Edge Script provides it, the copies become unnecessary but stay harmless.

**Reversal cost.** Low. Removing the copies is one call in `build-pages.mjs`; the gate and `servedFile` are independent of how clean URLs are provided, and a host with different rules changes only `pages-host.mjs`.

## D-033 · Applets upload through the shell to the account's own file servers, with Blossom's own client, and the person is asked before each file leaves

**Context.** Sixteen published applets require `upload` (NAP-UPLOAD), and the shell did not offer it. kehto ships the upload service and a reference HTTP uploader that sends a file to one server. Blossom, the file-server protocol most clients use, has a client SDK by its author that uploads to one server, mirrors to the others, checks first whether a server already holds the file, and handles the protocol's rejection and payment answers. BUD-03 gives every account a published list of its file servers (kind 10063).

**Decision.**

- **The wire is kehto's, the transfer is Blossom's.** `createUploadService` answers applets. Behind it, `createBlossomUploader` uses `blossom-client-sdk`'s `multiServerUpload`: the file goes to the first server and is mirrored to the rest. An upload is a success when any server stores it, and the result lists every copy (D-028). When none does it fails with `unavailable:not-accepted:0/<n>`.
- **The account decides where files go.** `kernl.files` follows the account's kind 10063 list and waits for the lookup to finish before falling back to `DEFAULT_FILE_SERVERS` (blossom.primal.net, nostr.download, blossom.band, which answered an upload preflight when chosen). An applet may not name a server. Settings shows the list and publishes changes to the account's outboxes and the lookup servers.
- **Every file is asked about.** Before anything is signed or sent, the person sees "Upload a file", marked `public`, with the file's name and type, its size, the servers it goes to, and "Anyone who has its address". The prompt appears once per file. One authorization (kind 24242) is then signed and covers every server. A read-only session is refused with `denied:read-only`, and an applet the shell cannot name is refused without asking.
- `ConsentSummary.severity` gains `public` for writes that put something where anyone can fetch it.

**Consequences.** Applets that need uploads now run, and their files land where the account's other clients look for them. A file the person allowed is public for good: Blossom has no private uploads, so the prompt says so.

**Reversal cost.** Low. The uploader is one module behind kehto's `Uploader` interface, so a host can pass its own.
5 changes: 5 additions & 0 deletions apps/web/features/preview-kernl.feature
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ Feature: The preview kernl
When the preview kernl saves inbox "" and outbox ""
Then the preview kernl save was accepted by 0 servers

Scenario: the preview's uploads go to the default file servers until it saves its own
Then the preview kernl file servers are the defaults
When the preview kernl saves file servers "https://mine.test/"
Then the preview kernl uploads go to "https://mine.test/"

Scenario: the preview's tiers can be replaced at once
When the preview kernl sets the "outbox" tier to "wss://a.test,wss://a.test"
Then the preview kernl "outbox" tier is "wss://a.test"
Expand Down
16 changes: 15 additions & 1 deletion apps/web/features/steps/kernl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { firstValueFrom, type Observable } from 'rxjs';
import { Given, Then, When } from 'quickpickle';
import type { IAccount } from 'applesauce-accounts';
import { PrivateKeyAccount, ReadonlyAccount } from 'applesauce-accounts/accounts';
import { BASELINE_DOMAINS } from '@kernlize/core';
import { BASELINE_DOMAINS, DEFAULT_FILE_SERVERS } from '@kernlize/core';
import { MemoryStorage } from '../../../../packages/core/features/steps/world.ts';
import { abbreviate, type AppletHandle, type AppletPointer, type AppletSummary, type ConsentPrompt, type FrameLike, type HandlerPrompt, type KernlHost, type MountPlan, type ShellKernl } from '../../src/shell/contract.ts';
import { PREVIEW_PUBLISHER, sampleSummary } from '../../src/shell/preview-applet.ts';
Expand Down Expand Up @@ -569,3 +569,17 @@ Then('the preview kernl {string} tier is {string}', (world: PreviewWorld, tier:
Then('the preview kernl refuses to set the {string} tier to {string}', (world: PreviewWorld, tier: 'outbox', urls: string) => {
expect(() => preview(world).kernl.relays.set(tier, list(urls))).toThrow('invalid:url');
});

Then('the preview kernl file servers are the defaults', async (world: PreviewWorld) => {
expect(preview(world).kernl.files.state()).toEqual({ status: 'missing', servers: DEFAULT_FILE_SERVERS });
expect(await preview(world).kernl.files.current()).toEqual(DEFAULT_FILE_SERVERS);
});

When('the preview kernl saves file servers {string}', async (world: PreviewWorld, servers: string) => {
expect(await preview(world).kernl.files.save(list(servers))).toMatchObject({ ok: true });
});

Then('the preview kernl uploads go to {string}', async (world: PreviewWorld, servers: string) => {
expect(await preview(world).kernl.files.current()).toEqual(list(servers));
expect(preview(world).kernl.files.state()).toMatchObject({ status: 'found', servers: list(servers) });
});
1 change: 1 addition & 0 deletions apps/web/src/shell/ConsentDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<span class="shell-badge">{shortId(summary.applet.hash)}</span>
{appletTitle} wants to
{#if summary.severity === 'destructive'}<span class="shell-badge warn">destructive</span>{/if}
{#if summary.severity === 'public'}<span class="shell-badge warn">public</span>{/if}
</p>
<h2 id="consent-title">{summary.title}</h2>
<dl id="consent-lines" class="lines">
Expand Down
123 changes: 123 additions & 0 deletions apps/web/src/shell/FileServerSettings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<script lang="ts">
import type { FileServerState } from '@kernlize/core';
import type { ShellKernl } from './contract';
import { observe } from './observe.svelte';
import { serverLabel } from './servers';

interface Props {
kernl: ShellKernl;
}

const { kernl }: Props = $props();

const published = observe<FileServerState>(kernl.files.state$, kernl.files.state());
$effect(() => published.stop);
const session = observe(kernl.session$, kernl.session);
$effect(() => session.stop);

let edited = $state.raw<readonly string[] | undefined>(undefined);
let typed = $state('');
let error = $state<string | null>(null);
let saved = $state<string | null>(null);
let saving = $state(false);

const current = $derived(edited ?? (published.value.status === 'loading' ? undefined : published.value.servers));
const readOnly = $derived(session.value?.readOnly !== false);
// A name that would say the protocol's name is replaced by the list's own (D-006).
const nameOf = (url: string, index: number): string => serverLabel(new URL(url).hostname, 'File', index);

function add(): void {
error = null;
saved = null;
const input = typed.trim();
let url: string;
try {
url = new URL(/^https?:\/\//i.test(input) ? input : `https://${input}`).origin + '/';
} catch {
error = 'Enter a file server address such as blossom.primal.net.';
return;
}
if (!url.includes('.')) {
error = 'Enter a file server address such as blossom.primal.net.';
return;
}
edited = [...new Set([...(current ?? []), url])];
typed = '';
}

function remove(url: string): void {
saved = null;
edited = (current ?? []).filter((entry) => entry !== url);
}

async function save(): Promise<void> {
if (!current) return;
saving = true;
error = null;
try {
const { describeOutcome } = await import('@kernlize/core');
const outcome = await kernl.files.save(current);
saved = describeOutcome(outcome);
if (outcome.ok) edited = undefined;
} catch {
error = 'The list could not be signed. Check that your signer is open, then try again.';
} finally {
saving = false;
}
}
</script>

<section class="shell-section" aria-labelledby="file-servers-heading">
<h3 id="file-servers-heading">File servers</h3>
<p class="shell-muted small">Where pictures and files you upload through an applet are kept. The first gets each file and the others keep copies. You are asked before every upload.</p>
{#if !current}
<p class="shell-muted" role="status">Looking for your file servers…</p>
{:else}
{#if published.value.status === 'missing' && !edited}<p class="small" role="status">This account has not chosen file servers yet, so these well-known ones are used.</p>{/if}
<ul class="shell-list urls" aria-label="File servers">
{#each current as url, index (url)}
{@const name = nameOf(url, index)}
<li data-address={url}>
<span class="server">{name}{index === 0 ? ' (first)' : ''}</span>
{#if !readOnly}<button type="button" class="shell-button quiet" onclick={() => remove(url)} aria-label="Remove {name} from file servers">Remove</button>{/if}
</li>
{:else}
<li class="shell-muted">None. Uploads are refused until you add one.</li>
{/each}
</ul>
{#if !readOnly}
<form class="add" onsubmit={(event) => (event.preventDefault(), add())}>
<label class="shell-field"><span>Add a file server</span><input class="shell-input mono" type="text" inputmode="url" autocomplete="off" spellcheck="false" bind:value={typed} placeholder="blossom.primal.net" /></label>
<button type="submit" class="shell-button" disabled={!typed.trim()}>Add</button>
</form>
<div class="shell-actions">
<button type="button" class="shell-button primary" onclick={save} disabled={saving || edited === undefined}>{saving ? 'Publishing…' : 'Publish file servers'}</button>
</div>
{/if}
{/if}
{#if saved}<p class="small" role="status">{saved}</p>{/if}
{#if error}<p class="shell-error" role="alert">{error}</p>{/if}
</section>

<style>
.small {
font-size: 0.85rem;
margin: 0;
}

.urls li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
max-width: none;
}

.add {
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
gap: 0.5rem;
}
</style>
2 changes: 2 additions & 0 deletions apps/web/src/shell/SettingsPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type { BootMode, RelayTier, ShellKernl } from './contract';
import type { DownloadMode, ShellDownloads } from './downloads';
import { toggleTheme } from '../lib/theme';
import FileServerSettings from './FileServerSettings.svelte';
import MailboxSettings from './MailboxSettings.svelte';
import MessagingSettings from './MessagingSettings.svelte';
import { observe } from './observe.svelte';
Expand Down Expand Up @@ -56,6 +57,7 @@

<Overlay title="Settings" {onclose} wide>
<MailboxSettings {kernl} {mode} />
<FileServerSettings {kernl} />
<section class="shell-section">
<h3>How the shell finds things</h3>
<p class="shell-muted small">Servers the shell itself asks, on your behalf; applets never connect to anything. Your own posts go to your outbox first.</p>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/shell/preview-kernl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { createPreviewBuiltins } from './preview-builtin';
import { PORT_DOMAINS, createPreviewPorts, type PortLike, type PreviewPorts } from './preview-port';
import { createPreviewIntents, type PreviewIntentDeps, type PreviewIntents } from './preview-intents';
import { createPreviewMessaging, PREVIEW_CORRESPONDENT, PREVIEW_LEGACY_CORRESPONDENT } from './preview-messaging';
import { createPreviewDirectory, createPreviewMailboxes } from './preview-mailboxes';
import { createPreviewDirectory, createPreviewFileServers, createPreviewMailboxes } from './preview-mailboxes';
import { createPreviewProfiles } from './preview-profiles';
import type { ShellTheme } from './shell-theme';
import { baselineDomains, createPreviewGrants, createPreviewRelays, loadBaselineDomains } from './preview-state';
Expand Down Expand Up @@ -413,6 +413,7 @@ export function createPreviewKernl(host: KernlHost, torrent?: TorrentBackend, op
catalog: { load: async () => PREVIEW_CATALOG, stream: () => of(PREVIEW_CATALOG) },
profiles,
mailboxes: createPreviewMailboxes(),
files: createPreviewFileServers(),
directory: createPreviewDirectory(),
intents: intents.intents,
session$,
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/shell/preview-mailboxes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BehaviorSubject, EMPTY } from 'rxjs';
import { createRelayDirectory, DEFAULT_SUGGESTIONS, type AccountMailboxes, type MailboxState, type RelayDirectory } from '@kernlize/core';
import { createRelayDirectory, DEFAULT_FILE_SERVERS, DEFAULT_SUGGESTIONS, type AccountFileServers, type AccountMailboxes, type FileServerState, type MailboxState, type RelayDirectory } from '@kernlize/core';

/** The preview's directory hears from no monitor, so it suggests from kernl's built-in list and completes nothing. */
export function createPreviewDirectory(): RelayDirectory {
Expand All @@ -23,3 +23,19 @@ export function createPreviewMailboxes(): AccountMailboxes {
},
};
}

/** The preview account's file servers: the defaults until it saves its own, kept in the page. */
export function createPreviewFileServers(): AccountFileServers {
let current: readonly string[] = DEFAULT_FILE_SERVERS;
const state$ = new BehaviorSubject<FileServerState>({ status: 'missing', servers: current });
return {
state$: state$.asObservable(),
state: () => state$.value,
current: async () => current,
async save(servers) {
current = [...servers];
state$.next({ status: 'found', servers: [...servers], event: { kind: 10063, tags: [], content: '', created_at: 0, pubkey: '', id: '', sig: '' } });
return { accepted: ['preview'], refused: [], ok: true };
},
};
}
5 changes: 5 additions & 0 deletions docs/packages/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ The host is the only way the core reaches the user or the page. Applets never re
| `guard: GuardFacade` | `config`, `profile$`, `setProfile(profile)`, `events$`, `mitigations()`, `release(windowId)`. `setProfile` rebuilds kernl's guard layers shell-wide; held window-level messages are dropped and counters restart. Open windows keep the runtime firewall rates they were mounted with. It is not persisted. |
| `grants: Grants` | The user-facing grants surface, see [Grants](#grants). |
| `relays: RelayTiers` | `get()`, `add(tier, url)`, `remove(tier, url)`, `set(tier, urls)`, `changes$`. `add` and `set` throw `invalid:relay-url` for anything that is not `ws://` or `wss://`; `set` with an unchanged list emits nothing. |
| `files: AccountFileServers` | Where the account's uploads go; the `upload` domain is offered and every upload is asked about (D-033). |
| `mailboxes: AccountMailboxes` | The signed-in account's published inbox and outbox (kind 10002, D-029). While a list is `found`, the `outbox` tier follows its outboxes, so every publish goes where the account says it writes. `save` signs with the active account and refuses `denied:read-only` for a read-only or signed-out session. |
| `directory: RelayDirectory` | Monitored servers (kind 30166) from `DEFAULT_MONITOR_RELAYS`, idle until `start()`; `search` completes an address and `suggest` proposes servers. Settings starts it while open. |
| `messaging`, `bridges` | The registries the services read. |
Expand Down Expand Up @@ -522,6 +523,10 @@ Signatures are abbreviated; the generated reference has the full ones.
| `publishAccepted` | `(pool: ApplesaucePool, relays: string[], event: NostrEvent) => Promise<number>` | Publishes everywhere and resolves with how many accepted; throws `unavailable:not-accepted:0/<n>` only when none did (D-028). |
| `lookupRelaysOf` | `(config) => string[]` | Where an unhinted replaceable is looked up: `discovery`, then `super`. |
| `createProfiles` | `(options: ProfilesOptions) => Profiles` | `card$(pubkey)` streams a `ProfileCard` (name, `https:` picture, address, about, `found`) and starts a lookup on the lookup relays, then on the author's own write relays when their relay list names servers the lookup relays lacked. Nothing times out; a late profile replaces the short key. `card(pubkey)` reads the store only. `settled(pubkey)` resolves once the profile is found or every lookup server has answered, never on a timer. |
| `createAccountFileServers` | `(options: AccountFileServersOptions) => AccountFileServers` | The account's file servers (BUD-03, kind 10063): `state$`, `state()` (`loading`, `missing` with the defaults, `found`), `current()` (waits only while the list is looked up), `save(servers) => Promise<PublishOutcome>` (publishes to the outboxes and lookup servers; `denied:read-only`, `invalid:server-url`). `kernl.files` is one (D-033). |
| `DEFAULT_FILE_SERVERS`, `fileServersFromEvent`, `fileServerTags` | constant and helpers | The fallback servers, the servers a list names in order, and the tags of a new list with every other tag kept. |
| `createBlossomUploader` | `(options: BlossomUploaderOptions) => Uploader` | kehto's `Uploader` over `blossom-client-sdk`: asks `ask(windowId, details)` once per file before anything is signed or sent, signs one authorization, uploads to the first server and mirrors to the rest; success when any server stores it, with every copy in `fallbackUrls` and NIP-94 tags. Refusals: `denied:read-only`, `denied:consent` (cancelled), `unavailable:no-file-server`, `unavailable:not-accepted:0/<n>`. `transfer` is injectable. |
| `describeUpload`, `askUploadConsent`, `sizeWords` | helpers | The `public` consent summary for an upload, the host prompt that refuses an unnamed applet without asking, and sizes in words. |
| `labelConversations` | `(adapter: DmAdapter, profiles: Pick<Profiles, 'settled'>) => DmAdapter` | Names every conversation participant the `dm` domain lists from their profile; a provider's own label is kept, and a person with no profile keeps none. `createKernlServices` applies it when given `profiles`, so messaging applets show names without looking anyone up. |
| `toProfileCard` | `(pubkey: string, event: NostrEvent \| undefined) => ProfileCard` | Display name, then name, then the abbreviated key. |
| `createDmRelayPool` | `(pool: ApplesaucePool, tiers: RelayTiers) => DmRelayPool` | For messaging backends. |
Expand Down
Loading
Loading