@gotcos/glasses-server 6.98.0 → 6.99.12
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/CHANGELOG.md +158 -0
- package/package.json +2 -1
- package/server/index.ts +147 -9
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +739 -0
- package/server/lib/drop-settings.ts +45 -0
- package/server/lib/drop-store.ts +428 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-data.ts +336 -26
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +9 -5
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +11 -0
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +260 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +34 -3
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +312 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +157 -0
- package/server/web-control/panes/cos-chat.js +973 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +257 -0
- package/server/web-control/panes/meetings.js +2264 -0
- package/server/web-control/panes/messages.js +711 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop it is OFF for every user by default (plan decision 4: on for Miles's Mac at the candidate, listed before any
|
|
3
|
+
* promotion). Two ways to turn it on, read at every request so no restart is needed for the file:
|
|
4
|
+
* - COS_DROP_IT in the server's environment wins when it is set (1, true, yes, on; anything else is off);
|
|
5
|
+
* - otherwise `data/drop-it-settings.json` `{ "enabled": true }`, written by PUT /api/drops/settings from Control.
|
|
6
|
+
* A missing, unreadable or malformed file reads as off.
|
|
7
|
+
*/
|
|
8
|
+
import { lstatSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { dirname } from 'node:path'
|
|
10
|
+
import { dataPath } from './data-dir.js'
|
|
11
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
12
|
+
import { securePrivateDirectory } from './secure-user-config.js'
|
|
13
|
+
|
|
14
|
+
export const DROP_IT_PROTOCOL = 1
|
|
15
|
+
export const DROP_IT_SETTINGS_FILE = () => dataPath('drop-it-settings.json')
|
|
16
|
+
|
|
17
|
+
export function dropItSetting(file = DROP_IT_SETTINGS_FILE()): boolean {
|
|
18
|
+
try {
|
|
19
|
+
const stat = lstatSync(file)
|
|
20
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 4096) return false
|
|
21
|
+
const value = JSON.parse(readFileSync(file, 'utf8')) as { enabled?: unknown }
|
|
22
|
+
return value?.enabled === true
|
|
23
|
+
} catch { return false }
|
|
24
|
+
}
|
|
25
|
+
export function dropItEnvValue(env: NodeJS.ProcessEnv = process.env): boolean | null {
|
|
26
|
+
const raw = env.COS_DROP_IT
|
|
27
|
+
if (raw === undefined || raw.trim() === '') return null
|
|
28
|
+
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())
|
|
29
|
+
}
|
|
30
|
+
export function dropItEnabled(env: NodeJS.ProcessEnv = process.env, file = DROP_IT_SETTINGS_FILE()): boolean {
|
|
31
|
+
return dropItEnvValue(env) ?? dropItSetting(file)
|
|
32
|
+
}
|
|
33
|
+
export function dropItSource(env: NodeJS.ProcessEnv = process.env): 'env' | 'settings' {
|
|
34
|
+
return dropItEnvValue(env) === null ? 'settings' : 'env'
|
|
35
|
+
}
|
|
36
|
+
export function writeDropItSetting(enabled: boolean, file = DROP_IT_SETTINGS_FILE()): void {
|
|
37
|
+
securePrivateDirectory(dirname(file))
|
|
38
|
+
durableAtomicWriteFileSync(file, JSON.stringify({ enabled }) + '\n', { mode: 0o600 })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The capability block for /api/health and /api/models. Codes only: no counts, no words. Additive. */
|
|
42
|
+
export function dropItCapability(env: NodeJS.ProcessEnv = process.env): { protocol: number; enabled: boolean; source: 'env' | 'settings'; p2Settings: number } {
|
|
43
|
+
// p2Settings (6.99.11): GET/PUT /api/drops/settings/p2 exists. Clients that do not see it hide the P2 controls.
|
|
44
|
+
return { protocol: DROP_IT_PROTOCOL, enabled: dropItEnabled(env), source: dropItSource(env), p2Settings: 1 }
|
|
45
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop it P1 (plan PLAN_drop_it_2026-11-04 sections 1, 3 and 6): the two files behind the pinned COS chat.
|
|
3
|
+
*
|
|
4
|
+
* - `data/drops/drops.json`: every drop. Its words, kind, where it went (links), a status timeline, an undo window,
|
|
5
|
+
* and the first answer to its dropId so a repeated POST returns that answer.
|
|
6
|
+
* - `data/inbox/feed.json`: the chat's messages, each pointing at a drop id. Persistent, no daily rollover, and
|
|
7
|
+
* separate from the Messages store (lib/conversation.ts): nothing here takes a global message number or an era,
|
|
8
|
+
* so a drop never adds a row to Messages and never moves the lens message counter (canary 11).
|
|
9
|
+
*
|
|
10
|
+
* Built like WorkIntakeStore: one writer lock per folder, a 0700 folder and 0600 files owned by this user, atomic
|
|
11
|
+
* writes, and a bad row set aside on its own instead of taking the store down. Nothing here logs words: the only
|
|
12
|
+
* console line is a count of quarantined rows.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { join, resolve } from 'node:path'
|
|
16
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
17
|
+
import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
|
|
18
|
+
|
|
19
|
+
export class DropStoreError extends Error {
|
|
20
|
+
constructor(readonly code: string, readonly status = 409) { super(code) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DROP_SOURCES = ['mac', 'web', 'g2', 'telegram'] as const
|
|
24
|
+
export type DropSource = typeof DROP_SOURCES[number]
|
|
25
|
+
export const DROP_DESTS = ['new_card', 'existing_card', 'thread', 'memory', 'session', 'note', 'reminder', 'ask'] as const
|
|
26
|
+
export type DropDest = typeof DROP_DESTS[number]
|
|
27
|
+
export const DROP_KINDS = ['idea', 'task', 'note', 'memory', 'file', 'reminder', 'ask'] as const
|
|
28
|
+
export type DropKind = typeof DROP_KINDS[number]
|
|
29
|
+
/** routing: saved, not yet filed. asking: one question is open. held: a session follow-up waiting for Send. */
|
|
30
|
+
export const DROP_STATES = ['routing', 'filed', 'asking', 'held', 'asked', 'undone'] as const
|
|
31
|
+
export type DropState = typeof DROP_STATES[number]
|
|
32
|
+
export const LINK_KINDS = ['card', 'session', 'handoff', 'thread', 'memory', 'meeting', 'file', 'reminder', 'ask'] as const
|
|
33
|
+
export type LinkKind = typeof LINK_KINDS[number]
|
|
34
|
+
|
|
35
|
+
export interface DropLink {
|
|
36
|
+
kind: LinkKind
|
|
37
|
+
id: string
|
|
38
|
+
label: string
|
|
39
|
+
/** card: the Work domain; thread: the source id written into the thread (Undo needs it). */
|
|
40
|
+
domain?: string
|
|
41
|
+
sourceId?: string
|
|
42
|
+
/** false when the destination answered with something that was already there: Undo then only unlinks it. */
|
|
43
|
+
created?: boolean
|
|
44
|
+
}
|
|
45
|
+
export interface DropChoice { id: string; dest: DropDest; label: string; detail: string; target?: { id: string; domain?: string; label: string } }
|
|
46
|
+
export interface DropRoute { by: 'rule' | 'jev' | 'user' | 'fallback'; p?: number; reason?: string; tokens?: number; provider?: string }
|
|
47
|
+
export interface DropFileRef { store: string; key: string; fileID: string; sha256: string; displayName: string }
|
|
48
|
+
export interface DropRemind { at: string | null; tz: string; firedAt: string | null; ackAt: string | null; snoozes: number }
|
|
49
|
+
export interface DropTimelineEntry { at: string; state: DropState; dest?: DropDest; note?: string }
|
|
50
|
+
export interface DropAsk { clientJobId: string; generation: number; answeredAt?: string }
|
|
51
|
+
|
|
52
|
+
export interface DropRecord {
|
|
53
|
+
id: string
|
|
54
|
+
text: string
|
|
55
|
+
source: DropSource
|
|
56
|
+
createdAt: string
|
|
57
|
+
updatedAt: string
|
|
58
|
+
state: DropState
|
|
59
|
+
dest: DropDest | null
|
|
60
|
+
kind: DropKind
|
|
61
|
+
links: DropLink[]
|
|
62
|
+
route: DropRoute | null
|
|
63
|
+
choices?: DropChoice[]
|
|
64
|
+
hold?: { sessionId: string; label: string; sentAt?: string }
|
|
65
|
+
remind?: DropRemind
|
|
66
|
+
file?: DropFileRef
|
|
67
|
+
ask?: DropAsk
|
|
68
|
+
hint?: { sessionId: string }
|
|
69
|
+
/** Undo and Move work until this instant (24 hours after filing). */
|
|
70
|
+
undoUntil: string | null
|
|
71
|
+
timeline: DropTimelineEntry[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const FEED_KINDS = ['drop', 'receipt', 'question', 'followup', 'ping', 'answer'] as const
|
|
75
|
+
export type FeedKind = typeof FEED_KINDS[number]
|
|
76
|
+
export interface FeedAction { id: string; label: string }
|
|
77
|
+
export interface FeedItem {
|
|
78
|
+
id: string
|
|
79
|
+
kind: FeedKind
|
|
80
|
+
/** The drop id, or the ping's own id. */
|
|
81
|
+
threadId: string
|
|
82
|
+
createdAt: string
|
|
83
|
+
readAt: string | null
|
|
84
|
+
source: DropSource | 'cos'
|
|
85
|
+
title?: string
|
|
86
|
+
line: string
|
|
87
|
+
links: DropLink[]
|
|
88
|
+
actions: FeedAction[]
|
|
89
|
+
/** live: its buttons work; done: answered or superseded (buttons gone). */
|
|
90
|
+
state: 'live' | 'done'
|
|
91
|
+
undoUntil?: string | null
|
|
92
|
+
/** Exactly-once key: a second insert with the same key returns the first item. */
|
|
93
|
+
key: string
|
|
94
|
+
/**
|
|
95
|
+
* 6.99.10 (plan section 12): this item waits on an answer from you (a ping or follow-up that needs a decision). It
|
|
96
|
+
* counts toward `waiting` on GET /api/inbox/summary while it is live, and stops counting when it is answered, done or
|
|
97
|
+
* dismissed. Kept only when it is a real boolean; an older row without it is valid and does not wait.
|
|
98
|
+
*/
|
|
99
|
+
needsReply?: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const DROP_LIMITS = {
|
|
103
|
+
text: 2_000, label: 200, line: 2_000, title: 200, links: 12, choices: 3, timeline: 40,
|
|
104
|
+
drops: 20_000, feed: 20_000, fileBytes: 32_000_000, quarantineKept: 500,
|
|
105
|
+
/** Retention (plan section 6): drop records and links a year; undone drops and chat receipts 30 days. */
|
|
106
|
+
keepDropDays: 365, keepUndoneDays: 30, keepReceiptDays: 30,
|
|
107
|
+
undoMs: 24 * 60 * 60_000,
|
|
108
|
+
} as const
|
|
109
|
+
|
|
110
|
+
export const DROP_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
111
|
+
export const FEED_ID = /^fi_[a-f0-9]{32}$/
|
|
112
|
+
const LINK_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/
|
|
113
|
+
const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
|
|
114
|
+
|
|
115
|
+
const str = (v: unknown, max: number, allowNewlines = false): string | null =>
|
|
116
|
+
typeof v === 'string' && v.length <= max && (allowNewlines ? !CONTROL.test(v) : !/[\x00-\x1f\x7f]/.test(v)) ? v : null
|
|
117
|
+
const iso = (v: unknown): string | null => typeof v === 'string' && v.length <= 40 && !Number.isNaN(Date.parse(v)) ? v : null
|
|
118
|
+
const isoOrNull = (v: unknown): string | null | undefined => v === null ? null : iso(v) ?? undefined
|
|
119
|
+
const oneOf = <T extends string>(list: readonly T[], v: unknown): T | null => typeof v === 'string' && (list as readonly string[]).includes(v) ? v as T : null
|
|
120
|
+
const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
121
|
+
|
|
122
|
+
export function parseLink(raw: unknown): DropLink {
|
|
123
|
+
if (!record(raw)) throw new DropStoreError('invalid_link', 400)
|
|
124
|
+
const kind = oneOf(LINK_KINDS, raw.kind), id = str(raw.id, 200), label = str(raw.label, DROP_LIMITS.label)
|
|
125
|
+
if (!kind || !id || !LINK_ID.test(id) || label === null) throw new DropStoreError('invalid_link', 400)
|
|
126
|
+
const link: DropLink = { kind, id, label }
|
|
127
|
+
if (raw.domain !== undefined) { const d = str(raw.domain, 64); if (!d) throw new DropStoreError('invalid_link', 400); link.domain = d }
|
|
128
|
+
if (raw.sourceId !== undefined) { const s = str(raw.sourceId, 200); if (!s || !LINK_ID.test(s)) throw new DropStoreError('invalid_link', 400); link.sourceId = s }
|
|
129
|
+
if (raw.created !== undefined) { if (typeof raw.created !== 'boolean') throw new DropStoreError('invalid_link', 400); link.created = raw.created }
|
|
130
|
+
return link
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseChoice(raw: unknown): DropChoice {
|
|
134
|
+
if (!record(raw)) throw new DropStoreError('invalid_choice', 400)
|
|
135
|
+
const id = str(raw.id, 16), dest = oneOf(DROP_DESTS, raw.dest), label = str(raw.label, DROP_LIMITS.label), detail = str(raw.detail, DROP_LIMITS.line)
|
|
136
|
+
if (!id || !/^[a-z0-9_]{1,16}$/.test(id) || !dest || label === null || detail === null) throw new DropStoreError('invalid_choice', 400)
|
|
137
|
+
const choice: DropChoice = { id, dest, label, detail }
|
|
138
|
+
if (raw.target !== undefined) {
|
|
139
|
+
const t = raw.target
|
|
140
|
+
if (!record(t)) throw new DropStoreError('invalid_choice', 400)
|
|
141
|
+
const tid = str(t.id, 200), tlabel = str(t.label, DROP_LIMITS.label)
|
|
142
|
+
if (!tid || !LINK_ID.test(tid) || tlabel === null) throw new DropStoreError('invalid_choice', 400)
|
|
143
|
+
choice.target = { id: tid, label: tlabel, ...(typeof t.domain === 'string' && t.domain.length <= 64 ? { domain: t.domain } : {}) }
|
|
144
|
+
}
|
|
145
|
+
return choice
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Validate one drop record from the journal. Unknown fields are dropped, never trusted. */
|
|
149
|
+
export function parseDropRecord(raw: unknown): DropRecord {
|
|
150
|
+
if (!record(raw)) throw new DropStoreError('invalid_drop', 400)
|
|
151
|
+
const id = typeof raw.id === 'string' && DROP_ID.test(raw.id) ? raw.id : null
|
|
152
|
+
const text = str(raw.text, DROP_LIMITS.text, true)
|
|
153
|
+
const source = oneOf(DROP_SOURCES, raw.source), state = oneOf(DROP_STATES, raw.state), kind = oneOf(DROP_KINDS, raw.kind)
|
|
154
|
+
const createdAt = iso(raw.createdAt), updatedAt = iso(raw.updatedAt)
|
|
155
|
+
const dest = raw.dest === null ? null : oneOf(DROP_DESTS, raw.dest)
|
|
156
|
+
if (!id || !text || !text.trim() || !source || !state || !kind || !createdAt || !updatedAt || (raw.dest !== null && !dest)) throw new DropStoreError('invalid_drop', 400)
|
|
157
|
+
if (!Array.isArray(raw.links) || raw.links.length > DROP_LIMITS.links || !Array.isArray(raw.timeline)) throw new DropStoreError('invalid_drop', 400)
|
|
158
|
+
const undoUntil = raw.undoUntil === null ? null : iso(raw.undoUntil)
|
|
159
|
+
if (raw.undoUntil !== null && !undoUntil) throw new DropStoreError('invalid_drop', 400)
|
|
160
|
+
const drop: DropRecord = { id, text, source, createdAt, updatedAt, state, dest, kind, links: raw.links.map(parseLink),
|
|
161
|
+
route: null, undoUntil, timeline: [] }
|
|
162
|
+
if (record(raw.route)) {
|
|
163
|
+
const by = oneOf(['rule', 'jev', 'user', 'fallback'] as const, raw.route.by)
|
|
164
|
+
if (!by) throw new DropStoreError('invalid_drop', 400)
|
|
165
|
+
drop.route = { by }
|
|
166
|
+
if (typeof raw.route.p === 'number' && raw.route.p >= 0 && raw.route.p <= 1) drop.route.p = raw.route.p
|
|
167
|
+
if (typeof raw.route.reason === 'string' && /^[a-z_]{1,64}$/.test(raw.route.reason)) drop.route.reason = raw.route.reason
|
|
168
|
+
if (Number.isSafeInteger(raw.route.tokens) && (raw.route.tokens as number) >= 0) drop.route.tokens = raw.route.tokens as number
|
|
169
|
+
if (raw.route.provider === 'jev' || raw.route.provider === 'subq') drop.route.provider = raw.route.provider
|
|
170
|
+
}
|
|
171
|
+
if (raw.choices !== undefined) {
|
|
172
|
+
if (!Array.isArray(raw.choices) || raw.choices.length > DROP_LIMITS.choices) throw new DropStoreError('invalid_drop', 400)
|
|
173
|
+
drop.choices = raw.choices.map(parseChoice)
|
|
174
|
+
}
|
|
175
|
+
if (raw.hold !== undefined) {
|
|
176
|
+
const h = raw.hold
|
|
177
|
+
const sessionId = record(h) ? str(h.sessionId, 200) : null, label = record(h) ? str(h.label, DROP_LIMITS.label) : null
|
|
178
|
+
if (!sessionId || !LINK_ID.test(sessionId) || label === null) throw new DropStoreError('invalid_drop', 400)
|
|
179
|
+
drop.hold = { sessionId, label, ...(record(h) && iso(h.sentAt) ? { sentAt: iso(h.sentAt)! } : {}) }
|
|
180
|
+
}
|
|
181
|
+
if (raw.remind !== undefined) {
|
|
182
|
+
const r = raw.remind
|
|
183
|
+
if (!record(r)) throw new DropStoreError('invalid_drop', 400)
|
|
184
|
+
const at = isoOrNull(r.at), firedAt = isoOrNull(r.firedAt), ackAt = isoOrNull(r.ackAt), tz = str(r.tz, 64)
|
|
185
|
+
if (at === undefined || firedAt === undefined || ackAt === undefined || !tz || !Number.isSafeInteger(r.snoozes) || (r.snoozes as number) < 0) throw new DropStoreError('invalid_drop', 400)
|
|
186
|
+
drop.remind = { at, tz, firedAt, ackAt, snoozes: r.snoozes as number }
|
|
187
|
+
}
|
|
188
|
+
if (raw.file !== undefined) {
|
|
189
|
+
const f = raw.file
|
|
190
|
+
if (!record(f)) throw new DropStoreError('invalid_drop', 400)
|
|
191
|
+
const store = str(f.store, 64), key = str(f.key, 512), fileID = str(f.fileID, 200), sha256 = str(f.sha256, 64), displayName = str(f.displayName, 255)
|
|
192
|
+
if (!store || !key || !fileID || !sha256 || !/^[a-f0-9]{64}$/.test(sha256) || !displayName) throw new DropStoreError('invalid_drop', 400)
|
|
193
|
+
drop.file = { store, key, fileID, sha256, displayName }
|
|
194
|
+
}
|
|
195
|
+
if (raw.ask !== undefined) {
|
|
196
|
+
const a = raw.ask
|
|
197
|
+
if (!record(a) || typeof a.clientJobId !== 'string' || !DROP_ID.test(a.clientJobId.toLowerCase()) || !Number.isSafeInteger(a.generation) || (a.generation as number) < 1) throw new DropStoreError('invalid_drop', 400)
|
|
198
|
+
drop.ask = { clientJobId: a.clientJobId.toLowerCase(), generation: a.generation as number, ...(iso(a.answeredAt) ? { answeredAt: iso(a.answeredAt)! } : {}) }
|
|
199
|
+
}
|
|
200
|
+
if (raw.hint !== undefined) {
|
|
201
|
+
const sessionId = record(raw.hint) ? str(raw.hint.sessionId, 200) : null
|
|
202
|
+
if (!sessionId || !LINK_ID.test(sessionId)) throw new DropStoreError('invalid_drop', 400)
|
|
203
|
+
drop.hint = { sessionId }
|
|
204
|
+
}
|
|
205
|
+
for (const entry of raw.timeline.slice(-DROP_LIMITS.timeline)) {
|
|
206
|
+
if (!record(entry)) throw new DropStoreError('invalid_drop', 400)
|
|
207
|
+
const at = iso(entry.at), st = oneOf(DROP_STATES, entry.state)
|
|
208
|
+
if (!at || !st) throw new DropStoreError('invalid_drop', 400)
|
|
209
|
+
const row: DropTimelineEntry = { at, state: st }
|
|
210
|
+
const d = oneOf(DROP_DESTS, entry.dest); if (d) row.dest = d
|
|
211
|
+
if (typeof entry.note === 'string' && /^[a-z_]{1,64}$/.test(entry.note)) row.note = entry.note
|
|
212
|
+
drop.timeline.push(row)
|
|
213
|
+
}
|
|
214
|
+
return drop
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function parseFeedItem(raw: unknown): FeedItem {
|
|
218
|
+
if (!record(raw)) throw new DropStoreError('invalid_feed_item', 400)
|
|
219
|
+
const id = typeof raw.id === 'string' && FEED_ID.test(raw.id) ? raw.id : null
|
|
220
|
+
const kind = oneOf(FEED_KINDS, raw.kind), threadId = str(raw.threadId, 200), createdAt = iso(raw.createdAt)
|
|
221
|
+
const readAt = isoOrNull(raw.readAt)
|
|
222
|
+
const source = raw.source === 'cos' ? 'cos' as const : oneOf(DROP_SOURCES, raw.source)
|
|
223
|
+
const line = str(raw.line, DROP_LIMITS.line, true), key = str(raw.key, 300)
|
|
224
|
+
const state = raw.state === 'live' || raw.state === 'done' ? raw.state : null
|
|
225
|
+
if (!id || !kind || !threadId || !LINK_ID.test(threadId) || !createdAt || readAt === undefined || !source || line === null || !key || !state) throw new DropStoreError('invalid_feed_item', 400)
|
|
226
|
+
if (!Array.isArray(raw.links) || raw.links.length > DROP_LIMITS.links || !Array.isArray(raw.actions) || raw.actions.length > 6) throw new DropStoreError('invalid_feed_item', 400)
|
|
227
|
+
const actions = raw.actions.map(a => {
|
|
228
|
+
const aid = record(a) ? str(a.id, 40) : null, label = record(a) ? str(a.label, 80) : null
|
|
229
|
+
if (!aid || !/^[a-z0-9_:-]{1,40}$/.test(aid) || !label) throw new DropStoreError('invalid_feed_item', 400)
|
|
230
|
+
return { id: aid, label }
|
|
231
|
+
})
|
|
232
|
+
const item: FeedItem = { id, kind, threadId, createdAt, readAt, source, line, links: raw.links.map(parseLink), actions, state, key }
|
|
233
|
+
if (raw.title !== undefined) { const t = str(raw.title, DROP_LIMITS.title, true); if (t === null) throw new DropStoreError('invalid_feed_item', 400); item.title = t }
|
|
234
|
+
if (raw.undoUntil !== undefined) { const u = isoOrNull(raw.undoUntil); if (u === undefined) throw new DropStoreError('invalid_feed_item', 400); item.undoUntil = u }
|
|
235
|
+
if (typeof raw.needsReply === 'boolean') item.needsReply = raw.needsReply
|
|
236
|
+
return item
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A 0700 folder owned by this user, then the writer lock, then the journal (or nothing yet). */
|
|
240
|
+
function openJournal(root: string, file: string, label: string): { path: string; lock: ServerInstanceLock; journal: Record<string, unknown> | null } {
|
|
241
|
+
const dir = resolve(root)
|
|
242
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
243
|
+
const stat = lstatSync(dir)
|
|
244
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new DropStoreError(`${label}_store_unsafe`, 503)
|
|
245
|
+
const path = join(dir, file)
|
|
246
|
+
let lock: ServerInstanceLock
|
|
247
|
+
try { lock = acquireServerInstanceLock({ lockDir: join(dir, 'writer.lock') }) }
|
|
248
|
+
catch { throw new DropStoreError(`${label}_store_locked`, 503) }
|
|
249
|
+
if (!existsSync(path)) return { path, lock, journal: null }
|
|
250
|
+
try {
|
|
251
|
+
const st = lstatSync(path)
|
|
252
|
+
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid?.() || (st.mode & 0o077) || st.size > DROP_LIMITS.fileBytes) throw new Error('unsafe')
|
|
253
|
+
const journal = JSON.parse(readFileSync(path, 'utf8'))
|
|
254
|
+
if (!record(journal) || journal.version !== 1) throw new Error('version')
|
|
255
|
+
return { path, lock, journal }
|
|
256
|
+
} catch {
|
|
257
|
+
lock.release()
|
|
258
|
+
throw new DropStoreError(`${label}_store_recovery_required`, 503)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function readRows<T>(journal: Record<string, unknown> | null, key: string, parse: (raw: unknown) => T, id: (row: T) => string, label: string): { rows: T[]; quarantined: unknown[] } {
|
|
263
|
+
const rows: T[] = [], quarantined: unknown[] = []
|
|
264
|
+
if (!journal) return { rows, quarantined }
|
|
265
|
+
const list = journal[key]
|
|
266
|
+
if (!Array.isArray(list)) throw new DropStoreError(`${label}_store_recovery_required`, 503)
|
|
267
|
+
const seen = new Set<string>()
|
|
268
|
+
for (const raw of list) {
|
|
269
|
+
try {
|
|
270
|
+
const row = parse(raw)
|
|
271
|
+
if (seen.has(id(row))) throw new Error('duplicate')
|
|
272
|
+
seen.add(id(row)); rows.push(row)
|
|
273
|
+
} catch { quarantined.push(raw) } // one bad row never takes the store down
|
|
274
|
+
}
|
|
275
|
+
if (Array.isArray(journal.quarantined)) quarantined.push(...journal.quarantined)
|
|
276
|
+
return { rows, quarantined: quarantined.slice(-DROP_LIMITS.quarantineKept) }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export class DropStore {
|
|
280
|
+
private rows: DropRecord[] = []
|
|
281
|
+
private quarantined: unknown[] = []
|
|
282
|
+
private readonly path: string
|
|
283
|
+
private readonly lock: ServerInstanceLock
|
|
284
|
+
private closed = false
|
|
285
|
+
constructor(root: string, private readonly now: () => Date = () => new Date(), private readonly maxBytes: number = DROP_LIMITS.fileBytes) {
|
|
286
|
+
const opened = openJournal(root, 'drops.json', 'drop')
|
|
287
|
+
this.path = opened.path; this.lock = opened.lock
|
|
288
|
+
try {
|
|
289
|
+
const read = readRows(opened.journal, 'drops', parseDropRecord, r => r.id, 'drop')
|
|
290
|
+
this.rows = read.rows; this.quarantined = read.quarantined
|
|
291
|
+
} catch (e) { this.close(); throw e }
|
|
292
|
+
if (this.quarantined.length) console.warn(`[drops] ${this.quarantined.length} unreadable drop record(s) set aside`)
|
|
293
|
+
}
|
|
294
|
+
list(): DropRecord[] { return structuredClone(this.rows) }
|
|
295
|
+
get(id: string): DropRecord | undefined { const row = this.rows.find(r => r.id === id); return row ? structuredClone(row) : undefined }
|
|
296
|
+
/** Copies of the matching rows only (a poll that needs a few rows does not copy them all). */
|
|
297
|
+
select(match: (row: Readonly<DropRecord>) => boolean): DropRecord[] { return this.rows.filter(match).map(r => structuredClone(r)) }
|
|
298
|
+
ids(): Set<string> { return new Set(this.rows.map(r => r.id)) }
|
|
299
|
+
quarantineCount(): number { return this.quarantined.length }
|
|
300
|
+
|
|
301
|
+
/** Insert a new drop. A repeated id returns the stored one unchanged (idempotent on the drop id). */
|
|
302
|
+
insert(drop: DropRecord): { drop: DropRecord; inserted: boolean } {
|
|
303
|
+
const existing = this.get(drop.id)
|
|
304
|
+
if (existing) return { drop: existing, inserted: false }
|
|
305
|
+
const parsed = parseDropRecord(drop)
|
|
306
|
+
this.write([parsed, ...this.rows])
|
|
307
|
+
return { drop: structuredClone(parsed), inserted: true }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Replace one drop with `fn(current)`. Validated before it is written. */
|
|
311
|
+
update(id: string, fn: (current: DropRecord) => DropRecord): DropRecord {
|
|
312
|
+
const index = this.rows.findIndex(r => r.id === id)
|
|
313
|
+
if (index < 0) throw new DropStoreError('drop_not_found', 404)
|
|
314
|
+
const next = parseDropRecord({ ...fn(structuredClone(this.rows[index])), updatedAt: this.now().toISOString() })
|
|
315
|
+
if (next.id !== id) throw new DropStoreError('invalid_drop', 400)
|
|
316
|
+
const rows = [...this.rows]; rows[index] = next
|
|
317
|
+
this.write(rows)
|
|
318
|
+
return structuredClone(next)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private write(rows: DropRecord[]): void {
|
|
322
|
+
if (this.closed) throw new DropStoreError('drop_store_closed', 503)
|
|
323
|
+
const now = this.now().getTime()
|
|
324
|
+
const kept = rows.filter(r => {
|
|
325
|
+
const age = now - Date.parse(r.updatedAt)
|
|
326
|
+
return r.state === 'undone' ? age <= DROP_LIMITS.keepUndoneDays * 86_400_000 : age <= DROP_LIMITS.keepDropDays * 86_400_000
|
|
327
|
+
})
|
|
328
|
+
if (kept.length > DROP_LIMITS.drops) throw new DropStoreError('drop_store_full', 503)
|
|
329
|
+
// Never write a file the reader would refuse (it reads at most maxBytes): undone rows go first, oldest first.
|
|
330
|
+
let body = JSON.stringify({ version: 1, drops: kept, quarantined: this.quarantined }) + '\n'
|
|
331
|
+
while (Buffer.byteLength(body) > this.maxBytes) {
|
|
332
|
+
let oldest = -1
|
|
333
|
+
kept.forEach((r, i) => { if (r.state === 'undone' && (oldest < 0 || r.updatedAt < kept[oldest].updatedAt)) oldest = i })
|
|
334
|
+
if (oldest < 0) throw new DropStoreError('drop_store_full', 503)
|
|
335
|
+
kept.splice(oldest, 1)
|
|
336
|
+
body = JSON.stringify({ version: 1, drops: kept, quarantined: this.quarantined }) + '\n'
|
|
337
|
+
}
|
|
338
|
+
durableAtomicWriteFileSync(this.path, body)
|
|
339
|
+
this.rows = kept
|
|
340
|
+
}
|
|
341
|
+
close(): void { if (this.closed) return; this.closed = true; this.lock.release() }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export class InboxFeedStore {
|
|
345
|
+
private items: FeedItem[] = []
|
|
346
|
+
private quarantined: unknown[] = []
|
|
347
|
+
private versionNo = 0
|
|
348
|
+
private readonly path: string
|
|
349
|
+
private readonly lock: ServerInstanceLock
|
|
350
|
+
private closed = false
|
|
351
|
+
constructor(root: string, private readonly now: () => Date = () => new Date(), private readonly maxBytes: number = DROP_LIMITS.fileBytes) {
|
|
352
|
+
const opened = openJournal(root, 'feed.json', 'inbox')
|
|
353
|
+
this.path = opened.path; this.lock = opened.lock
|
|
354
|
+
try {
|
|
355
|
+
const read = readRows(opened.journal, 'items', parseFeedItem, i => i.id, 'inbox')
|
|
356
|
+
this.items = read.rows; this.quarantined = read.quarantined
|
|
357
|
+
const v = opened.journal?.feedVersion
|
|
358
|
+
this.versionNo = Number.isSafeInteger(v) && (v as number) >= 0 ? v as number : this.items.length
|
|
359
|
+
} catch (e) { this.close(); throw e }
|
|
360
|
+
if (this.quarantined.length) console.warn(`[inbox] ${this.quarantined.length} unreadable feed item(s) set aside`)
|
|
361
|
+
}
|
|
362
|
+
/** Oldest first, as a chat reads. */
|
|
363
|
+
list(): FeedItem[] { return structuredClone(this.items) }
|
|
364
|
+
version(): number { return this.versionNo }
|
|
365
|
+
byKey(key: string): FeedItem | undefined { const item = this.items.find(i => i.key === key); return item ? structuredClone(item) : undefined }
|
|
366
|
+
get(id: string): FeedItem | undefined { const item = this.items.find(i => i.id === id); return item ? structuredClone(item) : undefined }
|
|
367
|
+
quarantineCount(): number { return this.quarantined.length }
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Insert under the exactly-once key (plan section 4). The key is checked and the item written in one synchronous
|
|
371
|
+
* step, so two callers can never both insert it. A repeat returns the first item.
|
|
372
|
+
*/
|
|
373
|
+
insert(items: FeedItem[]): FeedItem[] {
|
|
374
|
+
const out: FeedItem[] = [], add: FeedItem[] = []
|
|
375
|
+
for (const raw of items) {
|
|
376
|
+
const existing = this.items.find(i => i.key === raw.key) ?? add.find(i => i.key === raw.key)
|
|
377
|
+
if (existing) { out.push(structuredClone(existing)); continue }
|
|
378
|
+
const item = parseFeedItem(raw)
|
|
379
|
+
add.push(item); out.push(structuredClone(item))
|
|
380
|
+
}
|
|
381
|
+
if (add.length) this.write([...this.items, ...add], add.length)
|
|
382
|
+
return out
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Remove the items `drop` answers true for (the drops store let their drop go). Writes only when one goes. */
|
|
386
|
+
purge(drop: (item: Readonly<FeedItem>) => boolean): number {
|
|
387
|
+
const kept = this.items.filter(i => !drop(i))
|
|
388
|
+
const removed = this.items.length - kept.length
|
|
389
|
+
if (removed) this.write(kept, 1)
|
|
390
|
+
return removed
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
update(ids: ReadonlySet<string>, fn: (item: FeedItem) => FeedItem): FeedItem[] {
|
|
394
|
+
let changed = 0
|
|
395
|
+
const next = this.items.map(item => {
|
|
396
|
+
if (!ids.has(item.id)) return item
|
|
397
|
+
const updated = parseFeedItem(fn(structuredClone(item)))
|
|
398
|
+
if (updated.id !== item.id || updated.key !== item.key) throw new DropStoreError('invalid_feed_item', 400)
|
|
399
|
+
changed++
|
|
400
|
+
return updated
|
|
401
|
+
})
|
|
402
|
+
if (changed) this.write(next, 1)
|
|
403
|
+
return this.items.filter(i => ids.has(i.id)).map(i => structuredClone(i))
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private write(items: FeedItem[], bump: number): void {
|
|
407
|
+
if (this.closed) throw new DropStoreError('inbox_store_closed', 503)
|
|
408
|
+
const now = this.now().getTime()
|
|
409
|
+
const kept = items.filter(i => {
|
|
410
|
+
const age = now - Date.parse(i.createdAt)
|
|
411
|
+
return i.kind === 'drop' ? age <= DROP_LIMITS.keepDropDays * 86_400_000 : age <= DROP_LIMITS.keepReceiptDays * 86_400_000
|
|
412
|
+
})
|
|
413
|
+
if (kept.length > DROP_LIMITS.feed) throw new DropStoreError('inbox_store_full', 503)
|
|
414
|
+
const version = this.versionNo + bump
|
|
415
|
+
// Never write a file the reader would refuse: the oldest COS lines (receipts, answers, pings) go first.
|
|
416
|
+
const serialize = () => JSON.stringify({ version: 1, feedVersion: version, items: kept, quarantined: this.quarantined }) + '\n'
|
|
417
|
+
let body = serialize()
|
|
418
|
+
while (Buffer.byteLength(body) > this.maxBytes) {
|
|
419
|
+
const oldest = kept.findIndex(i => i.kind !== 'drop')
|
|
420
|
+
if (oldest < 0) throw new DropStoreError('inbox_store_full', 503)
|
|
421
|
+
kept.splice(oldest, 1)
|
|
422
|
+
body = serialize()
|
|
423
|
+
}
|
|
424
|
+
durableAtomicWriteFileSync(this.path, body)
|
|
425
|
+
this.items = kept; this.versionNo = version
|
|
426
|
+
}
|
|
427
|
+
close(): void { if (this.closed) return; this.closed = true; this.lock.release() }
|
|
428
|
+
}
|