@makerclay/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +221 -0
  2. package/README.md +82 -0
  3. package/package.json +52 -0
  4. package/src/admin/listing.js +149 -0
  5. package/src/admin/routes.js +362 -0
  6. package/src/attic.js +168 -0
  7. package/src/auth/can.js +79 -0
  8. package/src/auth/csrf.js +74 -0
  9. package/src/auth/none.js +24 -0
  10. package/src/auth/password.js +281 -0
  11. package/src/auth/passwords.js +79 -0
  12. package/src/auth/rate-limit.js +66 -0
  13. package/src/auth/sessions.js +86 -0
  14. package/src/auth/token-lanes.js +72 -0
  15. package/src/boot.js +120 -0
  16. package/src/client.js +99 -0
  17. package/src/collections/index.js +397 -0
  18. package/src/collections/routes.js +166 -0
  19. package/src/create-host.js +351 -0
  20. package/src/derived/data-extractor.js +22 -0
  21. package/src/derived/index.js +121 -0
  22. package/src/documents/format-html.js +313 -0
  23. package/src/documents/replace.js +257 -0
  24. package/src/documents/root-attrs.js +171 -0
  25. package/src/documents/serve.js +137 -0
  26. package/src/documents/stale.js +20 -0
  27. package/src/index.js +12 -0
  28. package/src/inspect.js +136 -0
  29. package/src/json-errors.js +59 -0
  30. package/src/livesync.js +75 -0
  31. package/src/nodes/identity.js +14 -0
  32. package/src/nodes/names.js +57 -0
  33. package/src/nodes/ops.js +613 -0
  34. package/src/nodes/scanner.js +321 -0
  35. package/src/nodes/store.js +111 -0
  36. package/src/pages.js +166 -0
  37. package/src/paths.js +302 -0
  38. package/src/recovery/overlay.js +327 -0
  39. package/src/recovery/replay.js +236 -0
  40. package/src/recovery-ui.js +185 -0
  41. package/src/recovery.js +30 -0
  42. package/src/requests.js +73 -0
  43. package/src/routes/meta.js +62 -0
  44. package/src/routes/read.js +105 -0
  45. package/src/routes/save.js +102 -0
  46. package/src/routes/sync.js +118 -0
  47. package/src/routes/upload.js +128 -0
  48. package/src/share/index.js +207 -0
  49. package/src/share/save-tokens.js +65 -0
  50. package/src/spec/codes.js +42 -0
  51. package/src/spec/meta.js +52 -0
  52. package/src/spec/wire.js +115 -0
  53. package/src/store/index.js +29 -0
  54. package/src/store/migrations/001-init.sql +114 -0
  55. package/src/store/sqlite.js +540 -0
  56. package/src/templates.js +50 -0
  57. package/src/tenants/index.js +355 -0
  58. package/src/tenants/isolation.js +91 -0
  59. package/src/tenants/routes.js +131 -0
  60. package/src/ui.js +95 -0
  61. package/src/util/cookies.js +26 -0
  62. package/src/util/express.js +8 -0
  63. package/src/util/fsx.js +205 -0
  64. package/src/util/id.js +37 -0
  65. package/src/util/lockfile.js +52 -0
  66. package/src/util/locks.js +35 -0
  67. package/src/util/multipart.js +33 -0
  68. package/src/versions/files.js +307 -0
  69. package/src/versions/index.js +16 -0
  70. package/src/versions/naming.js +172 -0
  71. package/src/versions/routes.js +91 -0
  72. package/src/wire-compat.js +61 -0
  73. package/ui/app.css +164 -0
  74. package/ui/attic.html +198 -0
  75. package/ui/dashboard.html +456 -0
  76. package/ui/editor.html +156 -0
  77. package/ui/error.html +18 -0
  78. package/ui/login.html +58 -0
  79. package/ui/records.html +173 -0
  80. package/ui/recovery.html +152 -0
  81. package/ui/setup.html +61 -0
  82. package/ui/share-qr.html +44 -0
  83. package/ui/templates/blank.html +16 -0
  84. package/ui/templates/devlog.html +71 -0
  85. package/ui/templates/hackable-dashboard.html +145 -0
  86. package/ui/templates/kanban.html +85 -0
  87. package/ui/templates/landing.html +108 -0
  88. package/ui/templates/writer.html +50 -0
  89. package/ui/tenants.html +172 -0
  90. package/ui/trash.html +134 -0
  91. package/ui/versions.html +114 -0
