javascript-solid-server 0.0.111 → 0.0.113

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.
@@ -327,7 +327,8 @@
327
327
  "Bash(gh label:*)",
328
328
  "Bash(mongosh --eval \"db.runCommand\\({ ping: 1 }\\)\" 2>&1 | head -5)",
329
329
  "Bash(which jss && jss --version 2>&1)",
330
- "Bash(jss start --help 2>&1 | grep -i mongo)"
330
+ "Bash(jss start --help 2>&1 | grep -i mongo)",
331
+ "Bash(grep -A5 '\"\"files\"\"' package.json)"
331
332
  ]
332
333
  }
333
334
  }
package/README.md CHANGED
@@ -91,23 +91,28 @@ Full options: [docs/configuration.md](docs/configuration.md)
91
91
  |-------|------|
92
92
  | Configuration & Options | [docs/configuration.md](docs/configuration.md) |
93
93
  | Authentication | [docs/authentication.md](docs/authentication.md) |
94
+ | Mashlib / SolidOS UI | [docs/mashlib.md](docs/mashlib.md) |
95
+ | WebSocket Notifications | [docs/notifications.md](docs/notifications.md) |
94
96
  | Git Support | [docs/git-support.md](docs/git-support.md) |
97
+ | Nostr Relay | [docs/nostr.md](docs/nostr.md) |
95
98
  | ActivityPub & Mastodon API | [docs/activitypub.md](docs/activitypub.md) |
96
99
  | remoteStorage | [docs/remotestorage.md](docs/remotestorage.md) |
97
- | Security & Subdomain Mode | [docs/security.md](docs/security.md) |
98
- | HTTP 402 Payments | [docs/payments.md](docs/payments.md) |
99
100
  | WebRTC & Tunnel | [docs/webrtc.md](docs/webrtc.md) |
100
101
  | MongoDB `/db/` Route | [docs/mongodb.md](docs/mongodb.md) |
102
+ | HTTP 402 Payments | [docs/payments.md](docs/payments.md) |
103
+ | Storage Quotas | [docs/quotas.md](docs/quotas.md) |
104
+ | Invite-Only Registration | [docs/invites.md](docs/invites.md) |
105
+ | Security & Subdomain Mode | [docs/security.md](docs/security.md) |
101
106
  | Architecture & Structure | [docs/architecture.md](docs/architecture.md) |
102
107
 
103
108
  ## Comparison
104
109
 
