@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.
- package/LICENSE +221 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/admin/listing.js +149 -0
- package/src/admin/routes.js +362 -0
- package/src/attic.js +168 -0
- package/src/auth/can.js +79 -0
- package/src/auth/csrf.js +74 -0
- package/src/auth/none.js +24 -0
- package/src/auth/password.js +281 -0
- package/src/auth/passwords.js +79 -0
- package/src/auth/rate-limit.js +66 -0
- package/src/auth/sessions.js +86 -0
- package/src/auth/token-lanes.js +72 -0
- package/src/boot.js +120 -0
- package/src/client.js +99 -0
- package/src/collections/index.js +397 -0
- package/src/collections/routes.js +166 -0
- package/src/create-host.js +351 -0
- package/src/derived/data-extractor.js +22 -0
- package/src/derived/index.js +121 -0
- package/src/documents/format-html.js +313 -0
- package/src/documents/replace.js +257 -0
- package/src/documents/root-attrs.js +171 -0
- package/src/documents/serve.js +137 -0
- package/src/documents/stale.js +20 -0
- package/src/index.js +12 -0
- package/src/inspect.js +136 -0
- package/src/json-errors.js +59 -0
- package/src/livesync.js +75 -0
- package/src/nodes/identity.js +14 -0
- package/src/nodes/names.js +57 -0
- package/src/nodes/ops.js +613 -0
- package/src/nodes/scanner.js +321 -0
- package/src/nodes/store.js +111 -0
- package/src/pages.js +166 -0
- package/src/paths.js +302 -0
- package/src/recovery/overlay.js +327 -0
- package/src/recovery/replay.js +236 -0
- package/src/recovery-ui.js +185 -0
- package/src/recovery.js +30 -0
- package/src/requests.js +73 -0
- package/src/routes/meta.js +62 -0
- package/src/routes/read.js +105 -0
- package/src/routes/save.js +102 -0
- package/src/routes/sync.js +118 -0
- package/src/routes/upload.js +128 -0
- package/src/share/index.js +207 -0
- package/src/share/save-tokens.js +65 -0
- package/src/spec/codes.js +42 -0
- package/src/spec/meta.js +52 -0
- package/src/spec/wire.js +115 -0
- package/src/store/index.js +29 -0
- package/src/store/migrations/001-init.sql +114 -0
- package/src/store/sqlite.js +540 -0
- package/src/templates.js +50 -0
- package/src/tenants/index.js +355 -0
- package/src/tenants/isolation.js +91 -0
- package/src/tenants/routes.js +131 -0
- package/src/ui.js +95 -0
- package/src/util/cookies.js +26 -0
- package/src/util/express.js +8 -0
- package/src/util/fsx.js +205 -0
- package/src/util/id.js +37 -0
- package/src/util/lockfile.js +52 -0
- package/src/util/locks.js +35 -0
- package/src/util/multipart.js +33 -0
- package/src/versions/files.js +307 -0
- package/src/versions/index.js +16 -0
- package/src/versions/naming.js +172 -0
- package/src/versions/routes.js +91 -0
- package/src/wire-compat.js +61 -0
- package/ui/app.css +164 -0
- package/ui/attic.html +198 -0
- package/ui/dashboard.html +456 -0
- package/ui/editor.html +156 -0
- package/ui/error.html +18 -0
- package/ui/login.html +58 -0
- package/ui/records.html +173 -0
- package/ui/recovery.html +152 -0
- package/ui/setup.html +61 -0
- package/ui/share-qr.html +44 -0
- package/ui/templates/blank.html +16 -0
- package/ui/templates/devlog.html +71 -0
- package/ui/templates/hackable-dashboard.html +145 -0
- package/ui/templates/kanban.html +85 -0
- package/ui/templates/landing.html +108 -0
- package/ui/templates/writer.html +50 -0
- package/ui/tenants.html +172 -0
- package/ui/trash.html +134 -0
- package/ui/versions.html +114 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { can } from '../auth/can.js';
|
|
3
|
+
import { jsonError } from '../json-errors.js';
|
|
4
|
+
import { createRateLimiter, parseRate } from '../auth/rate-limit.js';
|
|
5
|
+
|
|
6
|
+
// The collection HTTP surface.
|
|
7
|
+
//
|
|
8
|
+
// The submit lane is deliberately the odd one out: CORS-open, CSRF-exempt, and
|
|
9
|
+
// authenticated by a token in the BODY rather than by a cookie. That is not a
|
|
10
|
+
// gap, it is the feature — a plain <form> on the owner's blog posting into their
|
|
11
|
+
// makerclay is the thing collections exist for, and a cookie-based defense
|
|
12
|
+
// cannot survive that trip.
|
|
13
|
+
//
|
|
14
|
+
// Everything else on this router is owner-only or code-gated.
|
|
15
|
+
|
|
16
|
+
const READ_CODE_HEADER = 'x-collection-code';
|
|
17
|
+
|
|
18
|
+
export function mountCollectionRoutes(ctx) {
|
|
19
|
+
const { router, config, collections, gateCtx, wrap, clock } = ctx;
|
|
20
|
+
const json = express.json({ limit: '1mb' });
|
|
21
|
+
const rate = parseRate(config.collections?.submitRateLimit, { max: 30, windowMs: 60_000 });
|
|
22
|
+
const submitLimiter = createRateLimiter({ clock, ...rate });
|
|
23
|
+
|
|
24
|
+
function requireOwner(req, res, next) {
|
|
25
|
+
if (!can(req.actor, 'admin', null, gateCtx)) return jsonError(res, 'not-found', 'Not found');
|
|
26
|
+
return next();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function openCors(res) {
|
|
30
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
31
|
+
res.setHeader('Vary', 'Origin');
|
|
32
|
+
res.setHeader('Access-Control-Allow-Headers', `Content-Type, ${READ_CODE_HEADER}`);
|
|
33
|
+
// Never Allow-Credentials: the token in the body is the credential, and an
|
|
34
|
+
// open origin plus cookies is how a same-origin attacker gets a free ride.
|
|
35
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectionOf(req) {
|
|
39
|
+
return collections.bySlug(req.params.slug);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- marking (owner) ---------------------------------------------------
|
|
43
|
+
|
|
44
|
+
router.post('/collection', requireOwner, json, wrap(async (req, res) => {
|
|
45
|
+
const relPath = String(req.body?.path || '').replace(/^\/+/, '');
|
|
46
|
+
if (!relPath) return jsonError(res, 'bad-request', 'path is required.');
|
|
47
|
+
const collection = await collections.mark({ actor: req.actor, relPath, ctx: gateCtx });
|
|
48
|
+
return res.json({
|
|
49
|
+
ok: true,
|
|
50
|
+
slug: collection.slug,
|
|
51
|
+
submitToken: collection.submitToken,
|
|
52
|
+
accepting: collection.accepting,
|
|
53
|
+
submitUrl: `/_/collection/${collection.slug}/records`,
|
|
54
|
+
});
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
router.post('/collection/unmark', requireOwner, json, wrap(async (req, res) => {
|
|
58
|
+
const relPath = String(req.body?.path || '').replace(/^\/+/, '');
|
|
59
|
+
if (!relPath) return jsonError(res, 'bad-request', 'path is required.');
|
|
60
|
+
return res.json(await collections.unmark({ actor: req.actor, relPath, ctx: gateCtx }));
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
router.post('/collection/:slug/accepting', requireOwner, json, wrap(async (req, res) => {
|
|
64
|
+
const updated = collections.setAccepting({
|
|
65
|
+
actor: req.actor, collection: collectionOf(req), ctx: gateCtx, accepting: !!req.body?.accepting,
|
|
66
|
+
});
|
|
67
|
+
return res.json({ ok: true, accepting: updated.accepting });
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// ---- records -----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
router.options(/^\/collection\/[^/]+\/records(\/.*)?$/, (req, res) => {
|
|
73
|
+
openCors(res);
|
|
74
|
+
res.status(204).end();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
router.get('/collection/:slug/records', requireOwner, wrap(async (req, res) => {
|
|
78
|
+
const collection = collectionOf(req);
|
|
79
|
+
const records = await collections.list({ actor: req.actor, collection, ctx: gateCtx });
|
|
80
|
+
return res.json({
|
|
81
|
+
ok: true, slug: collection.slug, accepting: !!collection.accepting, count: records.length, records,
|
|
82
|
+
});
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// The public lane.
|
|
86
|
+
router.post('/collection/:slug/records', json, wrap(async (req, res) => {
|
|
87
|
+
openCors(res);
|
|
88
|
+
const collection = collectionOf(req);
|
|
89
|
+
const isOwner = collections.isOwnerOf(req.actor, collection, gateCtx);
|
|
90
|
+
if (!isOwner) {
|
|
91
|
+
const limit = submitLimiter.check(req);
|
|
92
|
+
if (!limit.ok) {
|
|
93
|
+
res.setHeader('Retry-After', String(limit.retryAfter));
|
|
94
|
+
return jsonError(res, 'rate-limited', 'Too many submissions. Slow down.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const body = req.body || {};
|
|
99
|
+
const result = await collections.submit({
|
|
100
|
+
collection,
|
|
101
|
+
actor: req.actor,
|
|
102
|
+
ctx: gateCtx,
|
|
103
|
+
key: body.key ?? null,
|
|
104
|
+
data: body.data && typeof body.data === 'object' ? body.data : body,
|
|
105
|
+
code: typeof body.code === 'string' ? body.code : null,
|
|
106
|
+
submitToken: typeof body.token === 'string' ? body.token : null,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return res.status(result.created ? 201 : 200).json({
|
|
110
|
+
ok: true,
|
|
111
|
+
key: result.key,
|
|
112
|
+
opaque: result.record.opaque,
|
|
113
|
+
created: result.created,
|
|
114
|
+
});
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
router.get('/collection/:slug/records/:key', wrap(async (req, res) => {
|
|
118
|
+
openCors(res);
|
|
119
|
+
const collection = collectionOf(req);
|
|
120
|
+
const record = await collections.read({
|
|
121
|
+
collection,
|
|
122
|
+
actor: req.actor,
|
|
123
|
+
ctx: gateCtx,
|
|
124
|
+
key: req.params.key,
|
|
125
|
+
code: req.get(READ_CODE_HEADER) || null,
|
|
126
|
+
req,
|
|
127
|
+
res,
|
|
128
|
+
});
|
|
129
|
+
if (!record) return jsonError(res, 'not-found', 'No such record.');
|
|
130
|
+
return res.json({ ok: true, record });
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
router.put('/collection/:slug/records/:key', json, wrap(async (req, res) => {
|
|
134
|
+
openCors(res);
|
|
135
|
+
const collection = collectionOf(req);
|
|
136
|
+
const body = req.body || {};
|
|
137
|
+
const record = await collections.update({
|
|
138
|
+
collection,
|
|
139
|
+
actor: req.actor,
|
|
140
|
+
ctx: gateCtx,
|
|
141
|
+
key: req.params.key,
|
|
142
|
+
// The WRITE code travels in the body, the read code in a header. Different
|
|
143
|
+
// lanes, so a read-only viewer never learns the write code from a URL or
|
|
144
|
+
// a proxy log.
|
|
145
|
+
code: typeof body.code === 'string' ? body.code : null,
|
|
146
|
+
data: body.data && typeof body.data === 'object' ? body.data : body,
|
|
147
|
+
req,
|
|
148
|
+
res,
|
|
149
|
+
});
|
|
150
|
+
return res.json({ ok: true, record });
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
router.delete('/collection/:slug/records/:key', requireOwner, wrap(async (req, res) => {
|
|
154
|
+
const collection = collectionOf(req);
|
|
155
|
+
return res.json(await collections.remove({
|
|
156
|
+
actor: req.actor, collection, ctx: gateCtx, key: req.params.key,
|
|
157
|
+
}));
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
router.post('/collection/:slug/records/:key/restore', requireOwner, wrap(async (req, res) => {
|
|
161
|
+
const collection = collectionOf(req);
|
|
162
|
+
return res.json(await collections.restore({
|
|
163
|
+
actor: req.actor, collection, ctx: gateCtx, key: req.params.key,
|
|
164
|
+
}));
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
|
|
4
|
+
import { createPaths } from './paths.js';
|
|
5
|
+
import { createLockPool } from './util/locks.js';
|
|
6
|
+
import { acquireLock } from './util/lockfile.js';
|
|
7
|
+
import { cookieMiddleware } from './util/cookies.js';
|
|
8
|
+
import { createRequestResolution } from './requests.js';
|
|
9
|
+
import { mountUploadRoutes } from './routes/upload.js';
|
|
10
|
+
import { mountSyncRoutes } from './routes/sync.js';
|
|
11
|
+
import { mountMetaRoutes } from './routes/meta.js';
|
|
12
|
+
import { mountSaveRoutes } from './routes/save.js';
|
|
13
|
+
import { mountReadRoutes, mountDocumentLane } from './routes/read.js';
|
|
14
|
+
import { wrap } from './util/express.js';
|
|
15
|
+
import { ulid, token as mintToken } from './util/id.js';
|
|
16
|
+
import { createNodes } from './nodes/store.js';
|
|
17
|
+
import { createVersions } from './versions/index.js';
|
|
18
|
+
import { createReplace } from './documents/replace.js';
|
|
19
|
+
import { createServe } from './documents/serve.js';
|
|
20
|
+
import { createDerived } from './derived/index.js';
|
|
21
|
+
import { createLiveSync } from './livesync.js';
|
|
22
|
+
import { none } from './auth/none.js';
|
|
23
|
+
import { jsonErrorHandler, jsonError } from './json-errors.js';
|
|
24
|
+
import { hostMeta } from './spec/meta.js';
|
|
25
|
+
import { mountVersionRoutes } from './versions/routes.js';
|
|
26
|
+
import { createSaveTokens } from './share/save-tokens.js';
|
|
27
|
+
import { createIsolation } from './tenants/isolation.js';
|
|
28
|
+
import { mountTokenLanes } from './auth/token-lanes.js';
|
|
29
|
+
import { createOps } from './nodes/ops.js';
|
|
30
|
+
import { createScanner } from './nodes/scanner.js';
|
|
31
|
+
import { createListing } from './admin/listing.js';
|
|
32
|
+
import { createTemplates } from './templates.js';
|
|
33
|
+
import { createRecovery } from './recovery.js';
|
|
34
|
+
import { mountBoot } from './boot.js';
|
|
35
|
+
import { createAttic } from './attic.js';
|
|
36
|
+
import { mountAdminRoutes } from './admin/routes.js';
|
|
37
|
+
import { createUi } from './ui.js';
|
|
38
|
+
import { mountClientRoutes } from './client.js';
|
|
39
|
+
import { mountPageRoutes } from './pages.js';
|
|
40
|
+
import { createShares, mountShareRoutes } from './share/index.js';
|
|
41
|
+
import { createCollections } from './collections/index.js';
|
|
42
|
+
import { mountCollectionRoutes } from './collections/routes.js';
|
|
43
|
+
import { createTenants, TENANT_DEFAULTS } from './tenants/index.js';
|
|
44
|
+
import { mountTenantRoutes } from './tenants/routes.js';
|
|
45
|
+
|
|
46
|
+
// One defaults tree, exported, because there used to be two: this one and a
|
|
47
|
+
// second in packages/makerclay/src/config.js, bridged by a `toHostOptions` that
|
|
48
|
+
// hardcoded most of `features` to true. The consequence was not cosmetic. Every
|
|
49
|
+
// flag it hardcoded was unreachable from config.json, so a person could not turn
|
|
50
|
+
// off the dashboard, the editor, uploads, collections, share links or the data
|
|
51
|
+
// API however they edited that file.
|
|
52
|
+
//
|
|
53
|
+
// Two kinds of setting live here and the difference is worth keeping straight:
|
|
54
|
+
//
|
|
55
|
+
// PRODUCT switches isolation, requireAuthToRead, capabilities, limits,
|
|
56
|
+
// versions, features, tenants, collections. Things a person
|
|
57
|
+
// running the server decides, so config.json reaches all of
|
|
58
|
+
// them and `makerclay` writes them into the file it creates.
|
|
59
|
+
//
|
|
60
|
+
// EMBEDDER switches layout, scan, plus the constructor-only arguments (data,
|
|
61
|
+
// store, auth, clock, ids, logger, hostId, publicUrl). These
|
|
62
|
+
// are for whoever CALLS createHost. `scan` in particular is
|
|
63
|
+
// a test and desktop concern, not a setting to hand a person.
|
|
64
|
+
//
|
|
65
|
+
// `features.tenants` is THE tenant switch; the `tenants` object holds only knobs.
|
|
66
|
+
// See tenants/index.js, which had to say the same thing about a second spelling.
|
|
67
|
+
export const HOST_DEFAULTS = {
|
|
68
|
+
isolation: 'untrusted',
|
|
69
|
+
layout: 'clean',
|
|
70
|
+
requireAuthToRead: false,
|
|
71
|
+
capabilities: { conditional: true, sync: true, format: true, upload: true },
|
|
72
|
+
limits: { saveBytes: 20e6, uploadBytes: 50e6, maxFolderDepth: 5, sseKeepaliveMs: 30_000 },
|
|
73
|
+
versions: { driver: 'files', maxAgeDays: 60, keepNewest: 20 },
|
|
74
|
+
features: {
|
|
75
|
+
dashboard: true, editor: true, uploads: true, collections: true,
|
|
76
|
+
share: true, tenants: false, tailwind: false, dataApi: true,
|
|
77
|
+
},
|
|
78
|
+
collections: { maxRecords: 10_000, submitRateLimit: '30/min' },
|
|
79
|
+
scan: { boot: true, watch: true },
|
|
80
|
+
tenants: TENANT_DEFAULTS,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const DEFAULTS = HOST_DEFAULTS;
|
|
84
|
+
|
|
85
|
+
function mergeConfig(options) {
|
|
86
|
+
return {
|
|
87
|
+
...DEFAULTS,
|
|
88
|
+
...options,
|
|
89
|
+
capabilities: { ...DEFAULTS.capabilities, ...(options.capabilities || {}) },
|
|
90
|
+
limits: { ...DEFAULTS.limits, ...(options.limits || {}) },
|
|
91
|
+
versions: { ...DEFAULTS.versions, ...(options.versions || {}) },
|
|
92
|
+
features: { ...DEFAULTS.features, ...(options.features || {}) },
|
|
93
|
+
collections: { ...DEFAULTS.collections, ...(options.collections || {}) },
|
|
94
|
+
scan: { ...DEFAULTS.scan, ...(options.scan || {}) },
|
|
95
|
+
tenants: { ...DEFAULTS.tenants, ...(options.tenants || {}) },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
export function createHost(options = {}) {
|
|
101
|
+
const config = mergeConfig(options);
|
|
102
|
+
const logger = config.logger || console;
|
|
103
|
+
const clock = config.clock || { now: () => Date.now() };
|
|
104
|
+
const ids = config.ids || { ulid, token: mintToken };
|
|
105
|
+
const hostId = config.hostId || ulid().slice(-8);
|
|
106
|
+
|
|
107
|
+
const paths = createPaths({
|
|
108
|
+
data: config.data,
|
|
109
|
+
layout: config.layout,
|
|
110
|
+
maxFolderDepth: config.limits.maxFolderDepth,
|
|
111
|
+
});
|
|
112
|
+
const lock = acquireLock(paths.files.lock);
|
|
113
|
+
|
|
114
|
+
const store = config.store ?? null;
|
|
115
|
+
// A migration that throws is the first thing §18.3 names as a reason to be in
|
|
116
|
+
// recovery mode, so it must not take the constructor down with it. Throwing
|
|
117
|
+
// here leaves an embedder that catches it holding the data directory's lock
|
|
118
|
+
// with no host to close, and prints no recovery URL for the one person who
|
|
119
|
+
// could fix it.
|
|
120
|
+
let storeError = null;
|
|
121
|
+
if (store) {
|
|
122
|
+
try {
|
|
123
|
+
store.open({ paths, clock });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
storeError = error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const locks = createLockPool();
|
|
130
|
+
const events = new EventEmitter();
|
|
131
|
+
events.setMaxListeners(0);
|
|
132
|
+
const nodes = createNodes({ store, clock, ids });
|
|
133
|
+
const versions = createVersions({ paths, locks, config: config.versions, clock, logger });
|
|
134
|
+
const liveSync = config.capabilities.sync === false
|
|
135
|
+
? null
|
|
136
|
+
: createLiveSync({ hostId, config, logger });
|
|
137
|
+
const derived = createDerived({ paths, config, liveSync, logger });
|
|
138
|
+
const auth = config.auth || none();
|
|
139
|
+
|
|
140
|
+
const gateCtx = {
|
|
141
|
+
requireAuthToRead: !!config.requireAuthToRead,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const isolation = createIsolation({ config, store, logger });
|
|
145
|
+
const saveTokens = createSaveTokens({ store, nodes, clock, ids });
|
|
146
|
+
const serve = createServe({
|
|
147
|
+
paths, nodes, config, isolation, saveTokens,
|
|
148
|
+
});
|
|
149
|
+
const { replace } = createReplace({
|
|
150
|
+
paths, nodes, versions, locks, events, config, clock, logger, derived,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const {
|
|
154
|
+
notFoundDocument, resolveHref, resolveTarget, resolveSaveToken,
|
|
155
|
+
} = createRequestResolution({ serve, store, clock, gateCtx });
|
|
156
|
+
const { tryResolveRead } = paths;
|
|
157
|
+
|
|
158
|
+
const ops = createOps({
|
|
159
|
+
paths, nodes, store, versions, locks, events, clock, ids, logger, replace, derived,
|
|
160
|
+
});
|
|
161
|
+
const listing = createListing({ paths, nodes, isolation, store });
|
|
162
|
+
const templates = createTemplates({ logger });
|
|
163
|
+
const ui = createUi({ paths, logger });
|
|
164
|
+
const shares = config.features.share === false
|
|
165
|
+
? null
|
|
166
|
+
: createShares({ store, nodes, paths, clock, ids, events, logger });
|
|
167
|
+
const collections = config.features.collections === false
|
|
168
|
+
? null
|
|
169
|
+
: createCollections({ store, nodes, paths, versions, clock, ids, events, config, logger });
|
|
170
|
+
const tenants = createTenants({
|
|
171
|
+
store, nodes, paths, ops, versions, config, clock, ids, events, logger, saveTokens,
|
|
172
|
+
// Lazily, because the auth provider mounts its routes (and builds its
|
|
173
|
+
// session store) after this module is constructed.
|
|
174
|
+
sessions: () => auth.sessions || null,
|
|
175
|
+
});
|
|
176
|
+
const scanner = createScanner({ paths, nodes, locks, events, config, clock, versions, logger });
|
|
177
|
+
const recovery = createRecovery({ paths, store, nodes, versions, clock, logger });
|
|
178
|
+
const attic = createAttic({ paths, store, nodes, versions, recovery, replace, locks, logger });
|
|
179
|
+
|
|
180
|
+
// Derived work runs after the response, so a test (or a shutdown) that stops
|
|
181
|
+
// at the response would otherwise race a sidecar write still in flight.
|
|
182
|
+
const background = new Set();
|
|
183
|
+
function track(promise) {
|
|
184
|
+
const entry = Promise.resolve(promise).catch((error) => {
|
|
185
|
+
logger.error?.('[makerclay] background work failed:', error?.message || error);
|
|
186
|
+
});
|
|
187
|
+
background.add(entry);
|
|
188
|
+
entry.finally(() => background.delete(entry));
|
|
189
|
+
return entry;
|
|
190
|
+
}
|
|
191
|
+
async function settled() {
|
|
192
|
+
while (background.size) await Promise.all([...background]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const app = express();
|
|
196
|
+
app.disable('x-powered-by');
|
|
197
|
+
app.set('etag', false);
|
|
198
|
+
|
|
199
|
+
const booted = mountBoot({
|
|
200
|
+
app, config, paths, store, storeError, recovery, scanner, ui, logger,
|
|
201
|
+
});
|
|
202
|
+
const { ready, recoveryUi } = booted;
|
|
203
|
+
|
|
204
|
+
app.use(cookieMiddleware);
|
|
205
|
+
|
|
206
|
+
app.use(wrap(async (req, res, next) => {
|
|
207
|
+
const identified = await auth.identify(req, { store, nodes, config });
|
|
208
|
+
req.actor = identified ? { grants: {}, ...identified } : { kind: 'guest', grants: {} };
|
|
209
|
+
// Share links are capabilities, not identities: they are added to whoever
|
|
210
|
+
// the request already is, and they never change `kind`.
|
|
211
|
+
if (shares) req.actor.grants.shares = shares.grantsFor(req, res);
|
|
212
|
+
auth.decorate?.(req, res, req.actor);
|
|
213
|
+
next();
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
const system = express.Router();
|
|
217
|
+
|
|
218
|
+
mountTokenLanes({ system, config });
|
|
219
|
+
|
|
220
|
+
system.get('/health', (req, res) => {
|
|
221
|
+
const { state } = booted;
|
|
222
|
+
res.json({
|
|
223
|
+
ok: state.state !== 'recover',
|
|
224
|
+
spec: hostMeta(config.capabilities).spec,
|
|
225
|
+
hostId,
|
|
226
|
+
state: state.state,
|
|
227
|
+
reason: state.reason ?? null,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
mountMetaRoutes({
|
|
232
|
+
system, config, serve, gateCtx, resolveTarget, resolveSaveToken, tryResolveRead,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
mountSaveRoutes({
|
|
236
|
+
system, config, nodes, serve, tenants, replace, gateCtx,
|
|
237
|
+
resolveHref, resolveSaveToken, track,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
mountUploadRoutes({
|
|
241
|
+
system, config, ops, tenants, gateCtx, resolveTarget, resolveSaveToken,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
mountSyncRoutes({
|
|
245
|
+
system, config, liveSync, nodes, gateCtx, resolveHref, resolveSaveToken,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
mountVersionRoutes({
|
|
249
|
+
router: system, config, paths, nodes, versions, replace, resolveTarget,
|
|
250
|
+
resolveSaveToken, tryResolveRead, gateCtx, logger, track,
|
|
251
|
+
isolationFor: serve.isolationFor,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
mountReadRoutes({ system, config, serve, derived, resolveTarget });
|
|
255
|
+
|
|
256
|
+
mountClientRoutes({ router: system, logger });
|
|
257
|
+
|
|
258
|
+
mountAdminRoutes({
|
|
259
|
+
router: system, config, paths, nodes, store, ops, listing, attic, templates, events,
|
|
260
|
+
gateCtx, wrap, track, clock, logger,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (shares) {
|
|
264
|
+
mountShareRoutes({
|
|
265
|
+
router: system, config, shares, nodes, store, ui, gateCtx, wrap,
|
|
266
|
+
publicUrl: config.publicUrl || null,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (collections) {
|
|
271
|
+
mountCollectionRoutes({
|
|
272
|
+
router: system, config, collections, nodes, store, gateCtx, wrap, clock,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (tenants.enabled) {
|
|
277
|
+
mountTenantRoutes({
|
|
278
|
+
router: system, config, tenants, nodes, resolveTarget, gateCtx, wrap, clock, ui,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
mountPageRoutes({
|
|
283
|
+
router: system, config, paths, nodes, ui, listing, versions, templates,
|
|
284
|
+
gateCtx, wrap, resolveTarget, serve, saveTokens, logger,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Everything else under the host's own prefix is reserved, so `/_/foo.html`
|
|
288
|
+
// can never fall through to the document lane and serve a document.
|
|
289
|
+
system.use((req, res) => jsonError(res, 'not-found', 'Not found'));
|
|
290
|
+
|
|
291
|
+
auth.routes?.(app, { config, paths, store, nodes, clock, ids, logger, gateCtx, ui, sessions: null });
|
|
292
|
+
app.use('/_', system);
|
|
293
|
+
|
|
294
|
+
// No generic mount/plugin seam here, deliberately. An embedder (hyperclay-local
|
|
295
|
+
// is the one that matters) owns its own Express app and mounts `host.app`
|
|
296
|
+
// inside it, so its routes sit in FRONT of core's and their precedence is
|
|
297
|
+
// obvious. The old `config.mounts` callback ran after every reserved route was
|
|
298
|
+
// installed, so it could not cleanly override anything, which is the whole
|
|
299
|
+
// reason it was never used.
|
|
300
|
+
|
|
301
|
+
// ---- the document lane -------------------------------------------------
|
|
302
|
+
mountDocumentLane({ app, config, serve, events, resolveTarget, notFoundDocument });
|
|
303
|
+
|
|
304
|
+
app.use(jsonErrorHandler({ logger }));
|
|
305
|
+
|
|
306
|
+
const host = {
|
|
307
|
+
id: hostId,
|
|
308
|
+
app,
|
|
309
|
+
config,
|
|
310
|
+
paths,
|
|
311
|
+
store,
|
|
312
|
+
nodes,
|
|
313
|
+
versions,
|
|
314
|
+
events,
|
|
315
|
+
locks,
|
|
316
|
+
liveSync,
|
|
317
|
+
derived,
|
|
318
|
+
isolation,
|
|
319
|
+
saveTokens,
|
|
320
|
+
documents: { replace },
|
|
321
|
+
serve,
|
|
322
|
+
ops,
|
|
323
|
+
listing,
|
|
324
|
+
templates,
|
|
325
|
+
scanner,
|
|
326
|
+
recovery,
|
|
327
|
+
attic,
|
|
328
|
+
ui,
|
|
329
|
+
shares,
|
|
330
|
+
collections,
|
|
331
|
+
tenants,
|
|
332
|
+
auth,
|
|
333
|
+
recoveryUi,
|
|
334
|
+
helpers: { resolveHref, resolveTarget, tryResolveRead, resolveSaveToken, gateCtx, wrap },
|
|
335
|
+
ready,
|
|
336
|
+
get state() { return booted.state; },
|
|
337
|
+
settled,
|
|
338
|
+
|
|
339
|
+
async close() {
|
|
340
|
+
liveSync?.closeAll();
|
|
341
|
+
await ready.catch(() => {});
|
|
342
|
+
await scanner.close();
|
|
343
|
+
await settled();
|
|
344
|
+
store?.close();
|
|
345
|
+
lock.release();
|
|
346
|
+
events.removeAllListeners();
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
return host;
|
|
351
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
import { extract, findRulesIn, parseRelaxed } from 'hyper-html-api/engine';
|
|
3
|
+
import cheerioAdapter from 'hyper-html-api/cheerio';
|
|
4
|
+
|
|
5
|
+
// Thin wrapper over the shared hyper-html-api engine. The engine is pure ESM and
|
|
6
|
+
// so is this core, so unlike hyperclay-local's CommonJS port there is no dynamic
|
|
7
|
+
// import dance: these are plain synchronous calls.
|
|
8
|
+
|
|
9
|
+
export function extractData(html, rules) {
|
|
10
|
+
return extract(cheerioAdapter, cheerio.load(html).root(), rules);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function extractViaTag(html, token) {
|
|
14
|
+
const $ = cheerio.load(html);
|
|
15
|
+
const found = findRulesIn(cheerioAdapter, $.root(), token);
|
|
16
|
+
if (!found) return null;
|
|
17
|
+
return extract(cheerioAdapter, $.root(), found.rules);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseExtractionRules(source) {
|
|
21
|
+
return parseRelaxed(source);
|
|
22
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import { extractViaTag } from './data-extractor.js';
|
|
3
|
+
import { atomicWrite, readFileTextIfExists, statInfo, unlinkIfExists } from '../util/fsx.js';
|
|
4
|
+
|
|
5
|
+
// Derived artifacts: the per-document data sidecar and the Tailwind stylesheet.
|
|
6
|
+
// Everything here runs AFTER the response has been sent (§9.1 step 14), so a
|
|
7
|
+
// failure is recorded for `doctor` and never converted into an HTTP failure.
|
|
8
|
+
|
|
9
|
+
async function loadTailwind(logger) {
|
|
10
|
+
try {
|
|
11
|
+
return await import('tailwind-hyperclay');
|
|
12
|
+
} catch {
|
|
13
|
+
logger.warn?.('[makerclay] tailwind-hyperclay is not installed; Tailwind generation is off');
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createDerived({ paths, config, liveSync, logger = console }) {
|
|
19
|
+
const features = config.features || {};
|
|
20
|
+
let tailwindModule = undefined;
|
|
21
|
+
|
|
22
|
+
// The sidecar records WHICH bytes it was derived from, and freshness is that
|
|
23
|
+
// identity matching, not the two files' timestamps being in the right order.
|
|
24
|
+
//
|
|
25
|
+
// Ordering cannot carry this. Nothing serializes sidecar writes: the save lane
|
|
26
|
+
// hands its derived work out past the lock, and `/_/api` rebuilds lazily on a
|
|
27
|
+
// miss, so a request that read the document before a save can finish writing
|
|
28
|
+
// its sidecar after that save wrote one. Judged by mtime that loser looks
|
|
29
|
+
// NEWER than the source it is already behind, so it passes the freshness check
|
|
30
|
+
// and keeps being served, and the next save is what finally clears it. A wrong
|
|
31
|
+
// answer that persists is worth more than the extra stat this costs.
|
|
32
|
+
//
|
|
33
|
+
// `statInfo` reports mtime in nanoseconds for this exact reason: whole
|
|
34
|
+
// milliseconds collide often enough that two writes in the same tick would
|
|
35
|
+
// compare equal.
|
|
36
|
+
function sourceIdentity(stat) {
|
|
37
|
+
return stat ? { size: stat.size, mtimeNs: String(stat.mtimeNs) } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function writeSidecar(node, html, sourcePath = null) {
|
|
41
|
+
const target = paths.derivedApiPath(node.owner, node.path.replace(/\.(html|htmlclay)$/i, ''));
|
|
42
|
+
let data = null;
|
|
43
|
+
try {
|
|
44
|
+
data = extractViaTag(html, 'api');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
logger.warn?.('[makerclay] api rules extraction failed:', error?.message || error);
|
|
47
|
+
data = null;
|
|
48
|
+
}
|
|
49
|
+
if (data === null) {
|
|
50
|
+
await unlinkIfExists(target);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const source = sourceIdentity(sourcePath ? await statInfo(sourcePath) : null);
|
|
54
|
+
await atomicWrite(target, JSON.stringify({ source, data }));
|
|
55
|
+
return target;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readSidecar(node, sourcePath) {
|
|
59
|
+
const target = paths.derivedApiPath(node.owner, node.path.replace(/\.(html|htmlclay)$/i, ''));
|
|
60
|
+
const stored = await readFileTextIfExists(target);
|
|
61
|
+
if (stored === null) return null;
|
|
62
|
+
|
|
63
|
+
let parsed = null;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(stored);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// A sidecar with no recorded source is one this host cannot vouch for: it
|
|
70
|
+
// either predates this format or was written without a path to stat. Treat
|
|
71
|
+
// it as a miss and rebuild rather than serving bytes of unknown provenance.
|
|
72
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.source) return null;
|
|
73
|
+
|
|
74
|
+
const current = sourceIdentity(await statInfo(sourcePath));
|
|
75
|
+
if (!current) return null;
|
|
76
|
+
if (current.size !== parsed.source.size || current.mtimeNs !== parsed.source.mtimeNs) return null;
|
|
77
|
+
|
|
78
|
+
return JSON.stringify(parsed.data);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function writeTailwind(node, html) {
|
|
82
|
+
if (!features.tailwind) return null;
|
|
83
|
+
if (tailwindModule === undefined) tailwindModule = await loadTailwind(logger);
|
|
84
|
+
if (!tailwindModule) return null;
|
|
85
|
+
const name = tailwindModule.getTailwindCssName(html);
|
|
86
|
+
if (!name) return null;
|
|
87
|
+
const css = await tailwindModule.compileTailwind(html);
|
|
88
|
+
const target = paths.derivedTailwindPath(node.owner, name);
|
|
89
|
+
await atomicWrite(target, css);
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scopeTailwindLink(node, html) {
|
|
94
|
+
if (!features.tailwind || !tailwindModule) return html;
|
|
95
|
+
if (!tailwindModule.hasAnyTailwindLink?.(html)) return html;
|
|
96
|
+
const dir = path.dirname(node.path);
|
|
97
|
+
const sitePath = dir === '.' ? '' : dir;
|
|
98
|
+
const base = path.basename(node.path, path.extname(node.path));
|
|
99
|
+
return tailwindModule.replaceTailwindLink(html, base, sitePath);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function onSaved({ node, html, senderId, sourcePath = null }) {
|
|
103
|
+
// Morph view-mode tabs with the persisted on-disk HTML. Edit-mode tabs are
|
|
104
|
+
// untouched: they ride the live lane through /_/sync.
|
|
105
|
+
if (liveSync) {
|
|
106
|
+
liveSync.broadcast(node.owner, node.path, { html, sender: senderId || 'server-save' }, { lane: 'saved' });
|
|
107
|
+
}
|
|
108
|
+
// The sidecar goes first, so a Tailwind failure cannot skip it and leave
|
|
109
|
+
// stale API data on disk.
|
|
110
|
+
if (features.dataApi !== false) await writeSidecar(node, html, sourcePath);
|
|
111
|
+
if (features.tailwind) {
|
|
112
|
+
try {
|
|
113
|
+
await writeTailwind(node, html);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.warn?.('[makerclay] tailwind compile failed (non-fatal):', error?.message || error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { onSaved, writeSidecar, readSidecar, writeTailwind, scopeTailwindLink };
|
|
121
|
+
}
|