Repository navigation
SoftN v0.0.27
SoftN v0.0.27
What's new
-
Upgrading from v0.0.26: the engine is ZIPP v0.0.32 (it was v0.0.29), in the browser, in the Rust backend host and in softn-runner; what logic can see of it is in its entry below, and the module every app downloads is 7.34 MB, 775 KB larger (1.80 MB with Brotli, from 1.63 MB). Six entries marked "Upgrading" below change what an existing deployment may notice: on the Rust backend host a sync update is the record's whole data rather than merged into it, and the before-sync hooks' answers are read strictly (an
asynchook now has every push refused: make it synchronous); a private peer-to-peer room with no key is joined under a new name, so every device that uses one must be updated; browsers no longer sendconfig.server.auth_tokenfor server sync, which has a token of its own,config.server.token; the PHP host'ssetup.phprun as root stops before it checks the application; and the PHP host answers requests on softn-runner, without Node, where its backend carries it, as the release's two archives with a backend now do. A FormLogic that installs this release rebuilds its server sandbox from ZIPP v0.0.32 (with Rust 1.92.0, as for v0.0.26); the adapter and every protocol number are v0.0.26's. -
Upgrading: on the Rust backend host, a sync update's
datais the record's whole data, which is what the runtime's client sends: a key it leaves out is removed. Updates were merged into the record. A client of your own that sends only the fields it changes must send the whole record. A before-sync hook that deletes a key from what a client sent still keeps the stored value (PRIVATE_BACKEND.md). A field a route'sdb.updateadded is gone again if a client then sends an update made on a copy from before that write: the record is the last writer's, whole, as clients already take it. -
Upgrading: on the Rust backend host, the before-sync hooks' answers are read strictly, so a hook that guards writes cannot let them through by answering in a form the host did not read (
PRIVATE_BACKEND.mdlists every form each takes).onBeforeSyncnow rejects its op on a number, a string, a list or an object with neitherdatanor nothing in it ({ allowed: false }), all of which took it.onBeforeSyncBatchnow refuses the whole push for a list oropsentry naming anything but this push's op ids (record ids in place of op ids included), anopsentry with nodata, or a number, all of which let every op through;falserejects every op. In either, aSet,Map,Date, function or Promise anywhere in the answer refuses it: nested, it read asundefined; as the whole answer, it was an error the client retried. So anasynchook, which answers with a Promise, now has every push refused for good, where its ops were kept and sent again: make the hook synchronous. A hook that answered1,'ok'or record ids must answer as documented, or each op is refused and undone on the device that pushed it. -
Upgrading: a private peer-to-peer room with no key (not shared, which is every private room on SoftN's own hosts) is joined under a name worked out from its own and encrypted with its own, so devices on this build and on an earlier one no longer meet in it: update every device that uses one. Shared rooms, and rooms with a key, meet as before.
-
Upgrading: browsers no longer send
config.server.auth_tokenfor server sync. It is the Rust host's operator token, which opens every route of the app that is not public, and a bundle that carried it gave it to everyone who had the bundle. Sync has a token of its own now,config.server.token(orSOFTN_SYNC_TOKENon the host; serving many apps,SOFTN_SYNC_TOKEN_<TENANT_ID>only), which opens/syncand/sync/ticketand nothing else; the operator token still opens them for the operator's scripts. An app whose pages synced withauth_tokenstops syncing until a sync token is set, and the host says so at startup. A sync token equal to the operator token refuses to start. -
Upgrading: the PHP host's
setup.phprun as root now stops before it checks the application, with aWARNING:and exit status 1, where it went on and only said afterwards that root was wrong. That check runs the app's logic and migrations, which as root ran with root's rights and left a database the web server's PHP could not open. A Dockerfile or provisioning script that runsphp backend/setup.phpas root and stops on an error must run it as the web server's PHP account instead (sudo -u www-data php backend/setup.php, orsu/runuserin an image). -
Upgrading: the PHP host answers requests on softn-runner, without Node, where its backend carries it, and the release's two archives with a backend now do (
backend/bin/softn-runner-linux-x86_64, its licences inbackend/licenses/, its digest and ceilings inBUILD-INFO.json). A backend updated from this release starts it for every request on Linux x86-64;setup.phpchecks it as a request starts it and says which runner requests run on. Node stays in the backend and still answers whereprivate/config.jsonsets"runner": "node", whereenableRequestHookorenableAfterRequestHookis on (those hooks are JavaScript modules only Node runs, and softn-runner refuses such a configuration), and on any other server; the WebSocket bridge runs on Node as before. What an app may notice is what softn-runner's README lists: a frame of SoftN's own shim is namedzipp:preamble,softn.time.formatends some 262,000 years from year 0, SQLite is 3.53.2 and keeps temporary data in memory, and a request still running a quarter of a second before the 20-second deadline is stopped, rolled back and answered500 host_error, where Node's process was killed and PHP answered503 runner_unavailable. How much memory a request may take is"requestMemoryMb"inprivate/config.json(128 to 1024 MiB, 256 without it, what Node's copy of the engine is held to): the app's engine may grow to it, a script's heap to 64 MiB less (past it the engine stops the script and the request fails, as on Node's engine at its 256 MiB), and SQLite to it again, where Node'snode:sqlitehad no limit and spilled to temporary files.http.phpnow names the backend to either runner (SOFTN_BACKEND_ROOT), which softn-runner needs because it is not besiderunner.mjs.npm run package:single -- --with-backendandnpm run package:private-single-phptake--runner-dir <dir>(whereapps/softn-runner/build.mjswrote the Linux build); without it an archive carries Node alone, as before. -
softn-runner locks its own process down on Linux before it reads a request, behind the engine's sandbox and wasmtime's. Landlock lets it read the backend folder and the system's time zone (for SQLite's
localtime), make, write and remove regular files only in its database folder and rate-limit notes, and refuses every other open and any device, link, socket or pipe made (and, on newer kernels, TCP, and signals and sockets outside it). seccomp filters refuse any system call of another ABI (i386, x32), running a program, making a process, sockets, signals, limits and scheduling aimed at any other process, other processes' memory, io_uring, a file's mode, owner, times or attributes changed by path (which Landlock does not govern), links and device nodes, typing into a terminal, and the calls that only widen the kernel's surface. It has its own session (no terminal), nothing can gain privileges, no core file is written, and what it makes is private. It is the kernel's own means, applied by the process to itself, so nothing is installed and no root or container is needed; a kernel without one part still gets the other, though without Landlock other processes of the account stay reachable through/proc.softn-runner --check-lockdownreports what a system enforced and tries eight refusals, and the PHP host'ssetup.phpsays it in words and names anything that was not refused. -
softn-runner takes a third of the memory it took on Linux, and a fifth less time: a request that only answers peaks at 14.6 MB, where it took 47 MB and Node's runner takes 100, and one that makes 150,000 objects at 64 MB (96 before, 139 on Node). The engine's compiled code, 20 MB of the program, runs where it lies in the program's pages, shared by every runner the server has running, and only what a request runs is read from disk; it was copied into memory of each process's own, which also cost 9 of a request's 40 ms. Where a system will not let those pages be made executable, and on Windows, it is copied as before. The host now says how much memory a request may take (
SOFTN_RUNNER_MEMORY_MB, 128 to 1024 MiB): the engine's memory and SQLite's are held to it, and a script's heap to 64 MiB less, up to the 512 MiB it always was; FormLogic says nothing and keeps the 1 GiB its Node runner's engine has, and the PHP host says 256 or its operator'srequestMemoryMb. -
The engine is ZIPP v0.0.32 (it was v0.0.29), in the browser, in the Rust backend host and in softn-runner. What logic can see of it, each run on 0.0.29's engine, 0.0.30's and 0.0.32's: a
DataViewwhose buffer has been detached or has shrunk beneath it has abyteLengthandbyteOffsetof 0, where reading them threw a TypeError (Node still throws);pop(),shift()andsplice()on an array whoselengthcannot be written remove or move the elements before they throw, as Node does;f.apply(t, a),Reflect.apply,Reflect.construct,f(...a)and[...a]run the getter of an element redefined as an accessor, where they read its old value; a string's property is not looked up onObject.prototypeonceString.prototype's own prototype is set tonull(aStringobject's, a number's, a boolean's and a BigInt's still are, as in 0.0.29); an arrow function in a class method reads the method's own variable where that has the class's name (inclass e { m() { let e = {}; return () => e } }the arrow answered the class, and minified code names both alike); and a method a typed array's subclass defines, such as its ownfillorat, is called, where the built-in one ran.String.prototype.normalizefollows Unicode 18.0: 89 code points it left as they were now decompose or have a combining class ("".normalize("NFKC")is"w"), and none that did has changed. Logic still has noWebAssemblyand nothing of Node (process,require,Buffer): ZIPP 0.0.31 gave its command line and native embeddings both, and the Rust host and softn-runner build the engine without them, which a test on each holds. After a call, the state that moved crosses from the engine to the page as one JSON text where JSON can carry it, rather than a property at a time, with the same values either way (ZIPP measures a read of a 400-object state at about a sixth of the time it took);Reflect.setandObject.definePropertyare faster. ZIPP's TypeScript, its JIT and its compiling of a large program's functions at their first call are not in the engines SoftN runs, whose sandboxed, metered builds compile the whole of the logic as before. The module every app downloads is 7.34 MB, 775 KB larger (1.80 MB with Brotli, from 1.63 MB). A FormLogic that installs a release with it rebuilds its server sandbox from ZIPP v0.0.32 (with Rust 1.92.0, as before). -
From a review of the whole repository:
- A template expression can no longer write onto the page's shared objects, which every app on the page and SoftN Web's own code use.
Math.__defineGetter__("random", …)replaced the page'sMath.random,x.__lookupGetter__("__proto__")led toObject.prototype(and a property set there was on every object), and an array method aimed at something not a list ([].fill.call(Date, 1),[].push.call(Math, 1),list.forEach([].push, JSON)) wrote onto it. The four legacy accessor methods are refused asconstructorand__proto__are, markup holds none of the language's own prototypes,Math,JSONandIntlare read-only copies with the same functions, and the array methods that write (push,fill,splice,sortand the rest) are called on lists only and are not held as values. - An app writes the clipboard, saves a file and opens a file picker only from a click, tap or key press aimed at it (at its own elements, or at the page while it is on screen). The browser allows each for a few seconds after a press anywhere on the page, and SoftN Web keeps every open app running, so an app behind the one on screen could replace on a timer what the person copied in the other (an address in a wallet app), or put up its own picker as they pressed Open in the other and be handed the file. An app that copied or saved from a press in SoftN Web's own controls, or in another app, no longer does.
- An app's stylesheet can no longer put a font of its own in place of one the permission bar is drawn in, nor open a select's picker over the host. A face that named itself twice (
font-family: a; font-family: "IBM Plex Sans") kept the second name, which is the one a face takes, so its glyphs drew the bar's words ("Deny" could read "Allow"); everyfont-familyof a face is renamed now, and a face or an animation that kept a name of its own is removed when the browser reads the stylesheet back.appearance: base-select(in the stylesheet or an inline style) opened a select's picker in the top layer, which no containment clips, and a picker sized to the window covered the bar from one click; it is dropped. Renaming an app's fonts and animations takes a lookup a name, where 4,000 faces held the page for most of a minute. - The host's notices (data that could not be saved, a page's script that stopped, an alert) are drawn above the app, outside what its CSS reaches:
[data-softn-notice] { display: none }hid them. - Upgrading: the static single-app package's
.htaccesslets no other site frame the app (frame-ancestors 'self',X-Frame-Options: SAMEORIGIN) and sendsReferrer-Policy: strict-origin-when-cross-origin, as the packages with a backend and the private one already did: the page shows the permission bar, and a site that framed it could cover the bar with a look-alike and take a click as a grant. A deployment that embeds the app on another site must list that site (DEPLOYMENT.mdsays how); on nginx, add the three headers. The package with the PHP backend now sends the cross-origin isolation headers the static one sends, so an app on the CPU language-model provider runs on every core there too. - Upgrading: the privately served single app (
softn-serve.php) refuses a request its browser says came from a sibling subdomain (Sec-Fetch-Site: same-site), as it refused one from another site: SameSite=Strict sends the viewer cookie to a sibling, which on a shared parent domain may be someone else's.withholdmatches in any case (*.mdwithheldnotes.mdand sentNOTES.MD). Its guide said nothing the application does not use leaves the server; every entry not withheld does, to any visitor who asks, and it now says so: keep files the app does not need out of the bundle or list them underwithhold. Offline, its page comes from its own cache, never from the model cache apps fill. - Server sync: a store last synced by v0.0.26 and now pointed at another sync host sends that host every record it holds, as any move does. v0.0.26 kept where listening stopped with no host's name, and that was taken for the new host's, so records not changed since were never sent to it and an edit of one was refused as "Record not found". Such a mark is now the host the store's pulls were from.
- Upgrading: app SQL's LIKE and GLOB patterns are held to 1,000 characters on the Rust host, on the PHP host's Node runner and on softn-runner, where a longer one now fails its statement ("LIKE or GLOB pattern too complex"). Matching one is a single SQLite instruction, which no deadline stops, and costs the pattern's length times the text's: a 20,000-character pattern took eight seconds against 200 KB, past the request's deadline against a 1 MB note, holding the app's writer lock inside a write.
- Upgrading: the Rust host holds SQLite's memory in the whole process to
SOFTN_SQLITE_MEMORY_MB(64 to 65,536 MiB, 1,024 without it; a wrong value stops startup), and a script's heap to 512 MiB per call, as softn-runner does. Neither had a limit, so one app's recursive query over large rows, or a handler that grew without end, could take the process and every app it serves. Past either the statement or the handler fails, and the process stays. - The Rust host closes a connection that sends nothing once its header time runs out (30 seconds), as it closed one that sent part of a request. The version read before HTTP/1 began had no timer, so 768 silent connections, the process's limit, stopped every app it serves answering. The host speaks HTTP/1.1, as it always has.
- The Rust host holds what a sync push writes, as well as what it carries, to the push's 4 MiB: an update with no data is stored and sent on with its record's whole data, so 1,000 of them in a 100 KB push copied a 512 KB record a thousand times under the app's writer lock. The rest of such a push is answered
sync_retry, and the client sends it again. - Server sync: an op id or a record id over 512 bytes is refused, by the Rust host and before it is sent by the client (
sync-protocol.jsonmaxIdBytes). Every id the store makes fits; a longer one comes only from an import. The host kept such ids outside the storage quota, and a record id near 4 MiB made a pull piece no client takes, so every client's pull of its collection stopped there. - Upgrading: on the Rust host, a before-sync hook that answers NaN or an infinity, anywhere in its answer, refuses the op (or the push), as any other number does. They crossed as null, which accepts:
return a && bwithaNaN let through the op the hook was there to keep out. - The Rust host refuses a bundle with an entry name systems read differently: a
:or another character Windows refuses, a trailing dot or space, or a device name (CON,nul.txt). On Windowsmanifest.json::$DATAwrotemanifest.jsonitself past the duplicate check, so one app's bundle could open another app's data under the other's id. - The Rust host keeps half its 768 connection places for requests: every app's sync sockets together hold at most 384, and an upgrade past that is answered 503 with
Retry-After. A socket keeps its connection's place while it is open, and the per-app limit (1,024) was more than the process's places, so open tabs alone could leave no request answered. - SoftN Web's service worker keeps a feature chunk only for a request to its own origin with no query, by its path: an app's
<img src="/web/?n=1/assets/a.js">was kept as a chunk, so any app could fill that cache (and evict the chunks it holds for offline use) or keep SoftN Web's own page there; offline, its fallback sets a query aside only in the precache, where it answered/web/with such a copy. Every app's worker built with the same template gets the same rule. - Desktop: the network question names every host the app lists on this computer or the local network, first, and says how many more there are past the names it shows; one listed thirteenth was "and 1 more", and asked nothing further. A request the app open before makes while the next one loads is refused, where it was put to the person in the next app's name and its answer kept for that app.
- The Builder saves a project whole or not at all, through a file beside it renamed into place: a disk that filled partway through Save left the project short or empty.
- Upgrading: the directory API keeps its rate-limit counters in
ratelimits/(256 small files, each held to 1,024 counters), where oneratelimits.jsonwithout a size limit was rewritten for every count, a quarter of a second at 65,000 visitors, while the catalogue waited. The oldratelimits.jsonandratelimits.lockare no longer read and can be deleted; counters start afresh. - Server sync: a collection name the host itself would refuse is not made a collection here, from a pull, a delta or a refused pull, where with no
collectionslist any name the host sent was; a field named__proto__is pushed as a field (it was left out, and the next pull wrote the host's copy over the record here); peer sync is refused while the permission bar is unanswered, whatever the configuration grants; and a pull place set while the host's clock ran ahead is dropped, and that collection pulled whole, where every later pull brought nothing written since until the clock caught up. - Desktop: a record is stored under the id
db.createreturned, where the native store gave it one of its own: a record that referred to it by that id found nothing once the create landed,hardDeleteof it deleted nothing, and after a reload the id named no record at all. The nativecreate_recordtakes the id (refusing one another record has). - Desktop: an edit made in a named collection reaches no record of another: the native store found a record by id alone, so a peer's record in a shared collection, with the id of one in a private collection, had the app's edit merged into the private one.
- Desktop: a field an edit sets to
undefinedis removed, as in the browser. The native call carries JSON, which has noundefined, so the field kept its old value there and came back at the next load. - Desktop: a replacing import or restore made while a record was still being created takes that record too, where it came back once its create landed and server sync was told of it. The store's reads answer what the page holds while an edit or a deletion is still on its way to the native store: a record just deleted was listed by every
<data>read and bydb.queryuntil the next reload, andgetAsyncanswered deleted records; and anupdateAsyncthat lands after a deletion made meanwhile no longer brings the record back. - Desktop, peer sync: a peer's removal for good of a record in a collection that has not loaded from the native store is made, where it was refused, and once the collection loaded this device sent the record back to the room, undoing the removal on every peer. The app's own removals still wait for the load.
- A backup leaves out a collection the store cannot read (damaged in storage, or not loaded from the desktop's native store) and names it in the export's
unreadablelist: it was exported as an empty list, and restoring the backup replaced the real collection with nothing. A collection named__proto__is in the export, where it was missing. - An app whose logic a runaway loop stopped is known to have stopped: later window events and calls are ignored, and that is said once, where every window event the logic listened for threw out of the browser's own listener and every call logged "engine is disposed" again (a 30 Hz loop, two errors a tick for good). An app in worker mode stops and the host says so, as when its script hangs, where it sat frozen with nothing said.
softn.net.fetch(url, null, cb), and every other call givennullfor its options, takes it as no options, where the call failed ("Cannot read properties of null"). A call into worker-mode logic given an argument that holds a function or a host object below its top level ({ done: fn },[event]) passes it as the main thread does, where it could not be sent and the function never ran.- SmartForm's and SmartGrid's number fields take a decimal and a trailing zero:
9.0was rewritten to9as it was typed, and typing 9.05 in Chromium left 95. A Camera's live frames keep coming on a page that redraws more often than its frame rate (beside a Microphone's level, a 10 fps camera never fired), and a QRReader no longer reports a code still in view again after redraws that have nothing to do with it. - SmartGrid sorts as Table and DataGrid do (numbers written as text by value, so
9before10, dates by time, empty cells last), types a column from its first filled cell rather than its first row, and no longer shows a store record's owncollection,created_at,updated_atanddeletedas columns: name them incolumnsto show them. A Typewriter withloop={false}leaves its last text showing, where it deleted it and ended blank. A Split'sinitialSizeit cannot read ("wide") is half the box, where the pane was NaN pixels and could not be dragged. A TileMap whosesrcbecomes empty or refused draws no tiles from the old tileset, and aviewportTileMap repaints whentilesetColumnschanges. Scene3D'sgameControlsturned on or off after the first render takes effect, and a scene going no longer lets go of a key another scene on the page is still listening for. - SoftN Web offers "Bring my data forward" for an update whose bundle seeds sample records. Opening an update seeds it before the person reaches Home, and the offer was made only for a build with nothing stored, so for every app that seeds (a directory app reloaded after a new version, a demo re-seeded) the update showed its samples and no way to the person's records. It is offered while the update holds only its seeds, from the earlier version that holds data of the person's (not a version opened once and never used), asks first, and replaces the samples with the earlier version's records; the samples it never had are added the next time it opens. An earlier version's data can be exported from its chip under Earlier versions, as removing one says to.
- SoftN Web's permission bar says an update asks for "the same as v1" only when its permission.json declares the same. It compared the capabilities' names alone, so an update that dropped v1's list of hosts, allowed plain HTTP or named other room collections was "the same as v1"; it now says what it may reach has changed.
- SoftN Web counts records in its notices: "Exported 3 stored records" was the store's keys, for an app with 300 notes; an import that changed nothing gets the card that says so, where a reason mentioning a damaged value (or an app named with "corrupt") was a damaged .softn file to download again.
- Publishing a second bundle on the site's publish page fills the form with its name and description where the first one's were (Tetris went out as Snake); a name or description the person wrote is kept. A comment posted on an app's page is followed by the page as the directory pages it, where page 2 then began with the comment page 1 had shown last. View source shows the version just published, where an answer for "the latest" was kept for five minutes.
- The directory API answers a publish or a remix whose picture cannot be stored (a nearly full disk) with the app's edit key and a
warning, where it answered 503 for an app it had already listed: nobody had its edit key, and the retry published a second one. - The guides and READMEs say what the code does where a review found they did not:
softn.backend.callreaches only a single-app host module or FormLogic, and an app calls the Rust or PHP host's routes withsoftn.net.fetch;db.pruneanddb.clearCollectiondo not exist, anddb.startSyncreturns nothing to await;<DataTable>and<Link>are<DataGrid>and<Table>; server storage,config.server(url,token,collections) and thesyncdeclaration it needs are documented;<Microphone>,<Camera>and<QRReader>need their capabilities;accelworks only in worker mode; the PHP host and softn-runner have no defaultserver.entry, the Rust host refuses a route with noauthorization, and tenant token variables are upper-cased; the PHP host's nginx example sends the cross-origin isolation headers. - On the host-JavaScript engine (
@softn/core/host-js), a key named__proto__in an app's state reaches the page as a key, as it does on ZIPP: it was lost, and an object under it became the copy's prototype, so the page read the whole value asnull; and a page writing such a value back gave the app's record that object as its prototype.
- A template expression can no longer write onto the page's shared objects, which every app on the page and SoftN Web's own code use.
-
From reviews of the AI layer, the desktop's native store and how a page draws an app:
- An app's model download can no longer stand in for SoftN Web's own page. A model URL's path must name the
.gguffile (<site>/web/?.ggufwas taken, and the site's page kept in the model cache under/web/), and SoftN Web's service worker answers a page or a worker script from the site's own caches, never from the model cache, which apps fill by address; offline, SoftN Web opened as that download. The model cache holds to its four files and 4 GB: a file said to be larger is not kept, one that arrives larger is fetched again for that load, and one whose size the server did not give is counted once it has arrived. A model still opening when its app closes is stopped. softn.ai.gpu.createBufferrefuses a dtype it does not know, aswriteBufferdoes; it made a buffer no read could turn into numbers.- A link an app draws with any
target, and every form, carriesrel="noopener", and no link keepsopener, so a page it opens has nowindow.opener: a namedtarget(spaces and all), aformtargeton a form's button, orrel="opener"gave that page a hold on SoftN Web's tab. A link with no target keeps therelit was written with. Rich text is held to the same, and rich text or an icon that reads differently each time it is parsed is refused rather than drawn unchecked. - Removing an app in SoftN Web takes its peer-sync rooms' offline caches, saved room and room settings with its data; bringing an app's data forward to an update, or to the identity SoftN Web now knows it by, brings its saved room and room settings too.
- A record with a field named
__TAURI_TO_IPC_KEY__is refused when it is written, on every store: the desktop app's IPC takes that key for its own, so such a record failed on the desktop after the app and sync had taken it, and could not be carried there. - A Scene3D in a tab that is not on screen hears no game key go down: Space pressed in the app on screen made a hidden game jump.
- Desktop: what peer sync brings in from a room is written in one native call, with one rewrite of the collection's stored document, where it was one of each a record; an edit or deletion keeps the stamp the page gave it, after a room's copy; a peer's reset epoch past what the store keeps, or far past this device's, is refused; an import does not take a live record another collection holds, and the refusals of the native store's that are known (an import while a collection is still loading or failed to, an id of white space, text it cannot read, a record naming another collection) are made before the import changes anything or reaches sync, where it had already been sent (a refusal not foreseen, or a native failure such as a full disk, still leaves the import sent); a collection a peer only names makes no document in memory; and the native dialogs drop characters that hide text or turn it around in an app's or a host's name.
- An app's model download can no longer stand in for SoftN Web's own page. A model URL's path must name the
-
From reviews of server sync from end to end, of Studio and of the Builder:
- Server sync: a record this device heard from the host, found again at the next load (which happens when the host's clock runs ahead of this device's), goes back stamped as the host stamped it, so it no longer replaces edits other devices made since; it was stamped later by the host's lead. An edit, a deletion or a removal made just after a write this device heard from the host (a hook's
db.update) is stamped after that write, where it could lose to it on the host and come undone; and an edit on a device whose clock runs fast is stamped in the host's time, without reading its collection back for each change. An edit sent and not yet answered when the page reloaded no longer hides a newer edit made before the next client listens (on a device behind the host it was never sent, and the pull wrote over it), and an edit this page made while nothing listened goes in the host's time as any edit made here does. A pull's records are written at once, where each read and wrote its whole collection (on the desktop, a native rewrite of it each), and one that cannot be saved is still passed over alone. A store that has synced keeps a note beside its records of what was changed while no client listened, so a page that closed before its client started has its edits sent at the next visit in the host's time, an import's records are sent as creates and updates (their backup stamps hid them), and a removal still reaches the host. An entry is sent only while its record is still as it left it, and leaves the note once the op is saved with the queue. - Server sync with an app open in two tabs: a change one tab made offline is kept for the next visit, which sends it, whatever the other tab does meanwhile. Each tab saved its queue of changes not yet sent over the other's (and none at all once it had nothing left to send), so the change was gone, and the next visit's look for changes made while nothing listened started after it, from the other tab's mark. A change a tab made before its client listened (while the person was asked to allow sync) is sent when it starts, for the same reason. The tabs' changes are kept in the order they were made, so one tab's edit of a record goes after the other's create of it. The next visit may send a change twice, once for each tab; the host answers the second without applying it.
- Server sync: a record removed for good (
hardDelete, a prune,clearCollection) while no client listened, such as an app clearing a collection as it starts while the person is asked to allow sync, is deleted on the host when the client starts, at the time it was removed, whatever collections the client names, as is what an import replacing a collection took. It left nothing for the client to find, so the host kept it and the first pull wrote it back. On the desktop, a record cleared before its create was written stays cleared. One the host never had is done when the host says so, and is not reported as a change refused. The store's ownclearof a collection is kept for a peer-to-peer room that is not open, ashardDeleteand a prune are: every record it took came back from the room at its next opening. - Desktop, with server sync: the native store keeps the stamp the page gave an edit or a deletion when it is after the copy edited. It took the time the call reached it, a moment later on the same clock, so the page's copy held a stamp the page never gave, and its own edits were taken for ones heard from elsewhere and sent without the offset to the host's time. The client waits for the store to load its records before it looks for what changed while no client listened (started first, it read an empty store, and the note of those changes was forgotten); a collection that could not load is left, note and all, to the next client. A deletion made while no client listened is sent although the store loads no deleted records, where the host's copy came back with the next pull. And on every store, an edit made while no client listened of a copy stamped ahead of this clock goes as a listening client sends it, in the host's time, not at the stamp just after that copy.
- Server sync: the note of what was changed while no client listened holds as much as fits, up to a megabyte and half the room the app's share of the browser's storage leaves (the rest is for the app's own writes). It was cut at 64 KB, so a clear or an import of more than a few hundred records, on a page that closed before its client started, reached the host in part. Removals made together are kept as one list. When it will not fit, what another path finds goes first (an edit, which the next client finds by its stamp), and an import's records are kept as lists of the ids it made and changed, each sent as the import made it; removals, deletions and those lists go last.
- Server sync, more of what was changed while no client listened reaches the host: a record one tab made with no client, which a listening tab then edited (the host refused the edit, having no such record), goes as a create; a restore kept as lists of ids goes after an older change of the record that was still queued, where it was passed over; and on the desktop, a collection that failed to load on a store syncing for the first time is sent whole once it loads, where it was never looked at again.
- Desktop: an app starts once its store has loaded its records from the native database (saying so meanwhile, and starting all the same after 15 seconds), where the loader started it at once and a script's first change of a record it knew met a store that held none yet. A change of a record not loaded yet that comes from elsewhere meanwhile (
db.delete(id),db.update(id, …),db.deleteAsync(id)) is made as soon as the record's collection has loaded, as any change is, where the native store took it alone and server sync was never told (a deletion came undone with the next pull); once loaded, one of a record the store does not hold is refused, as in the browser. - Server sync: a record deleted here after the copy the host holds, whose deletion never reached that host (a first sync, or a move, that a page left before the pull brought the copy), is deleted there, where the pull wrote it back here. Weighed in the host's time, and a deletion from an earlier page only by more than the two clocks' difference (the largest this device has had in the past week, with any host), so it does not outweigh another device's later edit on a device whose clock runs ahead or was set right since; and one the host refused is not sent again. The mark of where listening stopped only moves forward, so a clock set back no longer has each page send again, ahead of other devices' edits, what was edited while it ran ahead. On the desktop the store now loads its records' tombstones with them, as browser storage keeps them, so this holds there too; a record a peer brings back after a deletion here shows again. An editor's preview scope has an id of its own in every tab: two made in one millisecond shared their script storage.
- Server sync writes many changes at once: what another client pushed arrives as one write of each collection (a run of upserts, then of deletions, in order), a pull's deletions as one (on the desktop, one native call), and a burst of changes made here in one task goes to the host in as few pushes as it takes, the first at once. Each was written or pushed alone, re-reading and rewriting its whole collection each time.
- Server sync with an app open in two tabs, both back online: one tab's edit of a record the other made offline, reaching the host before the other's create of it, is sent again after that create, which the editing tab then sends itself (the host takes it once, from whichever tab it hears first), or else once more, as the other tab's create may have landed since. The host refused it, and the create, landing after, wrote the record as first made over the edit, on every device.
- Server sync: an app whose sync host changes sends the new host every record it holds. The mark of where listening stopped was the old host's, so a new, empty host was never sent the records the app already had. Each goes as a create, which a host with no copy takes and one with a copy answers with it; every copy the host sends (that answer, its pull, another client's change) is weighed against the record here by stamp, and where the one here is newer (a host seeded from an older copy) it goes as an update or a deletion and the host's copy is not written. A host that holds the same records (only its address changed) rewrites none of them. Changes still queued for the old host, or noted while no client listened, go after the create, and one the new host has no record for is done. The mark of where listening stopped is kept for each host, so two tabs on two hosts (an update of the app moved it while one was open) no longer take every load for a move, and a move a page leaves unfinished is picked up by the next. A store syncing with a host for the first time is weighed against it the same way. SoftN Web removes and moves that note with the app's other data; it was left behind.
- SoftN Web: bringing an app cached under its manifest's name (before apps were known by their bytes) to its own identity moves only its own records. A name may begin another's (
Notes,Notes:Pro), and the other app's records went too, under a name nothing read; its sync rooms' keys and offline caches went as well. From a name, only its saved room's keys are copied, each named in full, and nothing of them deleted. - Desktop: a record from a peer-to-peer room or the sync host that the native store cannot take (one holding a field named
__TAURI_TO_IPC_KEY__, at any depth, which Tauri's IPC calls as a function) is passed over and left in the room, or not taken from the host. It failed the whole native write it was in: one peer's record kept every record of its batch out of the store, there only until the app closed, and the storage status said writes were failing. - Desktop, with server sync: a record edited or deleted while its create was still being written is told to the host once the create lands, as it then is and under the id the store gave it (nothing, for one deleted first). It was told under the id it had until then, which the host never hears of: the host refused the change as not found, and the person was told a change was not taken. A scoped edit of such a record no longer asks the store to update a row it has not written yet.
- Rust backend host: an
onBeforeSyncBatchthat answers with something other than this push's op ids (the ops themselves,[{ id }], the records' ids, a number) refuses the push, where it rejected nothing and let every op through;falserejects every op, asonBeforeSync's does its op, andnullor a boolean in a list names no op. A rejected name that is an op's id and also another op's record id rejects every op it may name, and the rest of the push goes on. AnonBeforeSyncthat answers with a number, a string or a list rejects its op, where the0or''ofreturn a && btook it (PRIVATE_BACKEND.md). And an op whosedatais not an object is refused: a string or a list became the record's whole data, sent to every other client. - Studio's agent renames a file only as it last saw it, as it already deletes one, and a file it never read does not count as read under its new name: the rename of a file the person had edited since let the agent's next write put the older text back there. Nor does a file made at a path take on what was seen of another there before (one the person deleted): a partial read of a file renamed onto it counted as whole, and a write over it took lines never seen.
- The Builder renames a file or folder within the folder it is in: one an earlier Builder let be named with a slash (
../main.ui) could not be renamed out of a path no bundle holds, and, as the entry file, neither deleted nor saved. An editor's preview whose page closed has its script storage swept once a day has passed since it was last open; one still open in another tab says so every hour and keeps it. - Checking a project's logic runs its top level in a store of its own, in memory, taken away after. The save check loaded every project into one database in the browser's storage, kept for good, so one project's records were there when another's logic ran, and every agent check left a database behind in Studio's storage.
- Studio and the Builder no longer put in a bundle a file whose name has two dots in a row (
assets/version..png). Every SoftN reader takes..anywhere in a path for a way out of the bundle and leaves the file out, so the app opened without it, and the directory refused the bundle. Studio keeps such a file in a project and its export says, by name, that it must be renamed, and its agent makes no new one; the Builder refuses the name when a file or folder is made, renamed or moved (and a/or\in a name, which made a file in folders the project had not got), and its export, held back with the reason. Opening a bundle that has one, both open the rest and say which file was left out (Studio in its console, the Builder in a notice), where both had opened without it and without a word. - What an app's script keeps in
localStoragein an editor's preview goes when the preview does (createEphemeralXDBScope().dispose()takes it): the Builder's preview, and the Fieldnotes sample on SoftN Web, left a set of keys behind for every preview, counted against the storage every app shares. A preview whose page closed has its keys swept by the next page a day after it was last open; one still open in a tab that slept longer (where the browser has Web Locks) is left alone.
- Server sync: a record this device heard from the host, found again at the next load (which happens when the host's clock runs ahead of this device's), goes back stamped as the host stamped it, so it no longer replaces edits other devices made since; it was stamped later by the host's lead. An edit, a deletion or a removal made just after a write this device heard from the host (a hook's
-
From the second round of reviews of server sync and the directory:
- A pull's older copy of a record no longer replaces one that came live. The host reads a pull while it takes edits and sends the pull's pieces and the stream on one socket in no fixed order, so a piece read before another client's edit, or before this client's own edit was acknowledged, could arrive after it and put the older record back, which this client's next edit then sent to the host whole. Once a record has come live on a connection, a pull's copy of it is passed over.
- A run counts once a day for each visitor and app. One client could count 120 a minute, and runs weigh three in trending: an app moved above the popular ones in a second and a half. The day's visitor-and-app pairs are kept as hashes in
cache/runs-<day>.log, removed the next day. - An app's owner can hide a remix from the app's page: Hide beside each remix (the browser with the edit key), Show to put it back, and
hiddenRemixesonPATCH /api/apps/{slug}. A hidden remix is out of the page's list and out of the remix count, in trending too. Anyone could publish a remix of any app, under any name and author, and the newest twelve were on its page for good. - Publishing (a new app, a version, a remix) is counted by the IPv6 /56, which a subscriber is often handed whole: by the /64, one could publish 256 times the limit. Votes, runs and comments stay per /64.
-
From a review of peer-to-peer sync, what a peer writes into a room is checked before it is written here: the value's id must be its key and not empty, it goes into the collection of its map whatever it names, and its stamps must be times no more than a day ahead of this device's clock (a room has no clock of authority, and this device's may be the slow one; a value refused only for its stamp is left in the room rather than written over). A peer's value under one key overwrote the record of another key with no time weighed, a deleted or removed one included; an empty id made the collection unreadable on every browser peer until its site data was cleared; a value naming
_prefsreplaced the desktop's own_prefsrecord, so the desktop's refusal of a write from another collection did not hold for peers; and one value stamped 9999, from a peer that then left, pinned the record against every later edit. Anullwhere this device held a record threw out of the room's opening, and nothing written here reached the room after it. -
In a peer-to-peer room, an edit made here is stamped later than the record it edits, both as this device holds it and as the room does, following a stamp up to a day ahead of this device's clock, as far ahead as a room's stamps may be. An edit was stamped with the clock alone, so on a device whose clock was slow, an edit to a record last written by a device whose clock was fast lost to the copy it edited and came undone on every peer, this one included; a script's edit of a record stamped between one and twenty-four hours ahead, or of one this device held an older copy of than the room, was put back on the device that made it. Stamps are weighed as times, not as text, so a peer's stamp written with an offset (
+23:59) no longer sorts up to two days ahead of every other, and one is kept as XDB writes its own. (Server sync stamps edits in the host's time and is unchanged.) -
db.stopSync('')anddb.stopSync(null)stop every room, asdb.stopSync()does, a start still under way included; they stopped every room in the sync layer but let a private room still working out its name open after the stop. -
Records from a peer-to-peer room may take 2.5 MB of an app's 3 MB of browser storage, so the app's own writes always have room. Any peer of a room could fill the whole budget, after which the person's own edits were refused. A record that does not fit stays in the room, the storage status says so, and where this device held an older copy of it, its next write no longer puts that copy back over the room's (it went off every peer once the one holding the newer copy had left); what fits is written in one pass: a batch the budget refused was tried again a record at a time, each re-reading and re-serialising the collection, so joining a room with more records than fit held the tab.
-
An app can say which collections its peer-to-peer rooms carry:
"sync": { "enabled": true, "roomCollections": ["scores"] }inpermission.jsonlimits every room of the app to them, both ways (nothing else is sent, and nothing a peer writes into another collection is written here), and the consent bar names them. A room carried every collection whose name does not start with_, and a shared room's peers are anyone who knows its name, so any of them could write into, say, an app'ssettings. From logic,db.startSynctakes the room alone (ZIPP passes no options), so the declaration is how a logic app limits its rooms; thecollectionsoption, from a page, narrows it and never widens it, and is kept for the room as its shared mark is. A list that is not one of collection names is refused when the app is published, and a room with one does not start. The capability schema is version 3. -
A private peer-to-peer room's name is its key. It was joined under its name, in the clear and with no key (none of SoftN's hosts gives one), so the signalling server, and anyone who learned the name from it, could be one of its peers. It is now joined under a name worked out from its own (PBKDF2-SHA-256, 100,000 rounds, salted with the app's id), which holds no part of it, and what it sends to find its peers is encrypted with its own name. A room stopped while that is worked out is not opened, and a script cannot choose the name a room is joined under. On a page without Web Crypto (plain HTTP from another machine), where y-webrtc can encrypt nothing, a private room is joined as before and the console says so. The guide says to make a private room's name long and random.
-
An app's own files reach it in Studio's preview, and its shaders in every host. A page's
fetch('data.json')and a model the app ships were not found in Studio's preview, which gives each file as adata:address: onlyblob:addresses and the page's own paths were read, to keep a fetch from going pastnet, and adata:address goes nowhere.softn.ai.gpu.createShader({ bundle })read the file from a bundle provider, which no host supplies, so it always failed with "No bundle file provider"; it reads the file where the app's models are read. -
A
window.__…value an app's script writes is that app's while it is open. The page has one window, and SoftN Web keeps every open app mounted: another app whose script named the same key read the value and wrote over it, so an app with nonetcould hand its records to an open app withnet, or press another app's input latches. The first app whose script writes a key owns it until it closes; two views of one app share it, and a key only a component sets (Scene3D's__scene3dLocked) is every app's as before. A second view of an app, or an app opened after another had written a key, took that key for the host's and never wrote it. -
A field removed on a device leaves the record on the Rust backend host too. The runtime's client sends a record's whole data with every update, and the host merged it into the record, so a field removed here (set to
undefined, or gone in a restored backup) stayed on the server and came back to every device at its next pull, this one included. The update's data is the record's now. A key a before-sync hook takes out of what the client sent keeps the stored value, as it did, and the other clients are sent the record as stored, where they were sent what the hook handed back (a whitelisting hook's few fields, which they took for the whole record). -
A bundle's text files are decoded when something reads them, not all at the open. A 451 KB bundle with nine 50 MB
.jsonentries held some 600 MB in/web/from the moment it was opened, before the person had allowed anything, for as long as the tab lived. The manifest, the composer, imports andasset()read what they need; a corrupt text file fails the read that asks for it rather than the open, as a binary already did. -
From a review of the script runtime's host bridges:
- An app writes the clipboard only from something the person did (a click, a key press) and once the permission bar is answered.
navigator.clipboard.writeTextneeded nothing: an app with no permission.json, on a timer, behind another app, could replace what the person had copied every ten milliseconds (Chromium, and so the desktop, writes with page focus alone). A copy button still copies. In worker mode the copy, which threw there, is handed to the page and held to the same rule. - In worker mode an app's
localStorageis held to its 1 MB share as on the main thread, by the worker's own bridge (the script sees the refusal) and again by the page.config.execution: "worker"let an app with no permissions fill the store every app and the host share. FormLogic's engine (host-js) keeps its store as it was: there it is FormLogic's, not the browser's shared one. - In worker mode a host call's callback (after a
softn.net.fetch, say) and a window event run with the database as it is, as every other entry does: they had the records from before, missing what sync or the page wrote meanwhile. A worker's update of a record the page no longer has is reported as a write not kept, where it was dropped without a word. - Smaller ones: the host keeps at most 10,000 of a script's timers however they are asked for (it held 44,096 for a script calling
timer.waititself); a file chosen after the app closed is not kept for the page's life; a saved file is never one of the shell's other formats that run something (.appx,.msixbundle,.appinstaller,.chm,.msc,.diagcab,.library-ms,.search-ms,.settingcontent-ms,.xll,.jnlp,.iso,.img,.vhd,.vhdxand more join.exe); andnetwith no host list counts a one-label name (https://router/), the private-network suffixes (.lan,.home,.corp,.intranet,.private,.localdomain), site-local IPv6 and IPv4 written inside IPv6 (NAT64, the old compatible form) as this computer or its network.
- An app writes the clipboard only from something the person did (a click, a key press) and once the permission bar is answered.
-
Peer sync takes out of a room only what this device removed. A refresh took out every key the device did not list, and a device lists less than it holds: on the desktop, after its native store was read again (every
<data>read), a deleted record's tombstone (the store lists live records) and a peer's record whose write had not landed; after a collection could not be read, all of them. They went off every peer, and a browser that still had a deleted record put it back. A read of the native store that finds nothing new no longer announces itself either: each announcement started another read, so a mounted<data>block re-read SQLite every 120 ms. -
A room's records are written to browser storage at once rather than one at a time, each re-reading and re-serialising the whole collection: 4,000 records took 4.4 s to bring in, so one update from a peer could hold a joining tab. The development signalling server (
scripts/dev/signaling-server.mjs) lets in this machine's pages and those inORIGINS(any page in the developer's browser could join a topic), takes messages of up to 256 KB (it took 100 MiB and sent each on to every peer), keeps a connection to 64 topics, drops emptied ones and stops a closed connection's ping timer. -
SQL reads the same on every host. SQLite's
#nameparameter is counted and bound by name like:name, so value i is parameter i: the PHP host's scanner did not count it, and its value went to the next?(SELECT #a, ?with[5]answered null and 5), where the Rust host refused the bind count. A migration's statements are stepped to their end on the Rust host as on the PHP host, so a SELECT whose second row fails (an integer overflow) is refused on both, where the Rust host applied it. The Rust host counts a query's text before copying it, so one query of 200 columns of 2 MiB no longer held some 800 MB in the shared process before the 2 MiB result limit refused it.sql-parity.json, run on both hosts, holds the first two. -
The Rust host, from an independent review of the multi-tenant server:
softn.time.formatof an instant near the ends of what chrono holds, moved past them by the zone's offset (format(8210266876799, "+10:00")), is the error "Invalid timestamp". It panicked the worker, and a client could keep a tenant's workers dying.- Serving many apps, what a bundle asks for itself is held to its share of the one process:
workersto the 200 threads divided among the bundles (one asking for 200, loaded first, left every later tenant unable to load;--workers-per-tenantis the operator's and is not held to it);readPoolSizeto 16;maxSyncConnectionsto 1024;maxStorageMB: 0, which turned the storage quota off on a disk the apps share, reads as the 10 GiB cap there.
- An open sync socket is one of the 768 connections for as long as it is open. It was counted only until its upgrade, so the apps' sockets could pass the descriptor limit.
- An archive's tenant id is read from the entry that unpacks as
manifest.json(./manifest.jsontoo), and an archive with two entries for one file (by./,..or case) is refused. A bundle could claim another legacy tenant's id past the check that settles two claims. app.softnandapp.zipno longer unpack over each other: the first by name is loaded, and the other refused.
-
The PHP host, from an independent review of its PHP side:
- A multipart POST is refused 415 like any body that is not JSON. PHP had parsed it before
http.phpran, and the handler was run with{}. - The operator's
"workers"inprivate/config.jsoncaps the manifest'sconfig.server.workers, which only the app's owner could set. setup.phpstops, naming the file to correct, rather than writing aconfig.jsonof one newline when the manifest holds a number past the doubles.- A photo PHP's bundled GD could not decode within
memory_limitis refused rather than ending PHP with a fatal error, its request uncounted. - The nginx examples set
client_max_body_size 6mon the API and leave/.well-known/served.
- A multipart POST is refused 415 like any body that is not JSON. PHP had parsed it before
-
XDB, from an independent review of its browser data:
- A peer-to-peer peer that could not keep some of the room's records (a browser over its 3 MB budget) no longer takes them out of the room at its next write. They went off every peer, the one that wrote them included.
- Bundled seed records the person removed (clearing the collection, a prune, a hard delete) stay removed; they came back at every opening. Which seeds were added is kept beside the records, and a seed a later version of the app adds is still added.
- A change the sync host refuses is a
sync-refusedissue in the storage status, which the notice bar shows. It was told to no one, and a refused update was quietly undone by the host's copy. - A peer-to-peer adapter disconnected and connected again sends local writes to the room again.
- Studio's agent seeds records with ids no other collection uses (
task-1, not1):db.update(id)anddb.delete(id)act on the first collection that has the id, so seeding1in two collections sent changes to the wrong record.
-
From an independent review of how SoftN reads untrusted bundles:
- A bundle's asset is never a page of the site that shows it.
asset("…")handed every file out as ablob:URL of its own type, and ablob:URL opened as a page (a link, "Open image in new tab") is a page of the site that made it: a bundle's.svg,.htmlor.xmlasset was its own document on the site's origin, its script stopped in a new tab by nothing. An SVG is now adata:URL, which an<img>and CSS draw and no browser opens as a page; HTML, XHTML, XML and JavaScript aretext/plain, which opens as text; everything else is as it was. The runtime, the private single-app shell, the Builder's preview, canvas and asset view and Studio's media view all take it from core'sassetObjectUrl. An app linking to an HTML file of its bundle withasset()now opens it as text. - The Windows shell extension's thumbnail of a
.softnfile reads nothing outside the bundle. An SVG icon's<image>naming\\host\share\a.pngmade Windows connect to that share, with the user's credentials, for no more than showing the file in a folder, and a local file outside the archive was drawn into the thumbnail; an SVG icon now takes images only from inside itself. A crafted bundle can no longer end or swell the thumbnail process: an SVG nested some 700 groups deep overflowed its stack, a 1.56 MB entry inflated to 1.61 GB, and a 500 KB PNG of 16000 by 8000 took 556 MB to decode. The SVG is drawn on a thread with a 64 MiB stack, an entry inflates to 8 MiB at most, a raster icon decodes to 4096 a side, and no more than 512 MiB of a bundle is read. - A file at a project's root named
__proto__is refused by name when Studio or the Builder exports it. Studio's Export, Run and Publish threw "Cannot read properties of undefined (reading 'level')", and the Builder's archive had one entry for each of the file's bytes. A file of that name anywhere else is archived as any other. - A page app's
asset()stays in the page's folder however the path is written. It refused..as text, and the URL parser reads%2e%2e/as../, soasset("%2e%2e/secret.png")was a file outside the folder, and\\elsewhere.test/x.pngwas another site. - The desktop runtime no longer counts
\??\UNC\host\share\…as a path on this machine. Rust reads it as a folder at the root, and Windows reaches the share, signing in to it, when such a path a page sent is looked up. - Studio imports a project of thousands of files at once: each file's path was compared with every other, and 9,992 long names held it for 38 seconds.
- Studio refuses to export a file where another path needs a folder of the same name (a file
uibesideUI/main.ui), which no host that unpacks a bundle to a disk could write out. - The Builder no longer goes blank on
<Heading level="1 x">, and refuses a manifest whose name, version or main is not text, as every runtime does, rather than going blank on it. It reads the manifest at a bundle's root and nowhere else, where it took the first path ending inmanifest.json, and foldersa/banda_bare two folders in its Files tree, where the second replaced the first. - Studio's and the Builder's previews ask before they grant what an app declares, as every runtime does. Studio's was handed no declaration, which core reads as a host enforcing nothing: the page's markup fetched any address it named and a Camera opened the camera, for a project opened from a link as for one's own. The Builder's was handed the declaration as granted, so a declared
netor camera was used with nobody asked. Now a project that declares nothing gets an empty declaration, enforced, and what one declares is withheld until the person presses Allow in preview in the note above the app. The Allow is for that project and that declaration, for the session; an edit that asks for more is asked about again, and Withhold again takes it back. - The Builder keeps what it cannot draw to where it happened. An element the canvas cannot draw is a card saying so, with the rest of the page drawn as ever and the element tried again at its next change. A throw that would have blanked the whole Builder says it stopped and why, offers a reload, and keeps the unsaved work as the recovery copy, which the next open offers back.
- A purged app's slug is never given to another app. Visitors' browsers keep a
/play/app's records and settings by its address, and the next app published under the same name had them as its own. A removed app's slug is listed in the directory'sretired-slugs.json; a demo the seed retired takes its own back when it is filed again.
- A bundle's asset is never a page of the site that shows it.
-
From independent reviews of server sync, the directory, and Studio's keys and agent:
- One change the sync host cannot read no longer stops every change behind it. The host refuses a push it cannot parse whole, and the client sent it again for ever, so half an emoji (an app's
slicethrough one) or data nested past the host's 64 levels stopped everything from that device reaching the host, for good. Strings go out with a lone half of a UTF-16 pair as U+FFFD, and a change nested too deep is kept on the device and shown as async-refusedissue while the rest go. A record the host sends for one collection is written to that collection, whatever its owncollectionfield says. - An app's comments are a file of their own beside its
app.json, read only by the comments routes, and an app keeps its 500 newest. Every request decodes everyapp.json, and comments kept there, a few thousand at the comment limit's pace, cost every request of the whole directory time and memory until every route failed. A comment's id comes fromsequences.jsonalone. - A page on another site can no longer have its visitors rate an app, comment, count a play, suggest a category, publish or remix: those routes now answer the site's own origins (and
allowedOrigins) only, as the edit-key routes do, and scripts with noOriginas before. Any page could make each of its visitors a voter and a rate-limit bucket of its own. - Studio shows an
.htmlasset from its text (srcdoc), never from ablob:URL with Studio's origin, which opened in a tab of its own ran the file's script as Studio, provider keys and all. The agent's off-screen check is handed an empty declaration, enforced, where it fetched whatever the page's markup named after every write the AI made. - A Studio provider key goes only where it was given: an edit that changes a server's address no longer sends the saved key to the new one. A key removed in one Studio tab no longer comes back from another tab's next save of its settings.
- The agent's text tool protocol reads calls only where they are calls: a
<softn-file>block or a<tool_call>inside a call's value, or a file block quoted in a sentence, is text, where each could write over a file of the project. Itssearch_filesruns in a worker ended at three seconds, where a pattern that backtracks held Studio for as long as it ran. - The Rust host acknowledges a push in the stream of other clients' edits, in the order it stored it. A plain acknowledgement went on the connection's own queue, so an edit stored after it (another user's, or an after-sync hook's) could reach the pusher first, be set aside, and be overwritten by the pusher's next edit. A create for a record the host holds is answered with the record as it stands, so a record deleted on the host leaves a device that sent it as a create. The stream holds 512 messages, so a slow connection is closed as far behind as before.
- XDB: an import or a restore reaches the sync host as the writes it amounts to (an update for a record that was here, so a restore wins; a create for a new one; a delete for one a restore drops), where it stayed on the device it was made on. A record removed for good while the peer-to-peer room was not open is taken out of the room when the room next presents it, where the room's copy came back.
- The Builder's export names the files of an opened bundle it carries but does not show, server logic first, which it wrote back unseen.
- The directory refuses a publish, a remix, a new version or a storage write that adds, with a
507, when it would leave its disk with less thanminFreeBytesfree (256 MB by default;0turns it off), and/api/healthwarns before then. Nothing bounded the whole.
- One change the sync host cannot read no longer stops every change behind it. The host refuses a push it cannot parse whole, and the client sent it again for ever, so half an emoji (an app's
-
The desktop runtime:
- A large bundle opens. Its bytes reached the page as JSON, a number for each byte: a 180 MB bundle, within the 200 MB the reader takes, held the window for over a minute and then crashed it. They go as bytes now, and the same bundle opens in seconds.
- A listed host that looks like a server on the internet but leads to this computer or the local network is a second question, naming the address. The person allowed "only these servers: api.example.com", and whoever runs that name could point it at their router or a program on their computer for the app to read. Every name is looked up natively, for every request, and the request goes to the addresses checked. The first question no longer offers this computer and the local network when the rules list no host, since those requests are refused; a listed host that is there is labelled so. Android, which puts no question, reaches a listed name as before, at the addresses checked.
- The page is granted what it calls of Tauri's own, events and the window's title.
core:defaultgave it the image, menu, path, tray and resources APIs besides, reading an image from any path among them. - A pre-upgrade backup is refused when it would leave less than 512 MiB free on the disk. Each is a whole copy of an app's database, kept for rollbacks, and a page asking again and again filled the disk every app's database is on.
- The Windows thumbnail of a
.softnreads the bundle's ZIP as the runtime reads it, and refuses what the runtime refuses (two entries of one name, a second archive behind the first, a local header that disagrees with the directory, and the rest); a refused bundle has no thumbnail rather than another app's icon. It took the last end record anywhere, the first of two entries with one name and an entry's sizes from its local header, so Explorer could show one icon for a bundle that opened as another. - A delete that names its collection deletes from it alone, and a record a peer or the sync server sends for one collection does not replace a record of another. The native store keys records by id, and a removal or a write heard in one collection reached the record of another collection that had the id, one never shared included: the write replaced it and moved it into the collection it came in. Such a write is now refused (a
write-failedissue in the storage status) or, from the desktop's own peer network, skipped, as the Rust host already refuses one. Core names the collection wherever it knows it. Which record an app's owndb.update(id)ordb.delete(id)reaches, when two collections hold the id, is unchanged.
Downloads
| Download | What it is | Pick it when |
|---|---|---|
softn-website-v0.0.27-zipp-v0.0.32.zip |
The complete softn.com website. The whole softn.com in one folder: the landing page, the app directory, the web runtime, Builder, Studio, the documentation and the directory API. | You want to run your own SoftN site: a directory people can publish apps to and play them from. |
softn-app-static-v0.0.27.zip |
One app on a page (static). The smallest way to host one SoftN app: a plain page with a loading spinner, a permission bar and your app, with no SoftN branding and no server code. | You have a .softn app and a web host, and you want the app on a page of your own site. |
softn-app-static-with-backend-linux-x64-v0.0.27.zip |
One app on a page, with an optional PHP server backend. The static single-app page above, plus a backend/ folder that lets the app run its own private server logic and SQLite database on an Apache/PHP host, with no service to keep running. |
Your app has a private server part (server/ logic, SQL migrations, routes) and your host is Apache with PHP on Linux x86-64. |
softn-app-private-v0.0.27.zip |
One app served privately by PHP. The single-app page again, but PHP renders it and hands the app's files to the browser piece by piece behind a viewer cookie, so the .softn file itself is never at a URL. |
You do not want visitors (or crawlers) to be able to download your app as one file. |
softn-app-private-with-backend-linux-x64-v0.0.27.zip |
One app served privately by PHP, with an optional server backend. The privately served page above together with the PHP server backend: three folders, webroot/, private/ and backend/. The app works at once without the backend; you enable the backend when the app needs it. |
You want the .softn file kept off every URL and the app has a private server part. |
softn-formlogic-runtime-v0.0.27.zip |
The SoftN runtime for FormLogic. Everything FormLogic embeds from SoftN, already built: the sandboxed frame FormLogic runs an app in, Builder and Studio as editors inside FormLogic, the server-side runtime for native apps, and the starter adapter. FormLogic’s build downloads this from the latest SoftN release instead of building SoftN from source. | You run FormLogic and its build cannot reach GitHub; download this and point SOFTN_RELEASE_ARCHIVE at it. |
Each archive unzips with a README.md at the top that explains it; RELEASE-GUIDE.md below compares them all.
The backend download targets Linux x86-64 with Apache/PHP and process execution enabled. It is a generic, unconfigured distribution: install your own public and private app bundles and preserve existing configuration, keys and databases when upgrading. Native XDB synchronization remains a Rust-host feature.
The complete website archive's name carries the tag of the zipp engine inside it. It ships no
example apps: the directory starts empty, and .softn files dropped on any
page of the site publish into it — one, or a folder at once, with the admin
key from data/config.json lifting the hourly limit for the site owner. The
example apps are published as .softn downloads with every
softn-Examples release.
The desktop loader and builder are not attached to releases; they build from
this tag's manifests with npm run tauri build in their apps. RELEASE-GUIDE.md
below compares every download and says which one to take.
The single-app archive includes a small counter example; replace it with your own bundle. It has no app download controls, but browser-delivered app bytes remain extractable. Required license notices are included in all archives.
Built with ZIPP v0.0.32 (its javascript-python-base engine, javascript engine and torch package; the release SHA256SUMS has sha256 2f54dcc6d32729814483d4d7967aa16dd2d80a37f6aa9fee6af2f4f278469876).