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}