diff --git a/.changeset/blossom-upload.md b/.changeset/blossom-upload.md
new file mode 100644
index 0000000..038b28e
--- /dev/null
+++ b/.changeset/blossom-upload.md
@@ -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.
diff --git a/DECISIONS.md b/DECISIONS.md
index e8f1cec..488f046 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -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//`, 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/`.
+- **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.
diff --git a/apps/web/features/preview-kernl.feature b/apps/web/features/preview-kernl.feature
index afee5ee..18bcd6b 100644
--- a/apps/web/features/preview-kernl.feature
+++ b/apps/web/features/preview-kernl.feature
@@ -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"
diff --git a/apps/web/features/steps/kernl.ts b/apps/web/features/steps/kernl.ts
index 396c5ce..08c3b9a 100644
--- a/apps/web/features/steps/kernl.ts
+++ b/apps/web/features/steps/kernl.ts
@@ -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';
@@ -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) });
+});
diff --git a/apps/web/src/shell/ConsentDialog.svelte b/apps/web/src/shell/ConsentDialog.svelte
index ab75060..f76cbe5 100644
--- a/apps/web/src/shell/ConsentDialog.svelte
+++ b/apps/web/src/shell/ConsentDialog.svelte
@@ -32,6 +32,7 @@
{shortId(summary.applet.hash)}
{appletTitle} wants to
{#if summary.severity === 'destructive'}destructive{/if}
+ {#if summary.severity === 'public'}public{/if}
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.
{/if}
+
+
+
diff --git a/apps/web/src/shell/SettingsPanel.svelte b/apps/web/src/shell/SettingsPanel.svelte
index fb658ef..25a7bf1 100644
--- a/apps/web/src/shell/SettingsPanel.svelte
+++ b/apps/web/src/shell/SettingsPanel.svelte
@@ -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';
@@ -56,6 +57,7 @@
+
How the shell finds things
Servers the shell itself asks, on your behalf; applets never connect to anything. Your own posts go to your outbox first.
diff --git a/apps/web/src/shell/preview-kernl.ts b/apps/web/src/shell/preview-kernl.ts
index 8356471..9a7de35 100644
--- a/apps/web/src/shell/preview-kernl.ts
+++ b/apps/web/src/shell/preview-kernl.ts
@@ -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';
@@ -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$,
diff --git a/apps/web/src/shell/preview-mailboxes.ts b/apps/web/src/shell/preview-mailboxes.ts
index 8f24fbf..bf068e5 100644
--- a/apps/web/src/shell/preview-mailboxes.ts
+++ b/apps/web/src/shell/preview-mailboxes.ts
@@ -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 {
@@ -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({ 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 };
+ },
+ };
+}
diff --git a/docs/packages/core.md b/docs/packages/core.md
index 9f59b7b..ccaf881 100644
--- a/docs/packages/core.md
+++ b/docs/packages/core.md
@@ -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. |
@@ -522,6 +523,10 @@ Signatures are abbreviated; the generated reference has the full ones.
| `publishAccepted` | `(pool: ApplesaucePool, relays: string[], event: NostrEvent) => Promise` | Publishes everywhere and resolves with how many accepted; throws `unavailable:not-accepted:0/` 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` (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/`. `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) => 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. |
diff --git a/packages/core/features/file-uploads.feature b/packages/core/features/file-uploads.feature
new file mode 100644
index 0000000..ea624da
--- /dev/null
+++ b/packages/core/features/file-uploads.feature
@@ -0,0 +1,107 @@
+Feature: Uploading files
+ Applets upload through the shell (NAP-UPLOAD). The shell decides where a file
+ goes: the account's published file-server list (BUD-03, kind 10063), or
+ well-known servers while it has none. The person is asked once per file,
+ with what it is, its size and where it goes, before anything is signed or
+ sent. The file is uploaded to the first server and mirrored to the others
+ with the Blossom client SDK; any server that stores it makes the upload a
+ success (D-028, D-033).
+
+ Scenario: a file-server list reads in order, and a new one keeps its other tags
+ Then the file servers of a list naming "https://cdn.one.test,https://two.test/,https://cdn.one.test/" are "https://cdn.one.test/,https://two.test/"
+ And a list with no event names no file servers
+ And new file-server tags for "blossom.primal.net,https://two.test" over a list with a "client" tag are "server https://blossom.primal.net/|server https://two.test/|client kernl"
+ And new file-server tags for "not a server" are refused with "invalid:server-url"
+
+ Scenario: the account's servers are its published list, or the defaults once the lookup has answered
+ Given a file-server service for a signed-in account
+ Then the file servers are "loading"
+ When an upload asks for its servers while the list is being looked up
+ And the file-server lookup answers with nothing
+ Then the servers it was given are the defaults
+ Then the file servers are "missing" using the defaults
+ And the servers an upload uses are the defaults
+ When the account's list naming "https://mine.test" arrives
+ Then the file servers are "found" using "https://mine.test/"
+ And the servers an upload uses are "https://mine.test/"
+
+ Scenario: a lookup that fails leaves the defaults
+ Given a file-server service for a signed-in account
+ When the file-server lookup fails
+ Then the file servers are "missing" using the defaults
+
+ Scenario: with nobody signed in the defaults stand, and saving is refused
+ Given a file-server service with nobody signed in
+ Then the file servers are "missing" using the defaults
+ When the account saves file servers "https://mine.test"
+ Then the shell refused with "denied:read-only"
+
+ Scenario: saving publishes to the outboxes and lookup servers, and two of three is a success
+ Given a file-server service for a signed-in account
+ And "wss://lookup.test" refuses the file-server list
+ When the account saves file servers "https://mine.test,https://second.test"
+ Then the file-server save was accepted by "wss://out.test/,wss://out2.test/" and refused by "wss://lookup.test/"
+ And the file servers are "found" using "https://mine.test/,https://second.test/"
+ Given every server refuses the file-server list
+ When the account saves file servers "https://third.test"
+ Then the file-server save was accepted by nobody
+ And the file servers are "found" using "https://mine.test/,https://second.test/"
+
+ Scenario: a file-server list nobody can be sent is still a failure, not an error
+ Given a file-server service for a signed-in account
+ And publishing fails outright
+ When the account saves file servers "https://mine.test"
+ Then the file-server save was accepted by nobody
+
+ Scenario: the person is asked what, how big and where, and nothing is signed or sent without a yes
+ Given an uploader over "https://one.test/,https://two.test/" where the person says "no"
+ When an applet uploads a 2048-byte "image/png" file named "cat.png"
+ Then the upload was "cancelled" with "denied:consent"
+ And the person was asked about "cat.png, image/png", "2.0 KB" going to "one.test, two.test"
+ And nothing was signed or sent
+
+ Scenario: an allowed upload is signed once, stored on the first server, mirrored, and lists every copy
+ Given an uploader over "https://one.test/,https://two.test/" where the person says "yes"
+ When an applet uploads a 10-byte "text/plain" file
+ Then the upload is complete at "https://one.test/" with a copy at "https://two.test/"
+ And the authorization was signed 1 times
+ And the upload's file tags carry its address, hash, type and size
+
+ Scenario: a copy on one of two servers is a success; none is a failure that says how many were tried
+ Given an uploader over "https://one.test/,https://two.test/" where the person says "yes"
+ And "https://one.test/" refuses the file
+ When an applet uploads a 10-byte "text/plain" file
+ Then the upload is complete at "https://two.test/" with no copies
+ Given every file server refuses the file
+ When an applet uploads a 10-byte "text/plain" file
+ Then the upload was "failed" with "unavailable:not-accepted:0/2"
+ Given the transfer breaks
+ When an applet uploads a 10-byte "text/plain" file
+ Then the upload was "failed" with "unavailable:not-accepted:0/2"
+
+ Scenario: raw bytes without a type, a read-only session, and no servers at all
+ Given an uploader over "https://one.test/" where the person says "yes"
+ When an applet uploads 4 raw bytes without a type
+ Then the person was asked about "application/octet-stream", "4 bytes" going to "one.test"
+ Given the uploader has no signer
+ When an applet uploads a 10-byte "text/plain" file
+ Then the upload was "failed" with "denied:read-only"
+ Given an uploader over "" where the person says "yes"
+ When an applet uploads a 10-byte "text/plain" file
+ Then the upload was "failed" with "unavailable:no-file-server"
+
+ Scenario Outline: sizes read the way a person reads them
+ Then bytes read ""
+
+ Examples:
+ | bytes | words |
+ | 1 | 1 byte |
+ | 512 | 512 bytes |
+ | 1536 | 1.5 KB |
+ | 3355443 | 3.2 MB |
+ | 5368709120 | 5.0 GB |
+ | 5497558138880 | 5120.0 GB |
+
+ Scenario: the prompt goes through the host, and an applet the shell cannot name is refused unasked
+ Then asking the host about an upload from a known applet presents "Upload a file" marked "public"
+ And asking the host about an upload from an unknown applet answers no without asking
diff --git a/packages/core/features/kernl.feature b/packages/core/features/kernl.feature
index 51863f9..969ca3b 100644
--- a/packages/core/features/kernl.feature
+++ b/packages/core/features/kernl.feature
@@ -160,6 +160,23 @@ Feature: The composed shell
And the shell saves the account's inbox and outbox as "wss://own.test"
Then the shell's last save was accepted by "wss://own.test/"
+ Scenario: an applet granted uploads asks the person before a file leaves, and a no cancels it
+ Given a kernl shell over that host and pool
+ Then the shell offers the "upload" domain
+ When the shell logs in with a password account named "alice"
+ And the shell resolves the manifest address
+ And the shell allows "upload" for the resolved applet
+ And the shell plans the summary
+ And the shell mounts the plan into a fake frame
+ And the applet sends its hello through the shell
+ And the applet asks "shell.ready" over its port
+ And the applet uploads an 8-byte "image/png" file over its port
+ Then the shell asks the person "Upload a file" marked "public", sending it to "blossom.primal.net, nostr.download, blossom.band"
+ When the person answers no to the upload
+ Then the applet is told its upload was "cancelled" with "denied:consent"
+ When the shell saves the account's file servers as "https://mine.test"
+ Then the shell's file servers are "https://mine.test/"
+
Scenario: an applet is resolved, planned and mounted
Given a kernl shell over that host and pool
When the shell resolves the manifest address
diff --git a/packages/core/features/steps/catalog.ts b/packages/core/features/steps/catalog.ts
index f49d8c7..1e74c0c 100644
--- a/packages/core/features/steps/catalog.ts
+++ b/packages/core/features/steps/catalog.ts
@@ -279,7 +279,7 @@ Given('the fake pool streams catalogue manifests {string} seen on {string}, then
at += 1;
if (event) observer.next(event);
else observer.error(new Error('relay closed'));
- }, 5);
+ }, 25);
return () => clearInterval(timer);
})) as typeof pool.request;
});
diff --git a/packages/core/features/steps/dm-labels.ts b/packages/core/features/steps/dm-labels.ts
index 2e54d95..f7989c6 100644
--- a/packages/core/features/steps/dm-labels.ts
+++ b/packages/core/features/steps/dm-labels.ts
@@ -19,7 +19,7 @@ function participant(spec: string): { pubkey: string; label?: string } {
}
Given('a conversation adapter listing a conversation with {string}', (world: CoreWorld, specs: string) => {
- const conversation = { id: 'c1', kind: 'direct', participants: splitList(specs).map(participant) } as unknown as DmConversation;
+ const conversation: DmConversation = { id: 'c1', kind: 'direct', participants: splitList(specs).map(participant), unread: 0 };
const adapter: DmAdapter = {
status: () => ({ available: true }) as never,
conversations: async () => ({ conversations: [conversation] }) as DmConversationPage,
diff --git a/packages/core/features/steps/file-uploads.ts b/packages/core/features/steps/file-uploads.ts
new file mode 100644
index 0000000..df36310
--- /dev/null
+++ b/packages/core/features/steps/file-uploads.ts
@@ -0,0 +1,293 @@
+import { expect } from 'vitest';
+import { Given, Then, When } from 'quickpickle';
+import { Subject, type Observable } from 'rxjs';
+import { EventStore } from 'applesauce-core';
+import { PrivateKeySigner } from 'applesauce-signers';
+import type { BlobDescriptor } from 'blossom-client-sdk';
+import type { UploadResult, UploadStatus } from '@kehto/services';
+import type { NostrEvent } from '@napplet/core';
+import {
+ askUploadConsent,
+ createAccountFileServers,
+ createBlossomUploader,
+ DEFAULT_FILE_SERVERS,
+ fileServersFromEvent,
+ fileServerTags,
+ sizeWords,
+ type AccountFileServers,
+ type ConsentPrompt,
+ type MultiServerUpload,
+ type PublishOutcome,
+ type UploadDetails,
+} from '@kernlize/core';
+import { attempt, scratch, splitList, type CoreWorld } from './world.js';
+
+interface UploadScratch {
+ files?: AccountFileServers;
+ feed?: Subject;
+ store?: EventStore;
+ signer?: PrivateKeySigner;
+ refusing: Set;
+ refuseAll: boolean;
+ publishBreaks: boolean;
+ saved?: PublishOutcome;
+ servers: string[];
+ allow: boolean;
+ noSigner: boolean;
+ asked: UploadDetails[];
+ signed: number;
+ sent: number;
+ storing: Set | 'all';
+ breaks: boolean;
+ result?: UploadResult;
+}
+
+const u = (world: CoreWorld): UploadScratch => {
+ const state = scratch(world);
+ state.uploads ??= { refusing: new Set(), refuseAll: false, publishBreaks: false, servers: [], allow: true, noSigner: false, asked: [], signed: 0, sent: 0, storing: 'all', breaks: false } satisfies UploadScratch;
+ return state.uploads as UploadScratch;
+};
+
+const IDENTITY = { dTag: 'gallery', hash: 'ab'.repeat(32) };
+
+async function fileServers(world: CoreWorld, signedIn: boolean): Promise {
+ const state = u(world);
+ const store = new EventStore();
+ const signer = new PrivateKeySigner();
+ const feed = new Subject();
+ const pubkey = await signer.getPublicKey();
+ state.store = store;
+ state.signer = signer;
+ state.feed = feed;
+ const pubkey$ = new Subject();
+ state.files = createAccountFileServers({
+ store,
+ pool: {
+ publish: async (relays) => {
+ if (state.publishBreaks) throw new Error('network down');
+ return (relays as string[]).map((from) => ({ from, ok: !state.refuseAll && !state.refusing.has(from), message: 'blocked' }));
+ },
+ } as never,
+ load: () => feed as Observable,
+ pubkey$,
+ pubkey: () => (signedIn ? pubkey : undefined),
+ lookupRelays: () => ['wss://lookup.test'],
+ outboxes: () => ['wss://out.test', 'wss://out2.test'],
+ signer: () => (signedIn ? signer : undefined),
+ });
+ pubkey$.next(signedIn ? pubkey : undefined);
+}
+
+Then('the file servers of a list naming {string} are {string}', (_world: CoreWorld, named: string, expected: string) => {
+ const event = { kind: 10063, tags: splitList(named).map((url) => ['server', url]) } as NostrEvent;
+ expect(fileServersFromEvent(event)).toEqual(splitList(expected));
+});
+
+Then('a list with no event names no file servers', () => {
+ expect(fileServersFromEvent(undefined)).toEqual([]);
+});
+
+Then('new file-server tags for {string} over a list with a {string} tag are {string}', (_world: CoreWorld, servers: string, tag: string, expected: string) => {
+ const previous = { tags: [['server', 'wss-free.test'], [tag, 'kernl']] } as NostrEvent;
+ expect(fileServerTags(splitList(servers), previous).map((entry) => entry.join(' '))).toEqual(expected.split('|'));
+});
+
+Then('new file-server tags for {string} are refused with {string}', (_world: CoreWorld, servers: string, reason: string) => {
+ expect(() => fileServerTags(splitList(servers))).toThrow(reason);
+});
+
+Given('a file-server service for a signed-in account', (world: CoreWorld) => fileServers(world, true));
+Given('a file-server service with nobody signed in', (world: CoreWorld) => fileServers(world, false));
+
+When('the file-server lookup answers with nothing', (world: CoreWorld) => {
+ u(world).feed?.complete();
+});
+
+When("the account's list naming {string} arrives", async (world: CoreWorld, servers: string) => {
+ const state = u(world);
+ const event = await state.signer?.signEvent({ kind: 10063, created_at: 1_700_000_000, tags: splitList(servers).map((url) => ['server', url]), content: '' });
+ state.store?.add(event as NostrEvent);
+});
+
+Then('the file servers are {string}', (world: CoreWorld, status: string) => {
+ expect(u(world).files?.state().status).toBe(status);
+});
+
+Then('the file servers are {string} using the defaults', (world: CoreWorld, status: string) => {
+ expect(u(world).files?.state()).toEqual({ status, servers: DEFAULT_FILE_SERVERS });
+});
+
+Then('the file servers are {string} using {string}', (world: CoreWorld, status: string, servers: string) => {
+ expect(u(world).files?.state()).toMatchObject({ status, servers: splitList(servers) });
+});
+
+Then('the servers an upload uses are the defaults', async (world: CoreWorld) => {
+ expect(await u(world).files?.current()).toEqual(DEFAULT_FILE_SERVERS);
+});
+
+Then('the servers an upload uses are {string}', async (world: CoreWorld, servers: string) => {
+ expect(await u(world).files?.current()).toEqual(splitList(servers));
+});
+
+Given('{string} refuses the file-server list', (world: CoreWorld, relay: string) => {
+ u(world).refusing.add(relay);
+});
+
+Given('every server refuses the file-server list', (world: CoreWorld) => {
+ u(world).refuseAll = true;
+});
+
+Given('publishing fails outright', (world: CoreWorld) => {
+ u(world).publishBreaks = true;
+});
+
+When('the account saves file servers {string}', async (world: CoreWorld, servers: string) => {
+ await attempt(world, async () => {
+ u(world).saved = await u(world).files?.save(splitList(servers));
+ });
+});
+
+Then('the file-server save was accepted by {string} and refused by {string}', (world: CoreWorld, accepted: string, refused: string) => {
+ expect(u(world).saved).toMatchObject({ ok: true, accepted: splitList(accepted) });
+ expect(u(world).saved?.refused.map((entry) => entry.url)).toEqual(splitList(refused));
+});
+
+Then('the file-server save was accepted by nobody', (world: CoreWorld) => {
+ expect(u(world).saved).toMatchObject({ ok: false, accepted: [] });
+});
+
+function transfer(world: CoreWorld): MultiServerUpload {
+ return async (servers, blob, options) => {
+ const state = u(world);
+ if (state.breaks) throw new Error('network down');
+ const stored = new Map();
+ for (const server of servers) {
+ await options.onAuth(server, 'cd'.repeat(32), 'upload');
+ state.sent += 1;
+ if (state.storing !== 'all' && !state.storing.has(server)) {
+ options.onError(server, 'cd'.repeat(32), blob, new Error('rejected'));
+ continue;
+ }
+ stored.set(server, { url: `${server}${'cd'.repeat(32)}`, sha256: 'cd'.repeat(32), size: blob.size, uploaded: 1, ...(server.includes('two') ? {} : { type: blob.type || undefined }) } as BlobDescriptor);
+ options.onUpload(server);
+ }
+ return stored;
+ };
+}
+
+Given('an uploader over {string} where the person says {string}', (world: CoreWorld, servers: string, answer: string) => {
+ const state = u(world);
+ state.servers = splitList(servers);
+ state.allow = answer === 'yes';
+ state.noSigner = false;
+ state.signer = new PrivateKeySigner();
+});
+
+Given('{string} refuses the file', (world: CoreWorld, server: string) => {
+ u(world).storing = new Set(u(world).servers.filter((entry) => entry !== server));
+});
+
+Given('every file server refuses the file', (world: CoreWorld) => {
+ u(world).storing = new Set();
+});
+
+Given('the transfer breaks', (world: CoreWorld) => {
+ u(world).breaks = true;
+});
+
+Given('the uploader has no signer', (world: CoreWorld) => {
+ u(world).noSigner = true;
+});
+
+async function upload(world: CoreWorld, data: Blob | ArrayBuffer, extra: { mimeType?: string; filename?: string } = {}): Promise {
+ const state = u(world);
+ const signer = state.signer as PrivateKeySigner;
+ const uploader = createBlossomUploader({
+ servers: async () => state.servers,
+ signer: () => (state.noSigner ? undefined : { signEvent: (draft) => ((state.signed += 1), signer.signEvent(draft)) }),
+ ask: async (_windowId, details) => (state.asked.push(details), state.allow),
+ transfer: transfer(world),
+ });
+ const statuses: UploadStatus[] = [];
+ state.result = await uploader.upload({ data, ...extra }, { uploadId: 'u1', windowId: 'w1', onStatus: (status) => statuses.push(status) });
+}
+
+When('an applet uploads a {int}-byte {string} file named {string}', (world: CoreWorld, size: number, type: string, filename: string) =>
+ upload(world, new Blob([new Uint8Array(size)], { type }), { filename }),
+);
+
+When('an applet uploads a {int}-byte {string} file', (world: CoreWorld, size: number, type: string) => upload(world, new Blob([new Uint8Array(size)], { type })));
+
+When('an applet uploads {int} raw bytes without a type', (world: CoreWorld, size: number) => upload(world, new Uint8Array(size).buffer));
+
+Then('the upload was {string} with {string}', (world: CoreWorld, status: string, error: string) => {
+ expect(u(world).result).toMatchObject({ ok: false, status, error, rail: 'blossom' });
+});
+
+Then('the person was asked about {string}, {string} going to {string}', (world: CoreWorld, file: string, size: string, hosts: string) => {
+ const details = u(world).asked.at(-1) as UploadDetails;
+ const summary = askedSummary(details);
+ expect(summary.lines.map((line) => line.after)).toEqual([file, size, hosts, 'Anyone who has its address']);
+});
+
+function askedSummary(details: UploadDetails) {
+ let presented: ConsentPrompt | undefined;
+ void askUploadConsent({ presentConsent: (prompt) => void (presented = prompt) }, IDENTITY, details);
+ return (presented as ConsentPrompt).summary;
+}
+
+Then('nothing was signed or sent', (world: CoreWorld) => {
+ expect(u(world).signed).toBe(0);
+ expect(u(world).sent).toBe(0);
+});
+
+Then('the upload is complete at {string} with a copy at {string}', (world: CoreWorld, first: string, copy: string) => {
+ expect(u(world).result).toMatchObject({ ok: true, status: 'complete', url: `${first}${'cd'.repeat(32)}`, fallbackUrls: [`${copy}${'cd'.repeat(32)}`], sha256: 'cd'.repeat(32), size: 10, mimeType: 'text/plain' });
+});
+
+Then('the upload is complete at {string} with no copies', (world: CoreWorld, first: string) => {
+ expect(u(world).result).toMatchObject({ ok: true, url: `${first}${'cd'.repeat(32)}`, mimeType: 'text/plain' });
+ expect(u(world).result?.fallbackUrls).toBeUndefined();
+});
+
+Then('the authorization was signed {int} times', (world: CoreWorld, count: number) => {
+ expect(u(world).signed).toBe(count);
+});
+
+Then("the upload's file tags carry its address, hash, type and size", (world: CoreWorld) => {
+ const tags = u(world).result?.nip94 ?? [];
+ expect(tags.map((tag) => tag[0])).toEqual(['url', 'x', 'm', 'size', 'fallback']);
+ expect(tags.find((tag) => tag[0] === 'size')?.[1]).toBe('10');
+});
+
+Then('{int} bytes read {string}', (_world: CoreWorld, bytes: number, words: string) => {
+ expect(sizeWords(bytes)).toBe(words);
+});
+
+Then('asking the host about an upload from a known applet presents {string} marked {string}', async (_world: CoreWorld, title: string, severity: string) => {
+ const details: UploadDetails = { mimeType: 'image/png', size: 1, sha256: 'cd'.repeat(32), servers: ['https://one.test/'] };
+ let presented: ConsentPrompt | undefined;
+ const answer = askUploadConsent({ presentConsent: (prompt) => void (presented = prompt) }, IDENTITY, details);
+ expect(presented?.summary).toMatchObject({ title, severity, applet: IDENTITY, event: { kind: 24242, tags: [['t', 'upload'], ['x', 'cd'.repeat(32)]] } });
+ presented?.resolve(true);
+ expect(await answer).toBe(true);
+});
+
+Then('asking the host about an upload from an unknown applet answers no without asking', async () => {
+ let asked = false;
+ const details: UploadDetails = { mimeType: 'image/png', size: 1, sha256: 'cd'.repeat(32), servers: [] };
+ expect(await askUploadConsent({ presentConsent: () => void (asked = true) }, undefined, details)).toBe(false);
+ expect(asked).toBe(false);
+});
+
+When('an upload asks for its servers while the list is being looked up', (world: CoreWorld) => {
+ scratch(world).askedServers = u(world).files?.current();
+});
+
+Then('the servers it was given are the defaults', async (world: CoreWorld) => {
+ expect(await (scratch(world).askedServers as Promise)).toEqual(DEFAULT_FILE_SERVERS);
+});
+
+When('the file-server lookup fails', (world: CoreWorld) => {
+ u(world).feed?.error(new Error('relays down'));
+});
diff --git a/packages/core/features/steps/index.ts b/packages/core/features/steps/index.ts
index ba83992..80ebf44 100644
--- a/packages/core/features/steps/index.ts
+++ b/packages/core/features/steps/index.ts
@@ -37,3 +37,4 @@ import './event-cache-sqlite.js';
import './shell-event-cache.js';
import './kernl-network.js';
import './dm-labels.js';
+import './file-uploads.js';
diff --git a/packages/core/features/steps/kernl-network.ts b/packages/core/features/steps/kernl-network.ts
index 2b36bc0..ce4f104 100644
--- a/packages/core/features/steps/kernl-network.ts
+++ b/packages/core/features/steps/kernl-network.ts
@@ -1,9 +1,9 @@
-import { expect } from 'vitest';
+import { expect, vi } from 'vitest';
import { Given, Then, When } from 'quickpickle';
import { ReadonlyAccount } from 'applesauce-accounts/accounts';
import { PrivateKeySigner } from 'applesauce-signers';
import type { RelayTier } from '@kernlize/core';
-import { k } from './kernl.js';
+import { k, settle } from './kernl.js';
import { attempt, scratch, splitList, type CoreWorld } from './world.js';
Given('the fake pool serves a profile named {string} for a fresh key', async (world: CoreWorld, name: string) => {
@@ -52,3 +52,33 @@ Then("the shell's last save was accepted by {string}", (world: CoreWorld, urls:
expect(scratch(world).mailboxSave).toMatchObject({ ok: true, refused: [] });
expect((scratch(world).mailboxSave as { accepted: string[] }).accepted).toEqual(expect.arrayContaining(splitList(urls)));
});
+
+When('the applet uploads an {int}-byte {string} file over its port', async (world: CoreWorld, size: number, mimeType: string) => {
+ k(world).channel?.port1.postMessage({ type: 'upload.upload', id: 'upload-1', request: { data: new Uint8Array(size).buffer, mimeType } });
+ await vi.waitFor(() => expect(k(world).record.prompts.length).toBeGreaterThan(0), { timeout: 3000 });
+});
+
+Then('the shell asks the person {string} marked {string}, sending it to {string}', (world: CoreWorld, title: string, severity: string, hosts: string) => {
+ const summary = k(world).record.prompts.at(-1)?.summary;
+ expect(summary).toMatchObject({ title, severity });
+ expect(summary?.lines.find((line) => line.label === 'Sent to')?.after).toBe(hosts);
+});
+
+When('the person answers no to the upload', async (world: CoreWorld) => {
+ k(world).record.prompts.at(-1)?.resolve(false);
+ await settle();
+});
+
+Then('the applet is told its upload was {string} with {string}', async (world: CoreWorld, status: string, error: string) => {
+ await vi.waitFor(() => expect(k(world).received.some((message) => message.type === 'upload.upload.result')).toBe(true), { timeout: 3000 });
+ const reply = k(world).received.find((message) => message.type === 'upload.upload.result') as { result?: { status: string; error: string } };
+ expect(reply.result).toMatchObject({ status, error });
+});
+
+When("the shell saves the account's file servers as {string}", async (world: CoreWorld, servers: string) => {
+ await k(world).kernl.files.save(splitList(servers));
+});
+
+Then("the shell's file servers are {string}", (world: CoreWorld, servers: string) => {
+ expect(k(world).kernl.files.state()).toMatchObject({ status: 'found', servers: splitList(servers) });
+});
diff --git a/packages/core/features/steps/services.ts b/packages/core/features/steps/services.ts
index 07689ef..eee015f 100644
--- a/packages/core/features/steps/services.ts
+++ b/packages/core/features/steps/services.ts
@@ -243,6 +243,8 @@ Then('the adapter creates no windows, has no worker relay, forwards no hotkeys a
const a = adapter(world);
expect(a.windowManager.createWindow({ title: 'x', class: 'y' })).toBeNull();
expect(a.workerRelay.getWorkerRelay()).toBeNull();
+ // Without an upload service there is no upload backend, so kehto does not offer the domain.
+ expect(a.upload?.getUploader()).toBe(a.services?.upload ? { rails: ['blossom'] } : null);
expect(a.hotkeys.executeHotkeyFromForward({ key: 'a', code: 'KeyA', ctrlKey: false, altKey: false, shiftKey: false, metaKey: false })).toBeUndefined();
expect(a.config.getNappUpdateBehavior()).toBe('banner');
expect(a.auth.getUserPubkey()).toBeNull();
diff --git a/packages/core/package.json b/packages/core/package.json
index 3dd2508..c723034 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -59,6 +59,7 @@
"applesauce-relay": "^7.0.0",
"applesauce-signers": "^7.0.1",
"applesauce-sqlite": "^7.0.0",
+ "blossom-client-sdk": "5.1.0",
"nostr-double-ratchet": "^0.0.183",
"rxjs": "^7.8.2"
},
diff --git a/packages/core/src/files/file-servers.ts b/packages/core/src/files/file-servers.ts
new file mode 100644
index 0000000..ced6f04
--- /dev/null
+++ b/packages/core/src/files/file-servers.ts
@@ -0,0 +1,143 @@
+import { BehaviorSubject, catchError, EMPTY, endWith, firstValueFrom, ignoreElements, map, of, switchMap, type Observable } from 'rxjs';
+import type { EventStore } from 'applesauce-core';
+import { unixNow } from 'applesauce-core/helpers';
+import { BLOSSOM_SERVER_LIST_KIND, getBlossomServersFromList, normalizeBlossomServer } from 'applesauce-common/helpers';
+import type { ISigner } from 'applesauce-signers';
+import type { NostrEvent } from '@napplet/core';
+import type { ApplesaucePool } from '../applesauce/relay-hooks.js';
+import { summarizePublish, type PublishOutcome } from '../relays/publish-outcome.js';
+import type { ProfilePointer } from '../profiles/profiles.js';
+
+/**
+ * File servers to use when the account has not published its own list: public
+ * Blossom servers that answered an upload preflight when this was chosen.
+ *
+ * @example
+ * ```ts
+ * DEFAULT_FILE_SERVERS[0]; // 'https://blossom.primal.net/'
+ * ```
+ */
+export const DEFAULT_FILE_SERVERS: readonly string[] = Object.freeze(['https://blossom.primal.net/', 'https://nostr.download/', 'https://blossom.band/']);
+
+/** Where the account's files go: its published list (BUD-03, kind 10063), or the defaults while it has none. */
+export type FileServerState =
+ | { readonly status: 'loading' }
+ | { readonly status: 'missing'; readonly servers: readonly string[] }
+ | { readonly status: 'found'; readonly servers: readonly string[]; readonly event: NostrEvent };
+
+/** The signed-in account's file servers. */
+export interface AccountFileServers {
+ readonly state$: Observable;
+ state(): FileServerState;
+ /** The servers an upload goes to now: the published list once it is known, else the defaults. Waits only while the list is being looked up. */
+ current(): Promise;
+ /** Publish a new list. Rejects with `denied:read-only` or `invalid:server-url`. */
+ save(servers: readonly string[]): Promise;
+}
+
+/** Options for {@link createAccountFileServers}. */
+export interface AccountFileServersOptions {
+ readonly store: EventStore;
+ readonly pool: Pick;
+ /** Loads a replaceable event into the store and completes when the servers asked have answered. */
+ readonly load: (pointer: ProfilePointer) => Observable;
+ readonly pubkey$: Observable;
+ readonly pubkey: () => string | undefined;
+ /** Where the list is looked up, and published to besides the account's outboxes. */
+ readonly lookupRelays: () => readonly string[];
+ /** Where the account publishes. */
+ readonly outboxes: () => readonly string[];
+ readonly signer: () => Pick | undefined;
+ /** The servers used when no list is published; {@link DEFAULT_FILE_SERVERS} by default. */
+ readonly defaults?: readonly string[];
+}
+
+/**
+ * The servers a file-server list names, in its order, as root URLs.
+ *
+ * @param event - A kind 10063 list, or nothing.
+ * @returns Server URLs, deduplicated.
+ * @example
+ * ```ts
+ * fileServersFromEvent(list); // ['https://blossom.primal.net/']
+ * ```
+ */
+export function fileServersFromEvent(event: NostrEvent | undefined): readonly string[] {
+ return event ? [...new Set(getBlossomServersFromList(event).map((url) => url.href))] : [];
+}
+
+/**
+ * The tags of a new file-server list: one `server` tag per server, in order,
+ * with every other tag of the previous list kept.
+ *
+ * @param servers - Server URLs or hostnames.
+ * @param previous - The list being replaced.
+ * @returns Tags for a kind 10063 event. Throws `invalid:server-url` for something that is not a server.
+ * @example
+ * ```ts
+ * fileServerTags(['blossom.primal.net']); // [['server', 'https://blossom.primal.net/']]
+ * ```
+ */
+export function fileServerTags(servers: readonly string[], previous?: NostrEvent): string[][] {
+ const urls = servers.map((server) => {
+ try {
+ return normalizeBlossomServer(server).href;
+ } catch {
+ throw new Error('invalid:server-url');
+ }
+ });
+ const kept = (previous?.tags ?? []).filter((tag) => tag[0] !== 'server');
+ return [...[...new Set(urls)].map((url) => ['server', url]), ...kept];
+}
+
+function stateFor(options: AccountFileServersOptions, pubkey: string | undefined): Observable {
+ const defaults = options.defaults ?? DEFAULT_FILE_SERVERS;
+ if (pubkey === undefined) return of({ status: 'missing', servers: defaults });
+ // The list is looked up until every server asked has answered; only then do the defaults stand in.
+ const answered = options.load({ kind: BLOSSOM_SERVER_LIST_KIND, pubkey, relays: [...options.lookupRelays()] }).pipe(catchError(() => EMPTY), ignoreElements(), endWith(true));
+ return options.store.replaceable(BLOSSOM_SERVER_LIST_KIND, pubkey).pipe(
+ switchMap((event) => {
+ if (event) return of({ status: 'found', servers: fileServersFromEvent(event), event });
+ return answered.pipe(map((): FileServerState => ({ status: 'missing', servers: defaults })));
+ }),
+ );
+}
+
+/**
+ * Follow the active account's file-server list (BUD-03) and save a new one.
+ *
+ * Uploads go to these servers in order: the first gets the file, the rest
+ * mirror it. An account with no published list uses the defaults. Saving signs
+ * the list and publishes it to the account's outboxes and the lookup servers;
+ * it succeeds when any of them accepts it (D-028).
+ *
+ * @param options - Store, pool, loader, account, servers and signer.
+ * @returns The account's file servers.
+ * @example
+ * ```ts
+ * const files = createAccountFileServers({ store, pool, load, pubkey$, pubkey, lookupRelays, outboxes, signer });
+ * await files.current(); // ['https://blossom.primal.net/', …]
+ * ```
+ */
+export function createAccountFileServers(options: AccountFileServersOptions): AccountFileServers {
+ const state$ = new BehaviorSubject({ status: 'loading' });
+ options.pubkey$.pipe(switchMap((pubkey) => stateFor(options, pubkey))).subscribe((state) => state$.next(state));
+ return {
+ state$: state$.asObservable(),
+ state: () => state$.value,
+ current: () => firstValueFrom(state$.pipe(switchMap((state) => (state.status === 'loading' ? EMPTY : of(state.servers))))),
+ async save(servers) {
+ const signer = options.signer();
+ const pubkey = options.pubkey();
+ if (!signer || !pubkey) throw new Error('denied:read-only');
+ const previous = options.store.getReplaceable(BLOSSOM_SERVER_LIST_KIND, pubkey);
+ const tags = fileServerTags(servers, previous);
+ const created_at = Math.max(unixNow(), (previous?.created_at ?? 0) + 1);
+ const event = await signer.signEvent({ kind: BLOSSOM_SERVER_LIST_KIND, created_at, tags, content: previous?.content ?? '' });
+ const relays = [...new Set([...options.outboxes(), ...options.lookupRelays()])];
+ const outcome = await options.pool.publish(relays, event).then(summarizePublish, () => summarizePublish(relays.map((from) => ({ ok: false, from, message: 'publish failed' }))));
+ if (outcome.ok) options.store.add(event);
+ return outcome;
+ },
+ };
+}
diff --git a/packages/core/src/files/uploader.ts b/packages/core/src/files/uploader.ts
new file mode 100644
index 0000000..9217597
--- /dev/null
+++ b/packages/core/src/files/uploader.ts
@@ -0,0 +1,184 @@
+import { Actions, computeBlobSha256, createUploadAuth, type BlobDescriptor, type SignedEvent } from 'blossom-client-sdk';
+import type { Uploader, UploadRequest, UploadResult } from '@kehto/services';
+import type { ISigner } from 'applesauce-signers';
+import type { NostrEvent } from '@napplet/core';
+import type { AppletIdentity, ConsentSummary, KernlHost } from '../types.js';
+
+/** What the person is asked about before a file leaves for a server. */
+export interface UploadDetails {
+ readonly filename?: string;
+ readonly mimeType: string;
+ readonly size: number;
+ readonly sha256: string;
+ readonly servers: readonly string[];
+}
+
+/** Uploads to several servers, the first first and the rest as mirrors; hzrd149's `multiServerUpload` fits. */
+export type MultiServerUpload = (
+ servers: readonly string[],
+ blob: Blob,
+ options: {
+ onAuth: (server: string, sha256: string, type: 'upload' | 'media') => Promise;
+ onUpload: (server: string) => void;
+ onError: (server: string, sha256: string, blob: Blob, error: Error) => void;
+ },
+) => Promise