@ours.network/notifications 0.3.1-nightly.3 → 0.3.1-nightly.4

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/README.md CHANGED
@@ -56,11 +56,11 @@ Responses are never cached. User and producer credentials have separate scopes.
56
56
  | GET `/api/v1/devices/status?id=...` | User | `{ "active": true }` |
57
57
  | GET `/api/v1/inbox?limit=50&before=...` | User | Items, shared unread count, revision, next cursor |
58
58
  | GET `/api/v1/summary` | User | Shared unread count, revision, per-chat source/identity/count, and `sections: {chats, tasks}` |
59
- | POST `/api/v1/read-target` | User | `{ "url": "/fleet/chats?chat=agent", "revision": OBSERVED_REVISION }`, reads only this chat up to the revision captured before its history load |
59
+ | POST `/api/v1/read-target` | User | `{ "url": "/fleet/chats/agents/agent", "revision": OBSERVED_REVISION }`, reads only this chat up to the revision captured before its history load |
60
60
  | POST `/api/v1/read` | User | `{ "ids": ["notification-id"] }`, idempotent shared read |
61
61
  | POST `/api/v1/delete` | User | `{ "ids": ["notification-id"] }` → `{ "removed": COUNT }`; persistently removes only this user’s entries and queued deliveries after the client has confirmed target absence |
62
- | POST `/api/v1/delete-target` | Producer | `{ "url": "/fleet/chats?chat=agent" }` → `{ "removed": COUNT }`; only its credential’s user/source and exact canonical conversation, including filter/anchor variants |
63
- | POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "url": "/fleet/chats?chat=agent&detail=1" }` |
62
+ | POST `/api/v1/delete-target` | Producer | `{ "url": "/fleet/chats/agents/agent" }` → `{ "removed": COUNT }`; only its credential’s user/source and exact canonical conversation, including filter/anchor variants |
63
+ | POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "url": "/fleet/chats/agents/agent" }` |
64
64
 
65
65
  Inbox admission and queued delivery are one fsynced atomic snapshot. The service
66
66
  deduplicates by user/source/event ID; replay must retain identical title/body/URL
@@ -171,3 +171,5 @@ an inbox item is deleted, preventing retry resurrection. Legacy inbox entries ar
171
171
  admitted as receipts on load. The existing inbox/job caps remain; a separate cap of
172
172
  100,000 receipts per account rejects new events explicitly rather than evicting
173
173
  dedupe evidence. Upgrades must preserve the service's state file and scoped credentials.