105
- | Server | Size | Deps | Notes |
106
- |--------|------|------|-------|
107
- | [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) | ~14K LoC | 14 | Minimal, JSON-LD native |
108
- | [NSS](https://github.com/nodeSolidServer/node-solid-server) | 777 KB | 58 | Original Solid server |
109
- | [CSS](https://github.com/CommunitySolidServer/CommunitySolidServer) | 5.8 MB | 70 | Modular, configurable |
110
- | [Pivot](https://github.com/solid-contrib/pivot) | ~6 MB | 70+ | Built on CSS |
110
+ | Server | Package | Packages | node_modules |
111
+ |--------|---------|----------|-------------|
112
+ | [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) | ~1 MB | ~191 | ~77 MB |
113
+ | [CSS](https://github.com/CommunitySolidServer/CommunitySolidServer) | ~6 MB | ~311 | ~152 MB |
114
+ | [Pivot](https://github.com/solid-contrib/pivot) | ~6 MB | ~311+ | ~152 MB |
115
+ | [NSS](https://github.com/nodeSolidServer/node-solid-server) | ~7 MB | ~670 | ~539 MB |
111
116
 
112
117
  ## Performance
113
118
 
package/bin/jss.js CHANGED
@@ -53,12 +53,10 @@ program
53
53
  .option('--subdomains', 'Enable subdomain-based pods (XSS protection)')
54
54
  .option('--no-subdomains', 'Disable subdomain-based pods')
55
55
  .option('--base-domain <domain>', 'Base domain for subdomain pods (e.g., "example.com")')
56
- .option('--mashlib', 'Enable Mashlib data browser (local mode, requires mashlib in node_modules)')
57
- .option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)')
56
+ .option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode)')
58
57
  .option('--mashlib-module <url>', 'Enable ES module data browser from a URL')
59
58
  .option('--no-mashlib', 'Disable Mashlib data browser')
60
59
  .option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
61
- .option('--solidos-ui', 'Enable modern Nextcloud-style UI (requires --mashlib)')
62
60
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
63
61
  .option('--no-git', 'Disable Git HTTP backend')
64
62
  .option('--nostr', 'Enable Nostr relay')
@@ -143,7 +141,6 @@ program
143
141
  mashlibCdn: config.mashlibCdn,
144
142
  mashlibVersion: config.mashlibVersion,
145
143
  mashlibModule: config.mashlibModule,
146
- solidosUi: config.solidosUi,
147
144
  git: config.git,
148
145
  nostr: config.nostr,
149
146
  nostrPath: config.nostrPath,
@@ -193,7 +190,6 @@ program
193
190
  console.log(` Mashlib: local (data browser enabled)`);
194
191
  }
195
192
  if (config.mashlibModule) console.log(` Mashlib module: ${config.mashlibModule}`);
196
- if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)');
197
193
  if (config.git) console.log(' Git: enabled (clone/push support)');
198
194
  if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
199
195
  if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
@@ -355,43 +355,6 @@ jss quota reconcile alice
355
355
 
356
356
  Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
357
357
 
358
- ## Storage Quotas
359
-
360
- Limit storage per pod to prevent abuse and manage resources:
361
-
362
- ```bash
363
- jss start --default-quota 50MB
364
- ```
365
-
366
- ### Managing Quotas
367
-
368
- ```bash
369
- # Set quota for a user (overrides default)
370
- jss quota set alice 100MB
371
-
372
- # Show quota info
373
- jss quota show alice
374
- # alice:
375
- # Used: 12.5 MB
376
- # Limit: 100 MB
377
- # Free: 87.5 MB
378
- # Usage: 12%
379
-
380
- # Recalculate from actual disk usage
381
- jss quota reconcile alice
382
- ```
383
-
384
- ### How It Works
385
-
386
- - Quotas are tracked incrementally on PUT, POST, and DELETE operations
387
- - When quota is exceeded, the server returns HTTP 507 Insufficient Storage
388
- - Each pod stores its quota in `/{pod}/.quota.json`
389
- - Use `reconcile` to fix quota drift from manual file changes
390
-
391
- ### Size Formats
392
-
393
- Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
394
-
395
358
  ### Mashlib Data Browser
396
359
 
397
360
  Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources. Two modes are available:
@@ -0,0 +1,43 @@
1
+ # Invite-Only Registration
2
+
3
+ Control who can create accounts by requiring invite codes.
4
+
5
+ ```bash
6
+ jss start --idp --invite-only
7
+ ```
8
+
9
+ ## Managing Invite Codes
10
+
11
+ ```bash
12
+ # Create a single-use invite
13
+ jss invite create
14
+ # Created invite code: ABCD1234
15
+
16
+ # Create multi-use invite with note
17
+ jss invite create -u 5 -n "For team members"
18
+
19
+ # List all active invites
20
+ jss invite list
21
+ # CODE USES CREATED NOTE
22
+ # -------------------------------------------------------
23
+ # ABCD1234 0/1 2026-01-03
24
+ # EFGH5678 2/5 2026-01-03 For team members
25
+
26
+ # Revoke an invite
27
+ jss invite revoke ABCD1234
28
+ ```
29
+
30
+ ## How It Works
31
+
32
+ | Mode | Registration | Pod Creation |
33
+ |------|--------------|--------------|
34
+ | Open (default) | Anyone can register | Anyone can create pods |
35
+ | Invite-only | Requires valid invite code | Via registration only |
36
+
37
+ When `--invite-only` is enabled:
38
+ - The registration page shows an "Invite Code" field
39
+ - Invalid or expired codes are rejected with an error
40
+ - Each use decrements the invite's remaining uses
41
+ - Depleted invites are automatically removed
42
+
43
+ Invite codes are stored in `.server/invites.json` in your data directory.
@@ -0,0 +1,58 @@
1
+ # Mashlib Data Browser
2
+
3
+ Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources.
4
+
5
+ ## Modes
6
+
7
+ **CDN Mode** (recommended for getting started):
8
+ ```bash
9
+ jss start --mashlib-cdn --conneg
10
+ ```
11
+ Loads mashlib from unpkg.com CDN. Zero footprint — no local files needed.
12
+
13
+ **Local Mode** (for production/offline):
14
+ ```bash
15
+ jss start --mashlib --conneg
16
+ ```
17
+ Serves mashlib from `src/mashlib-local/dist/`. Requires building mashlib locally:
18
+ ```bash
19
+ cd src/mashlib-local
20
+ npm install && npm run build
21
+ ```
22
+
23
+ **ES Module Mode** (for custom or next-gen mashlib builds):
24
+ ```bash
25
+ jss start --mashlib-module https://example.com/mashlib.js
26
+ ```
27
+ Loads an ES module-based data browser from any URL. Uses `<script type="module">` and `<div id="mashlib">` (self-initializing). CSS is auto-derived by replacing `.js` with `.css`. Content negotiation is auto-enabled.
28
+
29
+ ## How It Works
30
+
31
+ 1. Browser requests `/alice/public/data.ttl` with `Accept: text/html`
32
+ 2. Server returns Mashlib HTML wrapper
33
+ 3. Mashlib fetches the actual data via content negotiation
34
+ 4. Mashlib renders an interactive, editable view
35
+
36
+ **Note:** Mashlib works best with `--conneg` enabled for Turtle support.
37
+
38
+ ## Modern UI (SolidOS UI)
39
+
40
+ ```bash
41
+ jss start --mashlib --solidos-ui --conneg
42
+ ```
43
+
44
+ Serves a modern Nextcloud-style UI shell while reusing mashlib's data layer:
45
+ - Modern file browser with breadcrumb navigation
46
+ - Profile, Contacts, Sharing, and Settings views
47
+ - Path-based URLs (browser URL reflects current resource)
48
+ - Responsive design for mobile devices
49
+
50
+ Requires solidos-ui dist files in `src/mashlib-local/dist/solidos-ui/`. See [solidos-ui](https://github.com/solidos/solidos/tree/main/workspaces/solidos-ui) for details.
51
+
52
+ ## Profile Pages
53
+
54
+ Pod profiles (`/alice/`) use HTML with embedded JSON-LD data islands and are rendered using:
55
+ - [mashlib-jss](https://github.com/JavaScriptSolidServer/mashlib-jss) — A fork of mashlib with `getPod()` fix for path-based pods
56
+ - [solidos-lite](https://github.com/SolidOS/solidos-lite) — Parses JSON-LD data islands into the RDF store
57
+
58
+ This allows profiles to work without server-side content negotiation while still providing full SolidOS editing capabilities.
package/docs/nostr.md ADDED
@@ -0,0 +1,56 @@
1
+ # Nostr Relay
2
+
3
+ Integrated NIP-01/NIP-11/NIP-16 Nostr relay running on the same port as the Solid server.
4
+
5
+ ```bash
6
+ jss start --nostr
7
+ ```
8
+
9
+ ## Endpoint
10
+
11
+ `wss://your.pod/relay` (configurable via `--nostr-path`)
12
+
13
+ ## Supported NIPs
14
+
15
+ - **NIP-01** — Basic protocol flow (EVENT, REQ, CLOSE)
16
+ - **NIP-11** — Relay information document (`GET /relay` with `Accept: application/nostr+json`)
17
+ - **NIP-16** — Event treatment (regular, replaceable, ephemeral)
18
+
19
+ ## Options
20
+
21
+ | Option | Description | Default |
22
+ |--------|-------------|---------|
23
+ | `--nostr` | Enable Nostr relay | false |
24
+ | `--nostr-path <path>` | WebSocket path | /relay |
25
+ | `--nostr-max-events <n>` | Max events in memory | 1000 |
26
+
27
+ ## How It Works
28
+
29
+ - Events are stored in memory (up to `--nostr-max-events`)
30
+ - Replaceable events (kinds 0, 3, 10000-19999) replace previous events by the same pubkey
31
+ - Ephemeral events (kinds 20000-29999) are broadcast but not stored
32
+ - Parameterized replaceable events (kinds 30000-39999) use the `d` tag for deduplication
33
+ - Rate limiting: 60 events per socket per minute
34
+
35
+ ## Client Usage
36
+
37
+ ```javascript
38
+ import { Relay } from 'nostr-tools';
39
+
40
+ const relay = await Relay.connect('wss://your.pod/relay');
41
+
42
+ // Subscribe
43
+ const sub = relay.subscribe([{ kinds: [1], limit: 10 }], {
44
+ onevent(event) { console.log(event); }
45
+ });
46
+
47
+ // Publish
48
+ await relay.publish(signedEvent);
49
+ ```
50
+
51
+ ## Nostr Authentication (NIP-98)
52
+
53
+ JSS also supports NIP-98 HTTP Auth for Solid operations. See [docs/authentication.md](authentication.md) for details on:
54
+ - NIP-98 Schnorr signature authentication
55
+ - `did:nostr` → WebID resolution
56
+ - Linking Nostr identity to your WebID profile
@@ -0,0 +1,50 @@
1
+ # WebSocket Notifications
2
+
3
+ Real-time notifications for resource changes using the solid-0.1 protocol (SolidOS compatible).
4
+
5
+ ```bash
6
+ jss start --notifications
7
+ ```
8
+
9
+ ## Discovery
10
+
11
+ Clients discover the WebSocket URL via the `Updates-Via` header:
12
+
13
+ ```bash
14
+ curl -I http://localhost:3000/alice/public/
15
+ # Updates-Via: ws://localhost:3000/.notifications
16
+ ```
17
+
18
+ ## Protocol
19
+
20
+ ```
21
+ Server: protocol solid-0.1
22
+ Client: sub http://localhost:3000/alice/public/data.json
23
+ Server: ack http://localhost:3000/alice/public/data.json
24
+ Server: pub http://localhost:3000/alice/public/data.json (on change)
25
+ ```
26
+
27
+ ## How It Works
28
+
29
+ 1. Client connects to the WebSocket URL from `Updates-Via`
30
+ 2. Server sends `protocol solid-0.1` greeting
31
+ 3. Client subscribes: `sub <resource-url>`
32
+ 4. Server acknowledges: `ack <resource-url>`
33
+ 5. On any change (PUT, PATCH, DELETE), server broadcasts: `pub <resource-url>`
34
+ 6. Container subscriptions also fire when child resources change
35
+
36
+ ## ACL Enforcement
37
+
38
+ - Anonymous clients can subscribe to public resources
39
+ - Private resource subscriptions require authentication
40
+ - Cross-origin subscriptions are rejected
41
+
42
+ ## Live Reload
43
+
44
+ For development, `--live-reload` injects a script that auto-refreshes the browser when files change on disk:
45
+
46
+ ```bash
47
+ jss start --live-reload --notifications
48
+ ```
49
+
50
+ File system changes (editing files directly) also trigger WebSocket notifications.
package/docs/quotas.md ADDED
@@ -0,0 +1,36 @@
1
+ # Storage Quotas
2
+
3
+ Limit storage per pod to prevent abuse and manage resources.
4
+
5
+ ```bash
6
+ jss start --default-quota 50MB
7
+ ```
8
+
9
+ ## Managing Quotas
10
+
11
+ ```bash
12
+ # Set quota for a user (overrides default)
13
+ jss quota set alice 100MB
14
+
15
+ # Show quota info
16
+ jss quota show alice
17
+ # alice:
18
+ # Used: 12.5 MB
19
+ # Limit: 100 MB
20
+ # Free: 87.5 MB
21
+ # Usage: 12%
22
+
23
+ # Recalculate from actual disk usage
24
+ jss quota reconcile alice
25
+ ```
26
+
27
+ ## How It Works
28
+
29
+ - Quotas are tracked incrementally on PUT, POST, and DELETE operations
30
+ - When quota is exceeded, the server returns HTTP 507 Insufficient Storage
31
+ - Each pod stores its quota in `/{pod}/.quota.json`
32
+ - Use `reconcile` to fix quota drift from manual file changes
33
+
34
+ ## Size Formats
35
+
36
+ Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.111",
3
+ "version": "0.0.113",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,7 +9,7 @@ import { checkAccess, getRequiredMode } from '../wac/checker.js';
9
9
  import { AccessMode } from '../wac/parser.js';
10
10
  import * as storage from '../storage/filesystem.js';
11
11
  import { getEffectiveUrlPath } from '../utils/url.js';
12
- import { generateDatabrowserHtml, generateModuleDatabrowserHtml, generateSolidosUiHtml } from '../mashlib/index.js';
12
+ import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashlib/index.js';
13
13
 
14
14
  /**
15
15
  * Build a resource URL for WAC checking, normalizing path-based pod access
@@ -148,12 +148,9 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au
148
148
  // If mashlib is enabled, serve mashlib instead of static error page
149
149
  // Mashlib has built-in login functionality via panes.runDataBrowser()
150
150
  if (request.mashlibEnabled) {
151
- // Use SolidOS UI if enabled, ES module if configured, otherwise classic mashlib
152
- const html = request.solidosUiEnabled
153
- ? generateSolidosUiHtml()
154
- : request.mashlibModule
155
- ? generateModuleDatabrowserHtml(request.mashlibModule)
156
- : generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
151
+ const html = request.mashlibModule
152
+ ? generateModuleDatabrowserHtml(request.mashlibModule)
153
+ : generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
157
154
  return reply.code(statusCode).type('text/html').send(html);
158
155
  }
159
156
  return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
package/src/config.js CHANGED
@@ -43,9 +43,6 @@ export const defaults = {
43
43
  mashlibVersion: '2.0.0',
44
44
  mashlibModule: false,
45
45
 
46
- // SolidOS UI (modern Nextcloud-style interface)
47
- solidosUi: false,
48
-
49
46
  // Git HTTP backend
50
47
  git: false,
51
48
 
@@ -137,7 +134,6 @@ const envMap = {
137
134
  JSS_MASHLIB_CDN: 'mashlibCdn',
138
135
  JSS_MASHLIB_VERSION: 'mashlibVersion',
139
136
  JSS_MASHLIB_MODULE: 'mashlibModule',
140
- JSS_SOLIDOS_UI: 'solidosUi',
141
137
  JSS_GIT: 'git',
142
138
  JSS_NOSTR: 'nostr',
143
139
  JSS_NOSTR_PATH: 'nostrPath',
@@ -331,8 +327,7 @@ export function printConfig(config) {
331
327
  console.log(` Notifications: ${config.notifications}`);
332
328
  console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
333
329
  console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
334
- console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
335
- console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
330
+ console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : 'disabled'}`);
336
331
  if (config.pay) {
337
332
  console.log(` Pay: ${config.payCost} sat/req`);
338
333
  if (config.payToken) console.log(` Token: ${config.payToken} @ ${config.payRate} sat/token`);
@@ -15,7 +15,7 @@ import {
15
15
  } from '../rdf/conneg.js';
16
16
  import { emitChange } from '../notifications/events.js';
17
17
  import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
18
- import { generateDatabrowserHtml, generateModuleDatabrowserHtml, generateSolidosUiHtml, shouldServeMashlib } from '../mashlib/index.js';
18
+ import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMashlib } from '../mashlib/index.js';
19
19
 
20
20
  /**
21
21
  * Live reload script - injected into HTML when --live-reload is enabled
@@ -230,12 +230,9 @@ export async function handleGet(request, reply) {
230
230
 
231
231
  // Check if we should serve Mashlib data browser for containers
232
232
  if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) {
233
- // Use SolidOS UI if enabled, ES module if configured, otherwise classic mashlib
234
- const html = request.solidosUiEnabled
235
- ? generateSolidosUiHtml()
236
- : request.mashlibModule
237
- ? generateModuleDatabrowserHtml(request.mashlibModule)
238
- : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
233
+ const html = request.mashlibModule
234
+ ? generateModuleDatabrowserHtml(request.mashlibModule)
235
+ : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
239
236
  const headers = getAllHeaders({
240
237
  isContainer: true,
241
238
  etag: stats.etag,
@@ -309,12 +306,9 @@ export async function handleGet(request, reply) {
309
306
  // Check if we should serve Mashlib data browser
310
307
  // Only for RDF resources when Accept: text/html is requested
311
308
  if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
312
- // Use SolidOS UI if enabled, ES module if configured, otherwise classic mashlib
313
- const html = request.solidosUiEnabled
314
- ? generateSolidosUiHtml()
315
- : request.mashlibModule
316
- ? generateModuleDatabrowserHtml(request.mashlibModule)
317
- : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
309
+ const html = request.mashlibModule
310
+ ? generateModuleDatabrowserHtml(request.mashlibModule)
311
+ : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
318
312
  const headers = getAllHeaders({
319
313
  isContainer: false,
320
314
  etag: stats.etag,
package/src/server.js CHANGED
@@ -62,14 +62,11 @@ export function createServer(options = {}) {
62
62
  const subdomainsEnabled = options.subdomains ?? false;
63
63
  const baseDomain = options.baseDomain || null;
64
64
  // Mashlib data browser is OFF by default
65
- // mashlibCdn: if true, load from CDN; if false, serve locally
66
- // mashlibModule: URL to ES module entry point (alternative to classic mashlib)
65
+ // mashlibCdn: load from CDN; mashlibModule: URL to ES module entry point
67
66
  const mashlibModule = options.mashlibModule ?? false;
68
- const mashlibEnabled = options.mashlib || !!mashlibModule;
69
67
  const mashlibCdn = options.mashlibCdn ?? false;
68
+ const mashlibEnabled = mashlibCdn || !!mashlibModule;
70
69
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
71
- // SolidOS UI (modern Nextcloud-style interface) - requires mashlib
72
- const solidosUiEnabled = options.solidosUi ?? false;
73
70
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
74
71
  const gitEnabled = options.git ?? false;
75
72
  // Nostr relay is OFF by default
@@ -179,7 +176,6 @@ export function createServer(options = {}) {
179
176
  fastify.decorateRequest('mashlibCdn', null);
180
177
  fastify.decorateRequest('mashlibVersion', null);
181
178
  fastify.decorateRequest('mashlibModule', null);
182
- fastify.decorateRequest('solidosUiEnabled', null);
183
179
  fastify.decorateRequest('defaultQuota', null);
184
180
  fastify.decorateRequest('config', null);
185
181
  fastify.decorateRequest('liveReloadEnabled', null);
@@ -193,7 +189,6 @@ export function createServer(options = {}) {
193
189
  request.mashlibCdn = mashlibCdn;
194
190
  request.mashlibVersion = mashlibVersion;
195
191
  request.mashlibModule = mashlibModule;
196
- request.solidosUiEnabled = solidosUiEnabled;
197
192
  request.defaultQuota = defaultQuota;
198
193
  request.config = { public: options.public, readOnly: options.readOnly };
199
194
  request.liveReloadEnabled = liveReloadEnabled;
@@ -410,7 +405,7 @@ export function createServer(options = {}) {
410
405
  // Authorization hook - check WAC permissions
411
406
  // Skip for pod creation endpoint (needs special handling)
412
407
  fastify.addHook('preHandler', async (request, reply) => {
413
- // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
408
+ // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
414
409
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
415
410
  const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
416
411
  '/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
@@ -424,7 +419,6 @@ export function createServer(options = {}) {
424
419
  request.method === 'OPTIONS' ||
425
420
  request.url.startsWith('/idp/') ||
426
421
  request.url.startsWith('/.well-known/') ||
427
- request.url.startsWith('/solidos-ui/') ||
428
422
  (nostrEnabled && request.url.startsWith(nostrPath)) ||
429
423
  (gitEnabled && isGitRequest(request.url)) ||
430
424
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
@@ -464,72 +458,15 @@ export function createServer(options = {}) {
464
458
  }
465
459
  }, handleCreatePod);
466
460
 
467
- // Mashlib static files (served from root like NSS does)
468
- if (mashlibEnabled) {
469
- if (mashlibCdn) {
470
- // CDN mode: redirect chunk requests to CDN
471
- // Mashlib uses code splitting, so it loads chunks like 789.mashlib.min.js
472
- const cdnBase = `https://unpkg.com/mashlib@${mashlibVersion}/dist`;
473
- const chunkPattern = /^\/\d+\.mashlib\.min\.js(\.map)?$/;
474
-
475
- fastify.addHook('onRequest', async (request, reply) => {
476
- if (chunkPattern.test(request.url)) {
477
- const filename = request.url.split('/').pop();
478
- return reply.redirect(302, `${cdnBase}/${filename}`);
479
- }
480
- });
481
- } else {
482
- // Local mode: serve from local files
483
- const mashlibDir = join(__dirname, 'mashlib-local', 'dist');
484
- const mashlibFiles = {
485
- '/mashlib.min.js': { file: 'mashlib.min.js', type: 'application/javascript' },
486
- '/mashlib.min.js.map': { file: 'mashlib.min.js.map', type: 'application/json' },
487
- '/mash.css': { file: 'mash.css', type: 'text/css' },
488
- '/mash.css.map': { file: 'mash.css.map', type: 'application/json' },
489
- '/841.mashlib.min.js': { file: '841.mashlib.min.js', type: 'application/javascript' },
490
- '/841.mashlib.min.js.map': { file: '841.mashlib.min.js.map', type: 'application/json' }
491
- };
492
-
493
- for (const [path, config] of Object.entries(mashlibFiles)) {
494
- fastify.get(path, async (request, reply) => {
495
- try {
496
- const content = await readFile(join(mashlibDir, config.file));
497
- return reply.type(config.type).send(content);
498
- } catch {
499
- return reply.code(404).send({ error: 'Not Found' });
500
- }
501
- });
502
- }
503
- }
504
- }
461
+ // Mashlib CDN mode: redirect chunk requests to CDN
462
+ if (mashlibEnabled && mashlibCdn) {
463
+ const cdnBase = `https://unpkg.com/mashlib@${mashlibVersion}/dist`;
464
+ const chunkPattern = /^\/\d+\.mashlib\.min\.js(\.map)?$/;
505
465
 
506
- // SolidOS UI static files (modern Nextcloud-style interface)
507
- // Serves from /solidos-ui/* - requires mashlib to be enabled as well
508
- if (solidosUiEnabled && mashlibEnabled) {
509
- const solidosUiDir = join(__dirname, 'mashlib-local', 'dist', 'solidos-ui');
510
-
511
- // Serve all files under /solidos-ui/* path
512
- fastify.get('/solidos-ui/*', async (request, reply) => {
513
- try {
514
- // Get the path after /solidos-ui/
515
- const filePath = request.url.replace('/solidos-ui/', '').split('?')[0];
516
- const fullPath = join(solidosUiDir, filePath);
517
-
518
- // Determine content type based on extension
519
- const ext = filePath.split('.').pop()?.toLowerCase();
520
- const contentTypes = {
521
- 'js': 'application/javascript',
522
- 'css': 'text/css',
523
- 'map': 'application/json',
524
- 'html': 'text/html'
525
- };
526
- const contentType = contentTypes[ext] || 'application/octet-stream';
527
-
528
- const content = await readFile(fullPath);
529
- return reply.type(contentType).send(content);
530
- } catch (err) {
531
- request.log.error(err, 'Failed to serve solidos-ui file');
532
- return reply.code(404).send({ error: 'Not Found' });
466
+ fastify.addHook('onRequest', async (request, reply) => {
467
+ if (chunkPattern.test(request.url)) {
468
+ const filename = request.url.split('/').pop();
469
+ return reply.redirect(302, `${cdnBase}/${filename}`);
533
470
  }
534
471
  });
535
472
  }
@@ -2,13 +2,18 @@
2
2
  * WebRTC Signaling Server Plugin
3
3
  *
4
4
  * Lightweight signaling server for WebRTC peer-to-peer connections.
5
- * Relays SDP offers/answers and ICE candidates between authenticated users.
6
- * The actual media/data flow directly between peers — JSS just introduces them.
5
+ * Supports two discovery modes:
6
+ *
7
+ * 1. Identity-based — connect to a specific peer by WebID
8
+ * 2. Content-addressed — find peers sharing the same resource hash
9
+ *
10
+ * Relays SDP offers/answers and ICE candidates between peers.
11
+ * The actual media/data flows directly between peers — JSS just introduces them.
7
12
  *
8
13
  * Usage: jss start --webrtc
9
14
  * Endpoint: wss://your.pod/.webrtc
10
15
  *
11
- * Protocol (JSON over WebSocket):
16
+ * Identity-based protocol (JSON over WebSocket):
12
17
  * → { type: "offer", to: "<webid>", sdp: "..." }
13
18
  * → { type: "answer", to: "<webid>", sdp: "..." }
14
19
  * → { type: "candidate", to: "<webid>", candidate: {...} }
@@ -21,6 +26,14 @@
21
26
  * ← { type: "peers", you: "<webid>", peers: ["<webid>", ...] }
22
27
  * ← { type: "peer-joined", webId: "<webid>" }
23
28
  * ← { type: "peer-left", webId: "<webid>" }
29
+ *
30
+ * Content-addressed protocol (JSON over WebSocket):
31
+ * → { type: "announce", resource: "<hash>", offers: [{ sdp: "...", offer_id: "..." }, ...] }
32
+ * → { type: "answer", resource: "<hash>", to: "<peer_id>", offer_id: "...", sdp: "..." }
33
+ * → { type: "leave", resource: "<hash>" }
34
+ * ← { type: "offer", resource: "<hash>", from: "<peer_id>", offer_id: "...", sdp: "..." }
35
+ * ← { type: "answer", resource: "<hash>", from: "<peer_id>", offer_id: "...", sdp: "..." }
36
+ * ← { type: "resource-peers", resource: "<hash>", count: <n> }
24
37
  */
25
38
 
26
39
  import websocket from '@fastify/websocket';
@@ -28,6 +41,9 @@ import { getWebIdFromRequestAsync } from '../auth/token.js';
28
41
 
29
42
  const ALLOWED_TYPES = new Set(['offer', 'answer', 'candidate', 'hangup']);
30
43
  const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
44
+ const MAX_OFFERS_PER_ANNOUNCE = 10;
45
+ const MAX_RESOURCES_PER_PEER = 50;
46
+ const RESOURCE_HASH_RE = /^[a-fA-F0-9]{8,128}$/;
31
47
 
32
48
  /**
33
49
  * Register WebRTC signaling routes on Fastify instance
@@ -39,9 +55,20 @@ const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
39
55
  export async function webrtcPlugin(fastify, options = {}) {
40
56
  const path = options.path || '/.webrtc';
41
57
 
42
- // Instance-scoped peer state
58
+ // Instance-scoped peer state (identity-based)
43
59
  const peers = new Map();
44
60
 
61
+ // Instance-scoped resource state (content-addressed)
62
+ // Map<resourceHash, Map<peerId, socket>>
63
+ const resources = new Map();
64
+
65
+ // Track which resources each peer has joined
66
+ // Map<peerId, Set<resourceHash>>
67
+ const peerResources = new Map();
68
+
69
+ // Peer ID counter for content-addressed mode
70
+ let nextPeerId = 1;
71
+
45
72
  // Only register @fastify/websocket if not already registered
46
73
  if (!fastify.websocketServer) {
47
74
  await fastify.register(websocket);
@@ -53,6 +80,8 @@ export async function webrtcPlugin(fastify, options = {}) {
53
80
  socket.close();
54
81
  }
55
82
  peers.clear();
83
+ resources.clear();
84
+ peerResources.clear();
56
85
  });
57
86
 
58
87
  function broadcast(senderWebId, msg) {
@@ -64,6 +93,130 @@ export async function webrtcPlugin(fastify, options = {}) {
64
93
  }
65
94
  }
66
95
 
96
+ // --- Content-addressed helpers ---
97
+
98
+ function getResourcePeers(resourceHash) {
99
+ let group = resources.get(resourceHash);
100
+ if (!group) {
101
+ group = new Map();
102
+ resources.set(resourceHash, group);
103
+ }
104
+ return group;
105
+ }
106
+
107
+ function addPeerToResource(peerId, socket, resourceHash) {
108
+ const group = getResourcePeers(resourceHash);
109
+ group.set(peerId, socket);
110
+
111
+ let tracked = peerResources.get(peerId);
112
+ if (!tracked) {
113
+ tracked = new Set();
114
+ peerResources.set(peerId, tracked);
115
+ }
116
+ tracked.add(resourceHash);
117
+ }
118
+
119
+ function removePeerFromResource(peerId, resourceHash) {
120
+ const group = resources.get(resourceHash);
121
+ if (group) {
122
+ group.delete(peerId);
123
+ if (group.size === 0) resources.delete(resourceHash);
124
+ }
125
+ const tracked = peerResources.get(peerId);
126
+ if (tracked) {
127
+ tracked.delete(resourceHash);
128
+ if (tracked.size === 0) peerResources.delete(peerId);
129
+ }
130
+ }
131
+
132
+ function removePeerFromAllResources(peerId) {
133
+ const tracked = peerResources.get(peerId);
134
+ if (!tracked) return;
135
+ for (const hash of tracked) {
136
+ const group = resources.get(hash);
137
+ if (group) {
138
+ group.delete(peerId);
139
+ if (group.size === 0) resources.delete(hash);
140
+ }
141
+ }
142
+ peerResources.delete(peerId);
143
+ }
144
+
145
+ function handleAnnounce(socket, peerId, msg) {
146
+ const hash = msg.resource;
147
+ if (!hash || typeof hash !== 'string' || !RESOURCE_HASH_RE.test(hash)) {
148
+ socket.send(JSON.stringify({ type: 'error', message: 'Invalid resource hash' }));
149
+ return;
150
+ }
151
+
152
+ // Limit resources per peer
153
+ const tracked = peerResources.get(peerId);
154
+ if (tracked && tracked.size >= MAX_RESOURCES_PER_PEER && !tracked.has(hash)) {
155
+ socket.send(JSON.stringify({ type: 'error', message: 'Too many resources' }));
156
+ return;
157
+ }
158
+
159
+ const group = getResourcePeers(hash);
160
+ addPeerToResource(peerId, socket, hash);
161
+
162
+ // Relay offers to existing peers in the group
163
+ const offers = Array.isArray(msg.offers) ? msg.offers.slice(0, MAX_OFFERS_PER_ANNOUNCE) : [];
164
+ const existingPeers = [...group.entries()].filter(([id]) => id !== peerId);
165
+
166
+ for (let i = 0; i < offers.length && i < existingPeers.length; i++) {
167
+ const offer = offers[i];
168
+ const [targetId, targetSocket] = existingPeers[i];
169
+ if (targetSocket.readyState !== 1) continue;
170
+ if (typeof offer.sdp !== 'string') continue;
171
+
172
+ const relay = Object.create(null);
173
+ relay.type = 'offer';
174
+ relay.resource = hash;
175
+ relay.from = peerId;
176
+ relay.offer_id = typeof offer.offer_id === 'string' ? offer.offer_id : String(i);
177
+ relay.sdp = offer.sdp;
178
+ try { targetSocket.send(JSON.stringify(relay)); } catch { /* peer gone */ }
179
+ }
180
+
181
+ // Tell the announcer how many peers are in the group
182
+ socket.send(JSON.stringify({
183
+ type: 'resource-peers',
184
+ resource: hash,
185
+ count: group.size - 1
186
+ }));
187
+ }
188
+
189
+ function handleResourceAnswer(socket, peerId, msg) {
190
+ const hash = msg.resource;
191
+ if (!hash || typeof hash !== 'string') return;
192
+
193
+ const group = resources.get(hash);
194
+ if (!group) return;
195
+
196
+ const targetId = msg.to;
197
+ const targetSocket = group.get(targetId);
198
+ if (!targetSocket || targetSocket.readyState !== 1) {
199
+ socket.send(JSON.stringify({ type: 'error', message: 'Peer not in resource group' }));
200
+ return;
201
+ }
202
+
203
+ const relay = Object.create(null);
204
+ relay.type = 'answer';
205
+ relay.resource = hash;
206
+ relay.from = peerId;
207
+ if (typeof msg.offer_id === 'string') relay.offer_id = msg.offer_id;
208
+ if (typeof msg.sdp === 'string') relay.sdp = msg.sdp;
209
+ try { targetSocket.send(JSON.stringify(relay)); } catch { /* peer gone */ }
210
+ }
211
+
212
+ function handleLeave(socket, peerId, msg) {
213
+ const hash = msg.resource;
214
+ if (!hash || typeof hash !== 'string') return;
215
+ removePeerFromResource(peerId, hash);
216
+ }
217
+
218
+ // --- WebSocket handler ---
219
+
67
220
  fastify.get(path, { websocket: true }, async (connection, request) => {
68
221
  const socket = connection.socket;
69
222
 
@@ -75,10 +228,16 @@ export async function webrtcPlugin(fastify, options = {}) {
75
228
  return;
76
229
  }
77
230
 
231
+ // Assign a stable peer ID for content-addressed mode
232
+ const peerId = String(nextPeerId++);
233
+ socket._peerId = peerId;
234
+
78
235
  // Register this peer (close old connection if reconnecting)
79
236
  const existing = peers.get(webId);
80
237
  const isReconnect = !!existing;
81
238
  if (existing) {
239
+ // Clean up old connection's resource memberships
240
+ if (existing._peerId) removePeerFromAllResources(existing._peerId);
82
241
  peers.delete(webId);
83
242
  existing.close();
84
243
  }
@@ -89,6 +248,7 @@ export async function webrtcPlugin(fastify, options = {}) {
89
248
  socket.send(JSON.stringify({
90
249
  type: 'peers',
91
250
  you: webId,
251
+ peerId: peerId,
92
252
  peers: [...peers.keys()].filter(id => id !== webId)
93
253
  }));
94
254
 
@@ -113,8 +273,28 @@ export async function webrtcPlugin(fastify, options = {}) {
113
273
  return;
114
274
  }
115
275
 
116
- if (!msg.to || !msg.type) {
117
- socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" or "type" field' }));
276
+ if (!msg.type) {
277
+ socket.send(JSON.stringify({ type: 'error', message: 'Missing "type" field' }));
278
+ return;
279
+ }
280
+
281
+ // Content-addressed messages
282
+ if (msg.type === 'announce') {
283
+ handleAnnounce(socket, peerId, msg);
284
+ return;
285
+ }
286
+ if (msg.type === 'answer' && msg.resource) {
287
+ handleResourceAnswer(socket, peerId, msg);
288
+ return;
289
+ }
290
+ if (msg.type === 'leave') {
291
+ handleLeave(socket, peerId, msg);
292
+ return;
293
+ }
294
+
295
+ // Identity-based messages require "to" field
296
+ if (!msg.to) {
297
+ socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" field' }));
118
298
  return;
119
299
  }
120
300
 
@@ -144,6 +324,9 @@ export async function webrtcPlugin(fastify, options = {}) {
144
324
  });
145
325
 
146
326
  socket.on('close', () => {
327
+ // Clean up content-addressed resource memberships
328
+ removePeerFromAllResources(peerId);
329
+
147
330
  // Only remove if this socket is still the registered one (not replaced by reconnect)
148
331
  if (peers.get(webId) === socket) {
149
332
  peers.delete(webId);
@@ -209,4 +209,158 @@ describe('WebRTC Signaling', () => {
209
209
  await new Promise(r => setTimeout(r, 50));
210
210
  });
211
211
  });
212
+
213
+ describe('Content-Addressed Peer Discovery', () => {
214
+ const RESOURCE_HASH = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
215
+
216
+ it('should return peer count on announce', async () => {
217
+ const { ws: alice } = await connectAndWait('alice');
218
+
219
+ alice.send(JSON.stringify({
220
+ type: 'announce',
221
+ resource: RESOURCE_HASH,
222
+ offers: []
223
+ }));
224
+
225
+ const msg = await waitForMessage(alice, 'resource-peers');
226
+ assert.strictEqual(msg.resource, RESOURCE_HASH);
227
+ assert.strictEqual(msg.count, 0);
228
+
229
+ alice.close();
230
+ await new Promise(r => setTimeout(r, 50));
231
+ });
232
+
233
+ it('should relay offers between peers sharing a resource', async () => {
234
+ const { ws: alice, peerId: alicePeerId } = await connectAndWait('alice');
235
+
236
+ // Alice announces with no offers (first in group)
237
+ alice.send(JSON.stringify({
238
+ type: 'announce',
239
+ resource: RESOURCE_HASH,
240
+ offers: []
241
+ }));
242
+ await waitForMessage(alice, 'resource-peers');
243
+
244
+ // Bob announces with an offer — should be relayed to Alice
245
+ const offerPromise = waitForMessage(alice, 'offer');
246
+ const { ws: bob, peerId: bobPeerId } = await connectAndWait('bob');
247
+
248
+ bob.send(JSON.stringify({
249
+ type: 'announce',
250
+ resource: RESOURCE_HASH,
251
+ offers: [{ sdp: 'v=0\r\nbob-offer', offer_id: 'offer1' }]
252
+ }));
253
+
254
+ const offer = await offerPromise;
255
+ assert.strictEqual(offer.type, 'offer');
256
+ assert.strictEqual(offer.resource, RESOURCE_HASH);
257
+ assert.strictEqual(offer.from, bobPeerId);
258
+ assert.strictEqual(offer.offer_id, 'offer1');
259
+ assert.ok(offer.sdp.includes('bob-offer'));
260
+
261
+ // Alice answers Bob
262
+ const answerPromise = waitForMessage(bob, 'answer');
263
+ alice.send(JSON.stringify({
264
+ type: 'answer',
265
+ resource: RESOURCE_HASH,
266
+ to: bobPeerId,
267
+ offer_id: 'offer1',
268
+ sdp: 'v=0\r\nalice-answer'
269
+ }));
270
+
271
+ const answer = await answerPromise;
272
+ assert.strictEqual(answer.type, 'answer');
273
+ assert.strictEqual(answer.resource, RESOURCE_HASH);
274
+ assert.strictEqual(answer.from, alicePeerId);
275
+ assert.ok(answer.sdp.includes('alice-answer'));
276
+
277
+ alice.close();
278
+ bob.close();
279
+ await new Promise(r => setTimeout(r, 50));
280
+ });
281
+
282
+ it('should clean up resources on disconnect', async () => {
283
+ const { ws: alice } = await connectAndWait('alice');
284
+
285
+ alice.send(JSON.stringify({
286
+ type: 'announce',
287
+ resource: RESOURCE_HASH,
288
+ offers: []
289
+ }));
290
+ await waitForMessage(alice, 'resource-peers');
291
+
292
+ // Bob joins the resource group
293
+ const { ws: bob } = await connectAndWait('bob');
294
+ bob.send(JSON.stringify({
295
+ type: 'announce',
296
+ resource: RESOURCE_HASH,
297
+ offers: []
298
+ }));
299
+ const bobPeers = await waitForMessage(bob, 'resource-peers');
300
+ assert.strictEqual(bobPeers.count, 1); // alice is there
301
+
302
+ // Alice disconnects
303
+ alice.close();
304
+ await new Promise(r => setTimeout(r, 200));
305
+
306
+ // Charlie joins — should see only bob
307
+ const { ws: charlie } = await connectAndWait('bob'); // reuse bob pod
308
+ charlie.send(JSON.stringify({
309
+ type: 'announce',
310
+ resource: RESOURCE_HASH,
311
+ offers: []
312
+ }));
313
+ const charliePeers = await waitForMessage(charlie, 'resource-peers');
314
+ // bob was reconnected (old connection closed), so count depends on timing
315
+ assert.ok(charliePeers.count >= 0);
316
+
317
+ bob.close();
318
+ charlie.close();
319
+ await new Promise(r => setTimeout(r, 50));
320
+ });
321
+
322
+ it('should handle leave message', async () => {
323
+ const { ws: alice } = await connectAndWait('alice');
324
+
325
+ alice.send(JSON.stringify({
326
+ type: 'announce',
327
+ resource: RESOURCE_HASH,
328
+ offers: []
329
+ }));
330
+ await waitForMessage(alice, 'resource-peers');
331
+
332
+ // Leave the resource group
333
+ alice.send(JSON.stringify({ type: 'leave', resource: RESOURCE_HASH }));
334
+
335
+ // Bob joins — should see 0 peers (alice left)
336
+ const { ws: bob } = await connectAndWait('bob');
337
+ bob.send(JSON.stringify({
338
+ type: 'announce',
339
+ resource: RESOURCE_HASH,
340
+ offers: []
341
+ }));
342
+ const msg = await waitForMessage(bob, 'resource-peers');
343
+ assert.strictEqual(msg.count, 0);
344
+
345
+ alice.close();
346
+ bob.close();
347
+ await new Promise(r => setTimeout(r, 50));
348
+ });
349
+
350
+ it('should reject invalid resource hash', async () => {
351
+ const { ws: alice } = await connectAndWait('alice');
352
+
353
+ alice.send(JSON.stringify({
354
+ type: 'announce',
355
+ resource: 'not-a-hex-hash!',
356
+ offers: []
357
+ }));
358
+
359
+ const err = await waitForMessage(alice, 'error');
360
+ assert.ok(err.message.includes('Invalid resource hash'));
361
+
362
+ alice.close();
363
+ await new Promise(r => setTimeout(r, 50));
364
+ });
365
+ });
212
366
  });