package/LICENSE ADDED
@@ -0,0 +1,221 @@
1
+ First Million Stays Yours License
2
+ Version 1.0
3
+
4
+ Copyright (c) 2026 Hyperspace Systems LLC
5
+
6
+ ----------------------------------------------------------------------
7
+ LICENSE DETAILS FOR THIS RELEASE
8
+
9
+ Software: @makerclay/core 1.0.0
10
+ Released: 2026-08-20
11
+ MIT Conversion Date: 2028-02-20 (18 months after release)
12
+
13
+ Licensor: Hyperspace Systems LLC, and its successors
14
+ and assigns
15
+ Payment, program, plain answers: https://hyperclay.com/host-program
16
+ Questions, notices, Coverage Certificates: license@hyperclay.com
17
+ SPDX identifier: LicenseRef-First-Million-Stays-Yours-1.0
18
+ (MIT after the Conversion Date)
19
+ Where to start: Section 1.
20
+ ----------------------------------------------------------------------
21
+
22
+
23
+ 1. THE CONDITION, AND WHO IT NEVER REACHES
24
+
25
+ This license gives You every right the MIT License gives You,
26
+ granted in Section 2, on one condition beyond MIT's: if products
27
+ and services that contain the Software, derive from it, or provide
28
+ its functionality to others by running it bring You, together with
29
+ every entity under common control with You, more than
30
+ US $1,000,000 in a calendar year, You pay Licensor 3% of the
31
+ amount above US $1,000,000. Almost nobody owes it. Where the
32
+ Software is one component of something larger, only the share
33
+ fairly attributable to it counts.
34
+
35
+ Under that line You owe nothing and condition (b) of Section 2 is
36
+ satisfied for the year with no act on Your part: nothing to pay,
37
+ report, register, or notify. Keep the copyright notice in copies,
38
+ as condition (a) requires, and You are finished with this license.
39
+ The line is tested year by year, so look again once a year. If You
40
+ are not sure whether something You sell is covered, Section 5
41
+ makes the answer free and binding within 30 days.
42
+
43
+ Crossing the line never makes the first US $1,000,000 payable,
44
+ never reaches back into an earlier year, and never ends this
45
+ license. Sections 4 through 6 say how Covered Revenue is counted
46
+ and the fee is paid. This section describes the condition; Section
47
+ 2 states it and Sections 4 through 6 measure it. Where this
48
+ section and those differ, those govern, and nothing in this
49
+ section creates an obligation they do not.
50
+
51
+
52
+ 2. GRANT AND CONDITIONS
53
+
54
+ Permission is hereby granted, free of charge except as condition (b)
55
+ below provides, to any person obtaining a copy of this software and
56
+ associated documentation files (the "Software"), to deal in the
57
+ Software without restriction, including without limitation the rights
58
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or
59
+ sell copies of the Software, and to permit persons to whom the
60
+ Software is furnished to do so, subject to the following conditions:
61
+
62
+ (a) The above copyright notice and this permission notice shall be
63
+ included in all copies or substantial portions of the Software.
64
+
65
+ (b) If in a calendar year the Covered Revenue of You and every
66
+ entity under common control with You exceeds US $1,000,000,
67
+ You shall pay Licensor 3% of the amount above US $1,000,000,
68
+ as Section 6 provides. "Covered Revenue" is receipts from
69
+ products and services You supply that themselves contain the
70
+ Software, are derived from it, or provide its functionality
71
+ to others by running it, less refunds, chargebacks, and
72
+ transaction taxes, attributed as Section 4 provides.
73
+ Otherwise this condition is satisfied for the year with no
74
+ act on Your part.
75
+
76
+ Both (a) and (b) are conditions of this grant, not independent
77
+ covenants. Exercising the rights granted here without satisfying
78
+ them, subject to the cure and good-faith provisions of Section 6, is
79
+ outside the scope of this license.
80
+
81
+ In this license, the "Software" is the version of the work identified
82
+ in the License Details above, in source or binary form, including
83
+ modifications of it and works derived from it; a work is derived
84
+ from the Software only if it would infringe copyright in the
85
+ Software without this license; the "Package" is the software line of
86
+ which this Software is one version; and "You" means the person or
87
+ legal entity exercising these rights.
88
+
89
+
90
+ 3. CONVERSION TO THE MIT LICENSE
91
+
92
+ On the MIT Conversion Date stated above, this version of the
93
+ Software becomes licensed to everyone under the MIT License,
94
+ automatically and without notice or any further act. That happens by
95
+ operation of this text. On that date:
96
+
97
+ (a) in Section 2, the words "free of charge except as condition
98
+ (b) below provides" read simply "free of charge";
99
+
100
+ (b) in Section 2, condition (b), the label "(a)", the paragraph
101
+ beginning "Both (a) and (b)", and the paragraph beginning "In
102
+ this license" are deleted;
103
+
104
+ (c) Section 1 and Sections 4 through 6 cease to have effect;
105
+
106
+ (d) this Section 3 ceases to have effect, the conversion it makes
107
+ having already taken effect irrevocably; and
108
+
109
+ (e) the License Details block, the section headings, and the
110
+ section numbering become a record of this release and have no
111
+ operative effect.
112
+
113
+ What then remains, Section 2 as amended and the disclaimer in
114
+ Section 7, under the copyright notice above, is the MIT License
115
+ (SPDX identifier: MIT), word for word. From that date copies of this
116
+ version may also carry the standard MIT License text in place of
117
+ this document.
118
+
119
+ This conversion is automatic, unconditional, and irrevocable, and
120
+ binds Licensor and its successors and assigns. It takes effect
121
+ whether or not Licensor republishes this version under a separate
122
+ MIT license file.
123
+
124
+ The same conversion happens immediately, without notice or any
125
+ further act, if for any period of 12 consecutive months Licensor
126
+ neither makes a public release of the Package nor provides any
127
+ security response to a responsibly reported vulnerability in it, or
128
+ if Licensor ceases business operations without a successor that
129
+ assumes this license.
130
+
131
+ The terms of this license, including the MIT Conversion Date, are
132
+ fixed for this version of the Software at its release and will never
133
+ change. Later versions of the Package may ship under revised terms;
134
+ revised terms apply only to those later versions, never
135
+ retroactively.
136
+
137
+
138
+ 4. COUNTING COVERED REVENUE
139
+
140
+ "Control" means (i) the power, direct or indirect, to cause the
141
+ direction or management of an entity, whether by contract or
142
+ otherwise, (ii) ownership of fifty percent (50%) or more of an
143
+ entity's outstanding shares, or (iii) beneficial ownership of an
144
+ entity. Aggregation across common control exists solely to prevent
145
+ splitting one business across entities to multiply the
146
+ US $1,000,000 exemption; there is no other affiliate test in this
147
+ license. Aggregated Covered Revenue is counted once, and one
148
+ self-certification and payment by any of the aggregated entities
149
+ discharges condition (b) and Section 6 for all of them.
150
+
151
+ The fee is charged at most once for a product or service. It never
152
+ stacks across Licensor's other packages under this License used
153
+ together in one offering, across versions of the Software, or across
154
+ copies and instances, and revenue counted once under this or any other
155
+ First Million Stays Yours License is never counted again.
156
+
157
+ If the Software, a work derived from it, or a product or service
158
+ that contains it is one component of a larger product, service,
159
+ bundle, or subscription, whether or not that component is
160
+ separately priced or sold, its Covered Revenue is the portion of
161
+ the receipts fairly attributable to that component: its actual
162
+ standalone price if You also sell it on a standalone basis;
163
+ otherwise, any reasonable allocation method You apply consistently
164
+ in Your own financial statements or management accounts. An
165
+ allocation that complies with this paragraph binds Licensor for the
166
+ calendar year it covers, and allocated Covered Revenue never
167
+ exceeds the actual receipts of the whole.
168
+
169
+ Where the customer holds its own copy of the Software under this
170
+ license, Your receipts for installing, configuring, or maintaining
171
+ that copy are not Covered Revenue.
172
+
173
+
174
+ 5. COVERAGE CERTIFICATES
175
+
176
+ At any time and at no charge, You may send Licensor, at the address
177
+ above, a complete and accurate written description of an offering
178
+ and, if applicable, a proposed allocation. Licensor shall respond
179
+ within 30 days with a written determination of whether, and to what
180
+ extent, receipts from the offering are Covered Revenue and whether
181
+ the allocation complies with Section 4. Silence for 30 days accepts
182
+ the request as submitted, except as to facts the request omitted or
183
+ misstated.
184
+
185
+ A determination, including a deemed acceptance, binds Licensor and
186
+ its successors for the versions of the Package it names, for as long
187
+ as the material facts described in the request remain accurate.
188
+
189
+
190
+ 6. REPORTING, PAYMENT, AUDIT, AND CURE
191
+
192
+ If a fee is due under condition (b) for a calendar year, You shall,
193
+ within 90 days after the end of that year, send Licensor a
194
+ self-certification stating the Covered Revenue and the fee due, and
195
+ pay the fee through the address or URL in the License Details
196
+ above.
197
+
198
+ Licensor may audit the calculation at most once per calendar year,
199
+ at Licensor's expense, through an independent accountant under
200
+ confidentiality, on 30 days' written notice. If an audit finds
201
+ underpayment exceeding 5% of the amount due, You shall also
202
+ reimburse the reasonable cost of that audit.
203
+
204
+ A good-faith underpayment, late report, or late payment does not
205
+ terminate this license and is not copyright infringement; any
206
+ unpaid amount remains a debt owed to Licensor. If You fail to pay
207
+ or report as this section requires, Licensor shall give You written
208
+ notice, and You have 30 days from that notice to cure. Only a
209
+ failure to cure within those 30 days is a failure of condition (b).
210
+
211
+
212
+ 7. DISCLAIMER
213
+
214
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
215
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
216
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
217
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
218
+ BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
219
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
220
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
221
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @makerclay/core
2
+
3
+ > **License: First Million Stays Yours License.** Free to use, no matter who you are or what you use it for. It only ever costs money if you sell or host a product built on this software and that product makes you more than $1M in a year. Under that line you owe nothing, sign nothing, register nowhere.
4
+ >
5
+ > | Free under | Above it | Becomes MIT |
6
+ > |---|---|---|
7
+ > | $1M/year from products built on it | 3% of the excess | 2028-02-20 (this version) |
8
+ >
9
+ > Plain answers: [hyperclay.com/host-program](https://hyperclay.com/host-program) · binding text in [LICENSE](LICENSE) · questions: license@hyperclay.com
10
+
11
+ The host for malleable HTML files, as an embeddable Express app.
12
+
13
+ This is the engine behind [`makerclay`](https://www.npmjs.com/package/makerclay).
14
+ If you want to run a server, install that instead. Install this when you are
15
+ building the server: a desktop app, a different CLI, a host with its own auth.
16
+
17
+ ```js
18
+ import express from 'express';
19
+ import { createHost, createSqliteStore } from '@makerclay/core';
20
+ import { ownerPassword } from '@makerclay/core/auth/password';
21
+
22
+ const host = createHost({
23
+ data: './my-data',
24
+ store: createSqliteStore(),
25
+ auth: ownerPassword(),
26
+ });
27
+
28
+ await host.ready;
29
+ express().use(host.app).listen(4600);
30
+ ```
31
+
32
+ `host.app` is an Express app. `host.ready` resolves once the boot scan and any
33
+ crash recovery have finished. `host.close()` releases the single-writer lock.
34
+
35
+ ## What `createHost` gives you
36
+
37
+ Everything under `/_/` (dashboard, editor, versions, trash, uploads, share links,
38
+ collections, signups, the spec's `/_/meta` and `/_/save` lanes), plus document
39
+ serving for everything else. It owns one directory:
40
+
41
+ ```
42
+ <data>/files/ documents
43
+ <data>/tenants/ one folder per signup
44
+ <data>/versions/ history, keyed by document identity
45
+ <data>/trash/ soft deletes
46
+ <data>/records/ collection submissions
47
+ <data>/makerclay.db a sparse overlay
48
+ ```
49
+
50
+ **The store is an overlay, not a mirror.** Rows exist only for state a file cannot
51
+ hold: which document is private, which is trashed, who is signed in. Lose the
52
+ database and the overlay rebuilds itself from the durable records under
53
+ `versions/`, minus the credentials, which are reissued rather than invented. With
54
+ state on disk and no records left to read it by, the host refuses to serve.
55
+
56
+ ## The pieces you can replace
57
+
58
+ | Option | What it is |
59
+ |---|---|
60
+ | `auth` | An identity provider. `none()` makes every request the owner (embedded/desktop). `ownerPassword()` is the single-owner password provider with first-run setup. Write your own: it needs `identify(req)` and, optionally, `routes()`. |
61
+ | `store` | The metadata overlay. `createSqliteStore()` uses the built-in `node:sqlite`. `null` is supported and turns off everything that needs a row. |
62
+ | `isolation` | `off`, `untrusted` (the default: tenant documents and any file the host never saved) or `all`. |
63
+ | `features` | Switches for the dashboard, editor, uploads, collections, share links, signups. |
64
+ | `wire` | Compatibility with older clients. See `src/wire-compat.js`, which names the deployed client behind every shim. |
65
+
66
+ Also exported: `can()` and the capability matrix, the path resolver, the spec's
67
+ etag and header helpers, the root-attribute reader and writer, and `ulid()`.
68
+
69
+ ## Invariants it will not let you break
70
+
71
+ - Every document mutation goes through `host.documents.replace`. Nothing else
72
+ writes a managed file.
73
+ - The document root is never served statically. Every read passes the gate.
74
+ - Serving never mutates a stored file. Serve-time injection happens on the
75
+ response bytes, and both injected attributes are stripped on the way back in.
76
+ - A response on any save lane is a JSON object, on every status.
77
+
78
+ ## Requirements
79
+
80
+ Node 22.13 or newer, for `node:sqlite`, which is behind a flag before then.
81
+
82
+ First Million Stays Yours License; see the matrix at the top of this README and the LICENSE file.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@makerclay/core",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "The embeddable single-tenant host for malleable HTML files",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "main": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./paths": "./src/paths.js",
11
+ "./store/sqlite": "./src/store/sqlite.js",
12
+ "./auth/password": "./src/auth/password.js",
13
+ "./auth/none": "./src/auth/none.js",
14
+ "./auth/can": "./src/auth/can.js"
15
+ },
16
+ "files": [
17
+ "src/",
18
+ "ui/",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@panphora/clayjs": "^1.2.0",
27
+ "busboy": "^1.6.0",
28
+ "cheerio": "1.2.0",
29
+ "chokidar": "^4.0.0",
30
+ "codemirror": "^5.65.21",
31
+ "express": "^4.21.2",
32
+ "hyper-html-api": "0.8.0",
33
+ "js-beautify": "^1.15.1",
34
+ "livesync-hyperclay": "^0.15.1",
35
+ "qrcode-generator": "^1.5.2",
36
+ "upath": "^2.0.1"
37
+ },
38
+ "peerDependencies": {
39
+ "tailwind-hyperclay": "*"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "tailwind-hyperclay": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "engines": {
47
+ "node": ">=22.13.0"
48
+ },
49
+ "scripts": {
50
+ "prepublishOnly": "python3 ../../scripts/ablation-check.py LICENSE"
51
+ }
52
+ }
@@ -0,0 +1,149 @@
1
+ import path from 'upath';
2
+ import { statIfExists, readdirIfExists } from '../util/fsx.js';
3
+ import { isDocumentPath, isReservedNodePath } from '../paths.js';
4
+
5
+ // The `/_/nodes` serializer. Ordering and formatting are ported from the
6
+ // platform's dashboard-nodes.js; the data model underneath is different, and
7
+ // deliberately so: the DIRECTORY is the truth and the store only decorates it.
8
+ // List a folder with the db deleted and you still get every file, just without
9
+ // the private/shared badges.
10
+
11
+ const SORTS = new Set(['name', 'modified', 'size', 'kind']);
12
+
13
+ function formatSize(bytes) {
14
+ if (bytes === null || bytes === undefined) return '';
15
+ if (bytes < 1024) return `${bytes} B`;
16
+ const units = ['KB', 'MB', 'GB'];
17
+ let value = bytes / 1024;
18
+ let unit = 0;
19
+ while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
20
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
21
+ }
22
+
23
+ function buildBreadcrumbs(dir) {
24
+ const crumbs = [{ name: 'Home', path: '' }];
25
+ if (!dir) return crumbs;
26
+ const segments = dir.split('/').filter(Boolean);
27
+ let accumulated = '';
28
+ for (const segment of segments) {
29
+ accumulated = accumulated ? `${accumulated}/${segment}` : segment;
30
+ crumbs.push({ name: segment, path: accumulated });
31
+ }
32
+ return crumbs;
33
+ }
34
+
35
+ // Folders always lead, whatever the sort. A file browser that interleaves them
36
+ // makes the tree unreadable, and every listing anyone has ever liked does this.
37
+ function sortEntries(entries, sort = 'name', direction = 'asc') {
38
+ const key = SORTS.has(sort) ? sort : 'name';
39
+ const sign = direction === 'desc' ? -1 : 1;
40
+ const compare = {
41
+ name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
42
+ modified: (a, b) => (a.modified || 0) - (b.modified || 0),
43
+ size: (a, b) => (a.size || 0) - (b.size || 0),
44
+ kind: (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
45
+ }[key];
46
+
47
+ return [...entries].sort((a, b) => {
48
+ if (a.kind === 'dir' && b.kind !== 'dir') return -1;
49
+ if (b.kind === 'dir' && a.kind !== 'dir') return 1;
50
+ return compare(a, b) * sign || a.name.localeCompare(b.name);
51
+ });
52
+ }
53
+
54
+ export function createListing({ paths, nodes, isolation = null, store = null }) {
55
+ function decorate(entry, row) {
56
+ if (!row) return entry;
57
+ return {
58
+ ...entry,
59
+ id: row.id,
60
+ private: !!row.private,
61
+ signups: !!row.signups,
62
+ isolated: isolation ? isolation.isolatedFor(row) : false,
63
+ shared: store ? store.listShares(row.id).filter((share) => !share.revokedAt).length : 0,
64
+ collection: store ? (store.getCollection(row.id)?.slug ?? null) : null,
65
+ etag: row.etag ?? null,
66
+ };
67
+ }
68
+
69
+ async function list({ owner = '', dir = '', q = '', sort = 'name', direction = 'asc' } = {}) {
70
+ let target = paths.realmRoot(owner);
71
+ if (dir) {
72
+ // The same resolution every other reader uses, rather than a bare join:
73
+ // it refuses `..`, dotfiles, an absolute path and any symlink that leaves
74
+ // the realm. A join would answer `?dir=../../../../etc` with a listing of
75
+ // the machine.
76
+ try {
77
+ target = await paths.resolveRead(owner, dir);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+ const stat = await statIfExists(target);
83
+ if (!stat?.isDirectory()) return null;
84
+
85
+ const needle = String(q || '').trim().toLowerCase();
86
+ const entries = [];
87
+ for (const item of await readdirIfExists(target, { withFileTypes: true })) {
88
+ if (item.name.startsWith('.')) continue;
89
+ // A symlink inside a managed tree is refused everywhere else, so listing
90
+ // one would advertise a file that 404s and leak its target's size.
91
+ if (item.isSymbolicLink()) continue;
92
+ if (needle && !item.name.toLowerCase().includes(needle)) continue;
93
+ const full = path.join(target, item.name);
94
+ const info = await statIfExists(full);
95
+ if (!info) continue;
96
+ const relPath = dir ? `${dir}/${item.name}` : item.name;
97
+ const kind = info.isDirectory() ? 'dir' : (isDocumentPath(relPath) ? 'doc' : 'file');
98
+ entries.push(decorate({
99
+ name: item.name,
100
+ path: relPath,
101
+ kind,
102
+ size: info.isDirectory() ? null : info.size,
103
+ sizeLabel: info.isDirectory() ? '' : formatSize(info.size),
104
+ modified: Math.round(info.mtimeMs),
105
+ url: `${owner ? `/~${owner}` : ''}/${relPath}`,
106
+ }, nodes.get(owner, relPath)));
107
+ }
108
+
109
+ return {
110
+ owner,
111
+ dir,
112
+ breadcrumbs: buildBreadcrumbs(dir),
113
+ sort,
114
+ direction,
115
+ q: String(q || ''),
116
+ entries: sortEntries(entries, sort, direction),
117
+ };
118
+ }
119
+
120
+ async function trash() {
121
+ if (!store) return { entries: [] };
122
+ // A deleted collection record is a row in here too, but it is not a file and
123
+ // it comes back through the collection API, not this view.
124
+ const rows = store.listTrash().filter((row) => !isReservedNodePath(row.path));
125
+ const entries = [];
126
+ for (const row of rows) {
127
+ // A child of a trashed folder went in with its parent and comes back with
128
+ // it, so only the roots are offered.
129
+ const parent = path.dirname(row.path);
130
+ if (parent !== '.' && rows.some((other) => other.id !== row.id && other.path === parent)) continue;
131
+ const stored = paths.trashPath(row.id, path.basename(row.path));
132
+ const info = await statIfExists(stored);
133
+ entries.push({
134
+ id: row.id,
135
+ name: path.basename(row.path),
136
+ path: row.path,
137
+ owner: row.owner,
138
+ kind: row.kind,
139
+ deletedAt: row.deletedAt,
140
+ size: info && !info.isDirectory() ? info.size : null,
141
+ sizeLabel: info && !info.isDirectory() ? formatSize(info.size) : '',
142
+ recoverable: !!info,
143
+ });
144
+ }
145
+ return { entries };
146
+ }
147
+
148
+ return { list, trash };
149
+ }