174
+
175
+ Conversation notification links use canonical contact (`/fleet/chats/contacts/:id`), agent (`/fleet/chats/agents/:id`), and task room (`/fleet/tasks/:taskId/room` or `/fleet/tasks/:taskId/rooms/:key`) paths. Deploy producers, this service, and the app together. Summary rows retain `source`/`chat` and include `kind` (`contact`, `agent`, or `room`) to distinguish colliding IDs. Filters and message anchors do not change target identity. Old query-based conversation URLs are intentionally unsupported; stored payload URLs are neither converted nor deleted by this change. Existing navigation-target security restrictions still apply.
@@ -1,9 +1,8 @@
1
- /** Stable conversation identity, independent of list filters and mobile detail state. */
2
- export declare function chatTarget(path: string): {
1
+ export interface ChatTarget {
2
+ kind: 'contact' | 'agent' | 'room';
3
3
  source: 'work' | 'messenger';
4
4
  chat: string;
5
- } | null;
6
- export declare function chatKey(value: {
7
- source: string;
8
- chat: string;
9
- }): string;
5
+ }
6
+ /** Stable conversation identity, independent of list filters and message anchors. */
7
+ export declare function chatTarget(path: string): ChatTarget | null;
8
+ export declare function chatKey(value: ChatTarget): string;
@@ -1,15 +1,34 @@
1
- /** Stable conversation identity, independent of list filters and mobile detail state. */
1
+ import { target as navigationTarget } from './validation.js';
2
+ /** Stable conversation identity, independent of list filters and message anchors. */
2
3
  export function chatTarget(path) {
3
- const url = new URL(path, 'https://ours.invalid');
4
- if (url.pathname === '/fleet/chats') {
5
- const chat = url.searchParams.get('chat');
6
- if (!chat || chat.length > 512)
4
+ try {
5
+ navigationTarget(path);
6
+ const url = new URL(path, 'https://ours.invalid');
7
+ if (['source', 'chat', 'task', 'detail'].some(key => url.searchParams.has(key)))
7
8
  return null;
8
- return { source: url.searchParams.get('source') === 'messenger' ? 'messenger' : 'work', chat };
9
+ // Match the original path: URL normalization must not turn malformed arity into a target.
10
+ const parts = path.split(/[?#]/, 1)[0].split('/').slice(1).map(decodeURIComponent);
11
+ if (parts.some(part => !part || /[\x00-\x1f\x7f]/.test(part) || part === '.' || part === '..'))
12
+ return null;
13
+ if (parts.length === 4 && parts[0] === 'fleet' && parts[1] === 'chats') {
14
+ if (parts[2] === 'contacts')
15
+ return { kind: 'contact', source: 'messenger', chat: parts[3] };
16
+ if (parts[2] === 'agents')
17
+ return { kind: 'agent', source: 'work', chat: parts[3] };
18
+ }
19
+ if (parts[0] === 'fleet' && parts[1] === 'tasks' && /^[A-Za-z0-9_-]{1,120}$/.test(parts[2] ?? '')) {
20
+ if (parts.length === 4 && parts[3] === 'room')
21
+ return { kind: 'room', source: 'work', chat: `room-${parts[2]}` };
22
+ if (parts.length === 5 && parts[3] === 'rooms' && /^[A-Za-z][A-Za-z0-9_-]*$/.test(parts[4])
23
+ && !['constructor', 'prototype', '__proto__'].includes(parts[4])) {
24
+ return { kind: 'room', source: 'work', chat: `room-${parts[2]}:${parts[4]}` };
25
+ }
26
+ }
9
27
  }
28
+ catch { /* Unsupported persisted input is unknown, never a missing target. */ }
10
29
  return null;
11
30
  }
12
31
  export function chatKey(value) {
13
- const chat = value.source === 'messenger' && /^[a-f0-9]{64}$/i.test(value.chat) ? value.chat.toUpperCase() : value.chat;
14
- return JSON.stringify([value.source, chat]);
32
+ const chat = value.kind === 'contact' && /^[a-f0-9]{64}$/i.test(value.chat) ? value.chat.toUpperCase() : value.chat;
33
+ return JSON.stringify([value.kind, value.source, chat]);
15
34
  }
package/dist/store.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ChatTarget } from './chat-target.js';
1
2
  import type { PushSubscription } from 'web-push';
2
3
  export interface Notification {
3
4
  id: string;
@@ -51,11 +52,9 @@ export declare class Store {
51
52
  summary(userId: string): {
52
53
  unread: number;
53
54
  revision: number;
54
- chats: {
55
- source: "work" | "messenger";
56
- chat: string;
55
+ chats: (ChatTarget & {
57
56
  count: number;
58
- }[];
57
+ })[];
59
58
  sections: {
60
59
  chats: number;
61
60
  tasks: number;
package/dist/store.js CHANGED
@@ -114,7 +114,7 @@ export class Store {
114
114
  if (/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(url.pathname))
115
115
  sections.tasks++;
116
116
  else if (targetForSection) {
117
- if (typeof n.taskId === 'string' || targetForSection.source === 'work' && /^room-[^:]+(?::[^:]+)?$/.test(targetForSection.chat))
117
+ if (typeof n.taskId === 'string' || targetForSection.kind === 'room')
118
118
  sections.tasks++;
119
119
  else if (targetForSection.source === 'messenger')
120
120
  sections.chats++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/notifications",
3
- "version": "0.3.1-nightly.3",
3
+ "version": "0.3.1-nightly.4",
4
4
  "description": "Authenticated web push delivery and shared notification inbox",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",