@idoall/dsh-session-colors 0.1.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/src/index.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Session-colour plugin, Host half.
3
+ *
4
+ * This half owns the durable store for colour marks and the two HTTP routes the
5
+ * browser half talks to.
6
+ *
7
+ * Why routes and a file instead of the `settings` service: DSH only host-backs
8
+ * user settings for loopback pages (`persistence = $host.isLoopback ? "host" :
9
+ * "memory"`), so a page opened over the LAN or a tunnel gets a permanently
10
+ * `unavailable` settings scope. Its own routes are not subject to that rule,
11
+ * which is what makes a mark set on one device visible on another.
12
+ *
13
+ * A mark is only a colour keyed by Session id. This half never touches Session
14
+ * identity, history, membership, ordering or archive records.
15
+ *
16
+ * Everything is defensive: a Host half that throws can take the Host's boot
17
+ * down with it, so every step degrades to "no durable store" instead.
18
+ */
19
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
20
+ import { dirname, isAbsolute, join } from 'node:path'
21
+ import z from '@deepseek-ai/schemastery'
22
+
23
+ /** Mount name, kept in sync with package.json and cordis.patch.yml. */
24
+ export const name = 'dsh-session-colors'
25
+
26
+ /** Host services are acquired inside `apply`; nothing must exist beforehand. */
27
+ export const inject = []
28
+
29
+ /** Route prefix owned by this plugin; the browser half uses the same constant. */
30
+ export const ROUTE_PREFIX = '/plugins/dsh-session-colors'
31
+
32
+ /** Request bodies are tiny: a Session id and four numbers. */
33
+ const BODY_CAP = 256 * 1024
34
+
35
+ /** One HSVA colour. Alpha is kept because the picker offers transparency. */
36
+ const ColorSchema = z.object({
37
+ h: z.number().min(0).max(360),
38
+ s: z.number().min(0).max(1),
39
+ v: z.number().min(0).max(1),
40
+ a: z.number().min(0).max(1),
41
+ })
42
+
43
+ /** The durable document: Session id → colour. `dict` because ids are dynamic. */
44
+ export const MarksSchema = z.object({
45
+ marks: z.dict(ColorSchema).default({}),
46
+ })
47
+
48
+ /**
49
+ * Plugin config. `dataDir` is supplied by the profile owner, exactly as
50
+ * `dsh-notify` does it: the plugin never guesses a profile directory.
51
+ */
52
+ export const Config = z.object({
53
+ dataDir: z.string().description('Absolute path to the profile-owned dsh-session-colors data directory.').required(false),
54
+ })
55
+
56
+ /** JSON response helper, matching the Host's own no-store convention. */
57
+ function sendJson(res, status, payload) {
58
+ res.statusCode = status
59
+ res.setHeader('content-type', 'application/json; charset=utf-8')
60
+ res.setHeader('cache-control', 'no-store')
61
+ res.end(JSON.stringify(payload))
62
+ }
63
+
64
+ /**
65
+ * A write must come from the page that owns this app. The LAN reverse proxy
66
+ * rewrites Origin/Host to loopback before forwarding, so a legitimate remote
67
+ * write still satisfies this check.
68
+ */
69
+ function sameOriginRequest(req) {
70
+ const site = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase()
71
+ if (site && site !== 'same-origin' && site !== 'same-site' && site !== 'none') return false
72
+ const origin = req.headers?.origin
73
+ if (!origin) return true
74
+ try {
75
+ return new URL(String(origin)).host === req.headers?.host
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ /** Read a JSON object body, refusing anything oversized or mislabelled. */
82
+ async function readJson(req, limit = BODY_CAP) {
83
+ const contentType = String(req.headers?.['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase()
84
+ if (contentType !== 'application/json') throw Object.assign(new Error('content-type must be application/json'), { status: 415 })
85
+ const declared = Number(req.headers?.['content-length'] ?? 0)
86
+ if (Number.isFinite(declared) && declared > limit) throw Object.assign(new Error('request body too large'), { status: 413 })
87
+ const chunks = []
88
+ let size = 0
89
+ for await (const chunk of req) {
90
+ size += chunk.byteLength
91
+ if (size > limit) throw Object.assign(new Error('request body too large'), { status: 413 })
92
+ chunks.push(chunk)
93
+ }
94
+ try {
95
+ const value = JSON.parse(Buffer.concat(chunks, size).toString('utf8'))
96
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be an object')
97
+ return value
98
+ } catch (error) {
99
+ throw Object.assign(error, { status: 400 })
100
+ }
101
+ }
102
+
103
+ /** Keep only well-formed entries so one bad record cannot break the layer. */
104
+ function cleanMarks(input) {
105
+ const marks = {}
106
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) return marks
107
+ for (const id of Object.keys(input)) {
108
+ const color = input[id]
109
+ if (color === null || typeof color !== 'object') continue
110
+ if (typeof color.h !== 'number' || typeof color.s !== 'number' || typeof color.v !== 'number' || typeof color.a !== 'number') continue
111
+ marks[id] = { h: color.h, s: color.s, v: color.v, a: color.a }
112
+ }
113
+ return marks
114
+ }
115
+
116
+ /**
117
+ * The durable marks store: one JSON document under the profile's data
118
+ * directory, written atomically. Without a usable `dataDir` it still serves the
119
+ * process's own view, so the feature degrades instead of disappearing.
120
+ * @param dataDir - absolute profile-owned directory, or undefined.
121
+ */
122
+ export function createMarksFile(dataDir) {
123
+ const file = typeof dataDir === 'string' && isAbsolute(dataDir) ? join(dataDir, 'session-colors.json') : null
124
+ let marks = {}
125
+ if (file !== null) {
126
+ try {
127
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
128
+ marks = cleanMarks(parsed?.marks)
129
+ } catch {
130
+ // First run, or a file we are about to overwrite.
131
+ }
132
+ }
133
+ return {
134
+ get: () => ({ ...marks }),
135
+ /**
136
+ * Apply one change and persist the whole document.
137
+ * @param sessionId - Session the mark belongs to.
138
+ * @param color - the colour, or undefined to clear the mark.
139
+ */
140
+ set(sessionId, color) {
141
+ const next = { ...marks }
142
+ if (color === undefined) delete next[sessionId]
143
+ else next[sessionId] = color
144
+ marks = cleanMarks(next)
145
+ if (file === null) return { ...marks }
146
+ try {
147
+ mkdirSync(dirname(file), { recursive: true })
148
+ const temporary = `${file}.tmp`
149
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, marks }, null, 2)}\n`, 'utf8')
150
+ renameSync(temporary, file)
151
+ } catch {
152
+ // A read-only profile still gets the change for this process.
153
+ }
154
+ return { ...marks }
155
+ },
156
+ /** What `/marks` reports about durability. */
157
+ get status() {
158
+ return { persistence: file === null ? 'session' : 'file' }
159
+ },
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Register the durable store and its routes when the web server is composed.
165
+ * @param ctx - Host context.
166
+ * @param config - plugin config; `dataDir` enables persistence.
167
+ */
168
+ export function apply(ctx, config = {}) {
169
+ try {
170
+ if (ctx === undefined || ctx === null || typeof ctx.inject !== 'function') return
171
+ const store = createMarksFile(config === null || config === undefined ? undefined : config.dataDir)
172
+ ctx.inject(['webServer', 'connection'], (hostCtx) => {
173
+ try {
174
+ const webServer = hostCtx === undefined || hostCtx === null ? undefined : hostCtx.webServer
175
+ const connection = hostCtx === undefined || hostCtx === null ? undefined : hostCtx.connection
176
+ if (webServer === undefined || webServer === null || typeof webServer.register !== 'function') return
177
+
178
+ const route = (path, handler) => {
179
+ const register = () => webServer.register({
180
+ kind: 'exact',
181
+ path: `${ROUTE_PREFIX}${path}`,
182
+ handler: async (req, res) => {
183
+ // Same fence the shipped authenticated routes use.
184
+ try {
185
+ const rejection = connection === undefined || connection === null ? undefined : connection.requestRejection(req)
186
+ if (rejection !== undefined) {
187
+ res.statusCode = rejection
188
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
189
+ return
190
+ }
191
+ } catch {
192
+ // A fence that cannot answer must not turn into an open door.
193
+ res.statusCode = 403
194
+ res.end('forbidden')
195
+ return
196
+ }
197
+ try {
198
+ await handler(req, res)
199
+ } catch (error) {
200
+ sendJson(res, error?.status ?? 500, { error: error?.status ? error.message : 'internal error' })
201
+ }
202
+ },
203
+ })
204
+ return typeof ctx.effect === 'function' ? ctx.effect(register, `dsh-session-colors: ${path}`) : register()
205
+ }
206
+
207
+ route('/marks', async (req, res) => {
208
+ if (req.method === 'GET') {
209
+ sendJson(res, 200, { marks: store.get(), storage: store.status })
210
+ return
211
+ }
212
+ if (req.method !== 'POST') {
213
+ res.statusCode = 405
214
+ res.setHeader('allow', 'GET, POST')
215
+ res.end()
216
+ return
217
+ }
218
+ if (!sameOriginRequest(req)) {
219
+ res.statusCode = 403
220
+ res.end('forbidden')
221
+ return
222
+ }
223
+ const body = await readJson(req)
224
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
225
+ if (sessionId === undefined || sessionId === '') {
226
+ sendJson(res, 400, { error: 'sessionId is required' })
227
+ return
228
+ }
229
+ const clearing = body.color === null || body.color === undefined
230
+ const color = clearing ? undefined : cleanMarks({ one: body.color }).one
231
+ if (!clearing && color === undefined) {
232
+ sendJson(res, 400, { error: 'color must be {h,s,v,a}' })
233
+ return
234
+ }
235
+ sendJson(res, 200, { marks: store.set(sessionId, color), storage: store.status })
236
+ })
237
+ } catch {
238
+ // No routes: the browser half keeps marks for the life of the page.
239
+ }
240
+ })
241
+ } catch {
242
+ // Never let this half break the Host boot.
243
+ }
244
+ }