javascript-solid-server 0.0.16 → 0.0.18

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 (45) hide show
  1. package/.claude/settings.local.json +8 -1
  2. package/LICENSE +661 -0
  3. package/README.md +28 -2
  4. package/bin/jss.js +6 -0
  5. package/data/.idp/accounts/_email_index.json +3 -0
  6. package/data/.idp/accounts/_webid_index.json +3 -0
  7. package/data/.idp/accounts/be4cfcd3-f6dd-41de-b2f5-7e4045d3e78c.json +9 -0
  8. package/data/.idp/authorization_code/6MiwPzYssbmBMdZ0_yp9RUmeGKJpmChhcPNAT-xkwE1.json +18 -0
  9. package/data/.idp/authorization_code/eC1ZBx2mpNsr0OjKMKdkKK69E4EfKV1SwU8p0dWFQTj.json +18 -0
  10. package/data/.idp/authorization_code/puWy-HQtx9ONFDhm_Bv-sRcBfm3OQPppRalQ956hLR4.json +18 -0
  11. package/data/.idp/client/client_mjocmpsj_vdrkczw9.json +25 -0
  12. package/data/.idp/client/client_mjocnc8k_3sd1aoa6.json +25 -0
  13. package/data/.idp/client/client_mjocnlbf_dkm0ltze.json +25 -0
  14. package/data/.idp/grant/ImpM7BIsyKuhvme7UOctBmRUicTsZweHOrHk95wza0s.json +16 -0
  15. package/data/.idp/grant/R2aOui_2A-m6E_aeq_03IyMd6N5OrFJ-lT67cCTuzOL.json +16 -0
  16. package/data/.idp/grant/YFxdDUi4neEPXjx_riL4_Tyg_VXuyhax_qm9yFEvrWG.json +16 -0
  17. package/data/.idp/grant/k5rDpXSPbMMzYLaWILONhIojmzNP6f1hkWxajC_weW3.json +16 -0
  18. package/data/.idp/grant/luO7y4I33a7yWi1BnfgOtoNcr1-5vH8l3t-aIqG8IgI.json +16 -0
  19. package/data/.idp/grant/nkXTstzTTyrUEN5H0f8Q-YR5jMqk0WdDc6H6H1XD6lJ.json +16 -0
  20. package/data/.idp/registration_access_token/CPhAs33MGp8s-gCRvdnbqjdDDdi0g9vNwacfuLGhX6L.json +7 -0
  21. package/data/.idp/registration_access_token/dbOnxWLEW5O2_pYEfCFQJYbvpdqJJ_t35gr1dd7jC_6.json +7 -0
  22. package/data/.idp/registration_access_token/lAFc6diCNs1g9KLYHRD75O-cH5uv4dRX09HPIelMZbE.json +7 -0
  23. package/data/.idp/session/VXgvz6cHkuwDGG3Hp7CYWzck4AlTDH9qCOnCxVVAGOb.json +28 -0
  24. package/data/demo/.acl +47 -0
  25. package/data/demo/inbox/.acl +50 -0
  26. package/data/demo/index.html +80 -0
  27. package/data/demo/private/.acl +32 -0
  28. package/data/demo/public/.acl +50 -0
  29. package/data/demo/public/card.ttl +8 -0
  30. package/data/demo/settings/.acl +32 -0
  31. package/data/demo/settings/prefs +17 -0
  32. package/data/demo/settings/privateTypeIndex +7 -0
  33. package/data/demo/settings/publicTypeIndex +7 -0
  34. package/package.json +2 -2
  35. package/src/config.js +7 -0
  36. package/src/handlers/resource.js +22 -3
  37. package/src/idp/interactions.js +13 -12
  38. package/src/mashlib/index.js +98 -0
  39. package/src/server.js +7 -0
  40. package/src/utils/url.js +18 -2
  41. package/test-data-idp-accounts/.idp/accounts/_email_index.json +1 -1
  42. package/test-data-idp-accounts/.idp/accounts/_webid_index.json +1 -1
  43. package/test-data-idp-accounts/.idp/accounts/b49949d9-6d61-45a1-bcee-07295aa07579.json +9 -0
  44. package/test-data-idp-accounts/.idp/keys/jwks.json +8 -8
  45. package/test-data-idp-accounts/.idp/accounts/3c1cd503-1d7f-4ba0-a3af-ebedf519594d.json +0 -9
@@ -0,0 +1,50 @@
1
+ {
2
+ "@context": {
3
+ "acl": "http://www.w3.org/ns/auth/acl#",
4
+ "foaf": "http://xmlns.com/foaf/0.1/"
5
+ },
6
+ "@graph": [
7
+ {
8
+ "@id": "#owner",
9
+ "@type": "acl:Authorization",
10
+ "acl:agent": {
11
+ "@id": "http://localhost:4000/demo/#me"
12
+ },
13
+ "acl:accessTo": {
14
+ "@id": "http://localhost:4000/demo/public/"
15
+ },
16
+ "acl:default": {
17
+ "@id": "http://localhost:4000/demo/public/"
18
+ },
19
+ "acl:mode": [
20
+ {
21
+ "@id": "acl:Read"
22
+ },
23
+ {
24
+ "@id": "acl:Write"
25
+ },
26
+ {
27
+ "@id": "acl:Control"
28
+ }
29
+ ]
30
+ },
31
+ {
32
+ "@id": "#public",
33
+ "@type": "acl:Authorization",
34
+ "acl:agentClass": {
35
+ "@id": "foaf:Agent"
36
+ },
37
+ "acl:accessTo": {
38
+ "@id": "http://localhost:4000/demo/public/"
39
+ },
40
+ "acl:default": {
41
+ "@id": "http://localhost:4000/demo/public/"
42
+ },
43
+ "acl:mode": [
44
+ {
45
+ "@id": "acl:Read"
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
@@ -0,0 +1,8 @@
1
+ @prefix foaf: <http://xmlns.com/foaf/0.1/> .
2
+ @prefix solid: <http://www.w3.org/ns/solid/terms#> .
3
+ @prefix schema: <http://schema.org/> .
4
+
5
+ <#me> a foaf:Person ;
6
+ foaf:name "Demo User" ;
7
+ foaf:mbox <mailto:demo@example.com> ;
8
+ schema:knows <https://melvincarvalho.com/#me> .
@@ -0,0 +1,32 @@
1
+ {
2
+ "@context": {
3
+ "acl": "http://www.w3.org/ns/auth/acl#",
4
+ "foaf": "http://xmlns.com/foaf/0.1/"
5
+ },
6
+ "@graph": [
7
+ {
8
+ "@id": "#owner",
9
+ "@type": "acl:Authorization",
10
+ "acl:agent": {
11
+ "@id": "http://localhost:4000/demo/#me"
12
+ },
13
+ "acl:accessTo": {
14
+ "@id": "http://localhost:4000/demo/settings/"
15
+ },
16
+ "acl:mode": [
17
+ {
18
+ "@id": "acl:Read"
19
+ },
20
+ {
21
+ "@id": "acl:Write"
22
+ },
23
+ {
24
+ "@id": "acl:Control"
25
+ }
26
+ ],
27
+ "acl:default": {
28
+ "@id": "http://localhost:4000/demo/settings/"
29
+ }
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "@context": {
3
+ "solid": "http://www.w3.org/ns/solid/terms#",
4
+ "pim": "http://www.w3.org/ns/pim/space#",
5
+ "publicTypeIndex": {
6
+ "@id": "solid:publicTypeIndex",
7
+ "@type": "@id"
8
+ },
9
+ "privateTypeIndex": {
10
+ "@id": "solid:privateTypeIndex",
11
+ "@type": "@id"
12
+ }
13
+ },
14
+ "@id": "http://localhost:4000/demo/settings/prefs",
15
+ "publicTypeIndex": "http://localhost:4000/demo/settings/publicTypeIndex",
16
+ "privateTypeIndex": "http://localhost:4000/demo/settings/privateTypeIndex"
17
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "@context": {
3
+ "solid": "http://www.w3.org/ns/solid/terms#"
4
+ },
5
+ "@id": "http://localhost:4000/demo/settings/privateTypeIndex",
6
+ "@type": "solid:TypeIndex"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "@context": {
3
+ "solid": "http://www.w3.org/ns/solid/terms#"
4
+ },
5
+ "@id": "http://localhost:4000/demo/settings/publicTypeIndex",
6
+ "@type": "solid:TypeIndex"
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "linked-data",
43
43
  "decentralized"
44
44
  ],
45
- "license": "MIT",
45
+ "license": "AGPL-3.0-only",
46
46
  "devDependencies": {
47
47
  "autocannon": "^8.0.0"
48
48
  }
package/src/config.js CHANGED
@@ -37,6 +37,10 @@ export const defaults = {
37
37
  subdomains: false,
38
38
  baseDomain: null,
39
39
 
40
+ // Mashlib data browser
41
+ mashlib: false,
42
+ mashlibVersion: '2.0.0',
43
+
40
44
  // Logging
41
45
  logger: true,
42
46
  quiet: false,
@@ -63,6 +67,8 @@ const envMap = {
63
67
  JSS_IDP_ISSUER: 'idpIssuer',
64
68
  JSS_SUBDOMAINS: 'subdomains',
65
69
  JSS_BASE_DOMAIN: 'baseDomain',
70
+ JSS_MASHLIB: 'mashlib',
71
+ JSS_MASHLIB_VERSION: 'mashlibVersion',
66
72
  };
67
73
 
68
74
  /**
@@ -195,5 +201,6 @@ export function printConfig(config) {
195
201
  console.log(` Notifications: ${config.notifications}`);
196
202
  console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
197
203
  console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
204
+ console.log(` Mashlib: ${config.mashlib ? `v${config.mashlibVersion}` : 'disabled'}`);
198
205
  console.log('─'.repeat(40));
199
206
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from '../rdf/conneg.js';
15
15
  import { emitChange } from '../notifications/events.js';
16
16
  import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
17
+ import { generateDatabrowserHtml, shouldServeMashlib } from '../mashlib/index.js';
17
18
 
18
19
  /**
19
20
  * Get the storage path and resource URL for a request
@@ -134,14 +135,32 @@ export async function handleGet(request, reply) {
134
135
  }
135
136
 
136
137
  // Handle resource
138
+ const storedContentType = getContentType(storagePath);
139
+ const connegEnabled = request.connegEnabled || false;
140
+
141
+ // Check if we should serve Mashlib data browser
142
+ // Only for RDF resources when Accept: text/html is requested
143
+ if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
144
+ const html = generateDatabrowserHtml(resourceUrl, request.mashlibVersion);
145
+ const headers = getAllHeaders({
146
+ isContainer: false,
147
+ etag: stats.etag,
148
+ contentType: 'text/html',
149
+ origin,
150
+ resourceUrl,
151
+ connegEnabled
152
+ });
153
+ headers['Vary'] = 'Accept';
154
+
155
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
156
+ return reply.type('text/html').send(html);
157
+ }
158
+
137
159
  const content = await storage.read(storagePath);
138
160
  if (content === null) {
139
161
  return reply.code(500).send({ error: 'Read error' });
140
162
  }
141
163
 
142
- const storedContentType = getContentType(storagePath);
143
- const connegEnabled = request.connegEnabled || false;
144
-
145
164
  // Content negotiation for RDF resources
146
165
  if (connegEnabled && isRdfContentType(storedContentType)) {
147
166
  try {
@@ -118,20 +118,22 @@ export async function handleLogin(request, reply, provider) {
118
118
 
119
119
  request.log.info({ accountId: account.id, uid }, 'Login successful');
120
120
 
121
- // For CTH compatibility, we need to return a response that CTH can handle.
122
- // CTH expects either:
123
- // 1. A redirect it can follow (but Java HttpClient follows to final destination which fails)
124
- // 2. A 200 response with "location" in body (CSS v3+ style)
125
- //
126
- // We use interactionResult to get the redirect URL, then save it and return JSON
127
-
128
- // Save the login result to the interaction for programmatic clients
129
- // This allows the auth endpoint to continue the flow when resumed
121
+ // Detect if this is a browser (wants HTML/redirect) or programmatic client (wants JSON)
122
+ const acceptHeader = request.headers.accept || '';
123
+ const wantsBrowserRedirect = acceptHeader.includes('text/html') && !acceptHeader.includes('application/json');
124
+
125
+ // Save the login result to the interaction
130
126
  interaction.result = result;
131
127
  await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
132
128
 
133
- // For CTH and programmatic clients: use interactionFinished with hijacked response
134
- // to properly complete the interaction while returning JSON
129
+ // For browsers (mashlib, etc): do a proper HTTP redirect
130
+ if (wantsBrowserRedirect) {
131
+ reply.hijack();
132
+ return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false });
133
+ }
134
+
135
+ // For CTH and programmatic clients: return JSON with location
136
+ // CTH expects a 200 response with "location" in body (CSS v3+ style)
135
137
  try {
136
138
  reply.hijack();
137
139
 
@@ -188,7 +190,6 @@ export async function handleLogin(request, reply, provider) {
188
190
  request.log.warn({ err: err.message, errName: err.name, uid }, 'interactionFinished failed, using fallback');
189
191
 
190
192
  // Fallback: return the redirect URL for manual following
191
- // The interaction result is already saved above
192
193
  const redirectTo = `/idp/auth/${uid}`;
193
194
  return reply
194
195
  .code(200)
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Mashlib Data Browser Integration
3
+ *
4
+ * Generates HTML wrapper that loads SolidOS Mashlib from CDN.
5
+ * When a browser requests an RDF resource with Accept: text/html,
6
+ * we return this wrapper which then fetches and renders the data.
7
+ */
8
+
9
+ const CDN_BASE = 'https://unpkg.com/mashlib';
10
+
11
+ /**
12
+ * Generate Mashlib databrowser HTML
13
+ * @param {string} resourceUrl - The URL of the resource being viewed
14
+ * @param {string} version - Mashlib version (default: '2.0.0')
15
+ * @returns {string} HTML content
16
+ */
17
+ export function generateDatabrowserHtml(resourceUrl, version = '2.0.0') {
18
+ const cdnUrl = `${CDN_BASE}@${version}/dist`;
19
+
20
+ return `<!doctype html>
21
+ <html>
22
+ <head>
23
+ <meta charset="utf-8"/>
24
+ <meta name="viewport" content="width=device-width, initial-scale=1">
25
+ <title>SolidOS - ${escapeHtml(resourceUrl)}</title>
26
+ <script defer src="${cdnUrl}/mashlib.min.js"></script>
27
+ <link href="${cdnUrl}/mash.css" rel="stylesheet">
28
+ <script>
29
+ document.addEventListener('DOMContentLoaded', function() {
30
+ // runDataBrowser uses window.location to determine what to fetch
31
+ panes.runDataBrowser();
32
+ });
33
+ </script>
34
+ <style>
35
+ /* Loading indicator */
36
+ body:not(.loaded) #PageBody::before {
37
+ content: 'Loading SolidOS...';
38
+ display: block;
39
+ padding: 2em;
40
+ text-align: center;
41
+ color: #666;
42
+ }
43
+ </style>
44
+ </head>
45
+ <body id="PageBody">
46
+ <header id="PageHeader"></header>
47
+ <div class="TabulatorOutline" id="DummyUUID" role="main">
48
+ <table id="outline"></table>
49
+ <div id="GlobalDashboard"></div>
50
+ </div>
51
+ <footer id="PageFooter"></footer>
52
+ </body>
53
+ </html>`;
54
+ }
55
+
56
+ /**
57
+ * Check if request wants HTML and mashlib should handle it
58
+ * @param {object} request - Fastify request
59
+ * @param {boolean} mashlibEnabled - Whether mashlib is enabled
60
+ * @param {string} contentType - Content type of the resource
61
+ * @returns {boolean}
62
+ */
63
+ export function shouldServeMashlib(request, mashlibEnabled, contentType) {
64
+ if (!mashlibEnabled) {
65
+ return false;
66
+ }
67
+
68
+ const accept = request.headers.accept || '';
69
+
70
+ // Must explicitly accept HTML
71
+ if (!accept.includes('text/html')) {
72
+ return false;
73
+ }
74
+
75
+ // Only serve mashlib for RDF content types
76
+ const rdfTypes = [
77
+ 'text/turtle',
78
+ 'application/ld+json',
79
+ 'application/json',
80
+ 'text/n3',
81
+ 'application/n-triples',
82
+ 'application/rdf+xml'
83
+ ];
84
+
85
+ const baseType = contentType.split(';')[0].trim().toLowerCase();
86
+ return rdfTypes.includes(baseType);
87
+ }
88
+
89
+ /**
90
+ * Escape HTML special characters
91
+ */
92
+ function escapeHtml(str) {
93
+ return str
94
+ .replace(/&/g, '&amp;')
95
+ .replace(/</g, '&lt;')
96
+ .replace(/>/g, '&gt;')
97
+ .replace(/"/g, '&quot;');
98
+ }
package/src/server.js CHANGED
@@ -30,6 +30,9 @@ export function createServer(options = {}) {
30
30
  // Subdomain mode is OFF by default - use path-based pods
31
31
  const subdomainsEnabled = options.subdomains ?? false;
32
32
  const baseDomain = options.baseDomain || null;
33
+ // Mashlib data browser is OFF by default
34
+ const mashlibEnabled = options.mashlib ?? false;
35
+ const mashlibVersion = options.mashlibVersion ?? '2.0.0';
33
36
 
34
37
  // Set data root via environment variable if provided
35
38
  if (options.root) {
@@ -66,12 +69,16 @@ export function createServer(options = {}) {
66
69
  fastify.decorateRequest('subdomainsEnabled', null);
67
70
  fastify.decorateRequest('baseDomain', null);
68
71
  fastify.decorateRequest('podName', null);
72
+ fastify.decorateRequest('mashlibEnabled', null);
73
+ fastify.decorateRequest('mashlibVersion', null);
69
74
  fastify.addHook('onRequest', async (request) => {
70
75
  request.connegEnabled = connegEnabled;
71
76
  request.notificationsEnabled = notificationsEnabled;
72
77
  request.idpEnabled = idpEnabled;
73
78
  request.subdomainsEnabled = subdomainsEnabled;
74
79
  request.baseDomain = baseDomain;
80
+ request.mashlibEnabled = mashlibEnabled;
81
+ request.mashlibVersion = mashlibVersion;
75
82
 
76
83
  // Extract pod name from subdomain if enabled
77
84
  if (subdomainsEnabled && baseDomain) {
package/src/utils/url.js CHANGED
@@ -120,7 +120,13 @@ export function getContentType(filePath) {
120
120
  '.jpeg': 'image/jpeg',
121
121
  '.gif': 'image/gif',
122
122
  '.svg': 'image/svg+xml',
123
- '.pdf': 'application/pdf'
123
+ '.pdf': 'application/pdf',
124
+ '.ttl': 'text/turtle',
125
+ '.n3': 'text/n3',
126
+ '.nt': 'application/n-triples',
127
+ '.rdf': 'application/rdf+xml',
128
+ '.nq': 'application/n-quads',
129
+ '.trig': 'application/trig'
124
130
  };
125
131
  return types[ext] || 'application/octet-stream';
126
132
  }
@@ -131,5 +137,15 @@ export function getContentType(filePath) {
131
137
  * @returns {boolean}
132
138
  */
133
139
  export function isRdfContentType(contentType) {
134
- return contentType === 'application/ld+json' || contentType === 'application/json';
140
+ const rdfTypes = [
141
+ 'application/ld+json',
142
+ 'application/json',
143
+ 'text/turtle',
144
+ 'text/n3',
145
+ 'application/n-triples',
146
+ 'application/rdf+xml',
147
+ 'application/n-quads',
148
+ 'application/trig'
149
+ ];
150
+ return rdfTypes.includes(contentType);
135
151
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "credtest@example.com": "3c1cd503-1d7f-4ba0-a3af-ebedf519594d"
2
+ "credtest@example.com": "b49949d9-6d61-45a1-bcee-07295aa07579"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "http://localhost:3101/credtest/#me": "3c1cd503-1d7f-4ba0-a3af-ebedf519594d"
2
+ "http://localhost:3101/credtest/#me": "b49949d9-6d61-45a1-bcee-07295aa07579"
3
3
  }
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "b49949d9-6d61-45a1-bcee-07295aa07579",
3
+ "email": "credtest@example.com",
4
+ "passwordHash": "$2b$10$mVzAvASfYaz/wtb7ENo.D..AKd5CWHnOqAeL3RRPGfH20AbZG.ZEm",
5
+ "webId": "http://localhost:3101/credtest/#me",
6
+ "podName": "credtest",
7
+ "createdAt": "2025-12-27T13:40:23.165Z",
8
+ "lastLogin": "2025-12-27T13:40:23.500Z"
9
+ }
@@ -3,20 +3,20 @@
3
3
  "keys": [
4
4
  {
5
5
  "kty": "EC",
6
- "x": "NzQ-skj7cwbmI4Q_-vlQzoPYoQLWq_u34ln95VMcpnU",
7
- "y": "H6gMaardV77boCWD4OTix1DsxdY-clIBw7I5xvvDDe8",
6
+ "x": "LAyVHoAoNTkPv1-7GonFPGYWWh2Oo8W1bxWFGdX8fW8",
7
+ "y": "bntHv0EpOcvKrzlGujXkBID_7iHmp9wFte4heIrzf3Y",
8
8
  "crv": "P-256",
9
- "d": "WrlpdJo_JidHFldBjU4Q6Wv_ULhAk1j-DTU6YlHxDAQ",
10
- "kid": "4e655460-7c94-479c-9ed8-923aa8bfd77f",
9
+ "d": "V6umt-paD0-Uk9SA-0NYZHZSOz0h9OZppYwopeZXedo",
10
+ "kid": "1c8e0740-f688-4a68-8231-8d2388dcd810",
11
11
  "use": "sig",
12
12
  "alg": "ES256",
13
- "iat": 1766839680
13
+ "iat": 1766842823
14
14
  }
15
15
  ]
16
16
  },
17
17
  "cookieKeys": [
18
- "CQXN03oU_rUqugGhwZgoiI7eZMgKJaPE_kyrT9lmTu4",
19
- "jsJGLtKzYy-RJPZaAMZDpUcfY4-EtdqNOFS-Uiowk9w"
18
+ "pq7a_Nu9u72ZeHdklGCnSocY9z4nyAkk0ZuUBU8YH7U",
19
+ "RzXV-DugE1lvl331HZ5Fo04A5UY2GLJn4MjAqcAZ8Ts"
20
20
  ],
21
- "createdAt": "2025-12-27T12:48:00.136Z"
21
+ "createdAt": "2025-12-27T13:40:23.081Z"
22
22
  }
@@ -1,9 +0,0 @@
1
- {
2
- "id": "3c1cd503-1d7f-4ba0-a3af-ebedf519594d",
3
- "email": "credtest@example.com",
4
- "passwordHash": "$2b$10$h3cRwsgAo/4wEOyip6ckyOf6J.reHOzJzyZrM.LDfQdIWa3e/MkAu",
5
- "webId": "http://localhost:3101/credtest/#me",
6
- "podName": "credtest",
7
- "createdAt": "2025-12-27T12:48:00.226Z",
8
- "lastLogin": "2025-12-27T12:48:00.567Z"
9
- }