@mushi-mushi/core 1.29.0 → 1.31.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.
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ // src/reporter-channels.ts
4
+ function reporterChannels(client) {
5
+ const r = client.reporterRequest;
6
+ const id = (reportId) => encodeURIComponent(reportId);
7
+ return {
8
+ getUpdates: (token, since) => r("GET", `/v1/reporter/updates${since ? `?since=${encodeURIComponent(since)}` : ""}`, token),
9
+ getReport: (reportId, token) => r("GET", `/v1/reporter/reports/${id(reportId)}`, token),
10
+ markReportRead: (reportId, token) => r("POST", `/v1/reporter/reports/${id(reportId)}/read`, token, {}),
11
+ markAllRead: (token) => r("POST", "/v1/reporter/notifications/read-all", token, {}),
12
+ getPrefs: (token) => r("GET", "/v1/reporter/notification-prefs", token),
13
+ setPrefs: (token, update) => r("PUT", "/v1/reporter/notification-prefs", token, update),
14
+ subscribePush: (token, subscription) => r("POST", "/v1/reporter/push-subscriptions", token, subscription),
15
+ unsubscribePush: (token, endpoint) => r("DELETE", "/v1/reporter/push-subscriptions", token, { endpoint })
16
+ };
17
+ }
18
+ async function subscribeBrowserPush(channels, reporterToken, serviceWorkerPath, vapidPublicKey) {
19
+ try {
20
+ if (!serviceWorkerPath || typeof Notification === "undefined" || !navigator.serviceWorker) {
21
+ return { ok: false, reason: "unsupported" };
22
+ }
23
+ if (!vapidPublicKey) return { ok: false, reason: "not_available" };
24
+ if (await Notification.requestPermission() !== "granted") return { ok: false, reason: "denied" };
25
+ const reg = await navigator.serviceWorker.register(serviceWorkerPath);
26
+ const b64 = vapidPublicKey.replace(/-/g, "+").replace(/_/g, "/");
27
+ const raw = atob(b64 + "=".repeat((4 - b64.length % 4) % 4));
28
+ const sub = await reg.pushManager.subscribe({
29
+ userVisibleOnly: true,
30
+ applicationServerKey: Uint8Array.from(raw, (ch) => ch.charCodeAt(0))
31
+ });
32
+ const res = await channels.subscribePush(reporterToken, sub.toJSON());
33
+ return res.ok ? { ok: true } : { ok: false, reason: res.error?.code ?? "error" };
34
+ } catch (err) {
35
+ return { ok: false, reason: err instanceof Error ? err.name || "error" : "error" };
36
+ }
37
+ }
38
+
39
+ exports.reporterChannels = reporterChannels;
40
+ exports.subscribeBrowserPush = subscribeBrowserPush;
41
+ //# sourceMappingURL=reporter-channels.cjs.map
42
+ //# sourceMappingURL=reporter-channels.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reporter-channels.ts"],"names":[],"mappings":"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","file":"reporter-channels.cjs","sourcesContent":["// SPDX-License-Identifier: MIT\n// Copyright (c) 2024–2026 Kenji Sakuramoto (kensaurus) — Mushi Mushi\n/**\n * FILE: reporter-channels.ts\n * PURPOSE: Typed calls for the reporter loop v2 routes (Plan 018 §4, §6.2):\n * the badge / toast feed, mark-read, the report timeline, and the\n * reporter's own email / push opt-in.\n *\n * USAGE:\n * import { reporterChannels } from '@mushi-mushi/core/reporter-channels'\n * const rc = reporterChannels(apiClient)\n * const res = await rc.getUpdates(reporterToken)\n *\n * Shipped as a subpath so the main `@mushi-mushi/core` entry stays inside\n * its size budget. Every call goes through `apiClient.reporterRequest`, which\n * signs the reporter digest headers, times out, retries once and never\n * rejects: failures resolve as `{ ok: false }`.\n *\n * Email and push are opt-in. The server refuses a channel the project has\n * not turned on, or cannot send (`EMAIL_NOT_AVAILABLE` / `PUSH_NOT_AVAILABLE`\n * with `reason: 'not_configured' | 'project_disabled'`), so never show an\n * opt-in control unless `GET /v1/sdk/config` → `reporter.emailEnabled` /\n * `reporter.pushEnabled` is true.\n */\n\nimport type { MushiApiClient, MushiApiResponse } from './types';\n\n/** `GET /v1/reporter/updates` — the unread badge and the next-visit toast. */\nexport interface MushiReporterUpdates {\n unread_total: number;\n /** Up to 3 newest unread events, `report_id` is always the reporter's own report. */\n latest: Array<{ report_id: string; kind: string | null; preview: string; at: string }>;\n server_time: string;\n}\n\nexport type MushiReporterChannelUnavailable = 'not_configured' | 'project_disabled';\n\n/** `GET|PUT /v1/reporter/notification-prefs`. The address is always masked. */\nexport interface MushiReporterNotificationPrefs {\n email: string | null;\n email_verified: boolean;\n /** An address was given; the confirmation link has not been clicked yet. */\n email_pending: boolean;\n unsubscribed: boolean;\n channels: { in_app: boolean; email: boolean; push: boolean };\n push_subscriptions: number;\n available: { email: boolean; push: boolean };\n unavailable_reason: { email: MushiReporterChannelUnavailable | null; push: MushiReporterChannelUnavailable | null };\n /** PUT only: a confirmation email went out. */\n verification_sent?: boolean;\n}\n\n/** `email`: a new address starts double opt-in; `null` forgets the address. */\nexport interface MushiReporterPrefsUpdate {\n email?: string | null;\n channels?: { email?: boolean; push?: boolean };\n}\n\n/** `PushSubscription.toJSON()` shape. */\nexport interface MushiPushSubscriptionJSON {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n}\n\n/** One entry of `GET /v1/reporter/reports/:id` → `timeline`. */\nexport interface MushiReporterTimelineItem {\n kind: string;\n at: string;\n /** English text; pipeline kinds can be re-rendered per locale unless `custom`. */\n text: string;\n /** Developer- or project-written text: show `text` as is. */\n custom?: boolean;\n body?: string;\n version?: string;\n closed_reason?: string;\n comment_id?: number | string;\n}\n\nexport interface MushiReporterReportDetail {\n report: Record<string, unknown> & { id: string; status: string };\n timeline: MushiReporterTimelineItem[];\n can_verify: boolean;\n}\n\nexport interface MushiReporterChannels {\n getUpdates(reporterToken: string, since?: string): Promise<MushiApiResponse<MushiReporterUpdates>>;\n getReport(reportId: string, reporterToken: string): Promise<MushiApiResponse<MushiReporterReportDetail>>;\n /** Mark one report's updates read; resolves the new unread total. */\n markReportRead(reportId: string, reporterToken: string): Promise<MushiApiResponse<{ marked_read: number; unread_total: number | null }>>;\n markAllRead(reporterToken: string): Promise<MushiApiResponse<{ marked_read: number; unread_total: number }>>;\n getPrefs(reporterToken: string): Promise<MushiApiResponse<MushiReporterNotificationPrefs>>;\n setPrefs(reporterToken: string, update: MushiReporterPrefsUpdate): Promise<MushiApiResponse<MushiReporterNotificationPrefs>>;\n subscribePush(reporterToken: string, subscription: MushiPushSubscriptionJSON): Promise<MushiApiResponse<{ subscribed: boolean }>>;\n unsubscribePush(reporterToken: string, endpoint: string): Promise<MushiApiResponse<{ removed: number }>>;\n}\n\nexport function reporterChannels(client: Pick<MushiApiClient, 'reporterRequest'>): MushiReporterChannels {\n const r = client.reporterRequest;\n const id = (reportId: string) => encodeURIComponent(reportId);\n return {\n getUpdates: (token, since) =>\n r('GET', `/v1/reporter/updates${since ? `?since=${encodeURIComponent(since)}` : ''}`, token),\n getReport: (reportId, token) => r('GET', `/v1/reporter/reports/${id(reportId)}`, token),\n markReportRead: (reportId, token) => r('POST', `/v1/reporter/reports/${id(reportId)}/read`, token, {}),\n markAllRead: (token) => r('POST', '/v1/reporter/notifications/read-all', token, {}),\n getPrefs: (token) => r('GET', '/v1/reporter/notification-prefs', token),\n setPrefs: (token, update) => r('PUT', '/v1/reporter/notification-prefs', token, update),\n subscribePush: (token, subscription) => r('POST', '/v1/reporter/push-subscriptions', token, subscription),\n unsubscribePush: (token, endpoint) => r('DELETE', '/v1/reporter/push-subscriptions', token, { endpoint }),\n };\n}\n\n/**\n * Browser push for a reporter: permission prompt, service worker, push\n * subscription, then `subscribePush`. Call it from a click (\"Notify me\"):\n * browsers only show the permission prompt after a user action. Never throws.\n *\n * `serviceWorkerPath` is a worker on the host app's own origin that shows\n * the push payload (`{ title, body, tag, url? }`); `vapidPublicKey` comes\n * from `GET /v1/sdk/config` → `reporter.vapidPublicKey` (null = not offered).\n */\nexport async function subscribeBrowserPush(\n channels: Pick<MushiReporterChannels, 'subscribePush'>,\n reporterToken: string,\n serviceWorkerPath: string | null | undefined,\n vapidPublicKey: string | null | undefined,\n): Promise<{ ok: true } | { ok: false; reason: string }> {\n try {\n if (!serviceWorkerPath || typeof Notification === 'undefined' || !navigator.serviceWorker) {\n return { ok: false, reason: 'unsupported' };\n }\n if (!vapidPublicKey) return { ok: false, reason: 'not_available' };\n if ((await Notification.requestPermission()) !== 'granted') return { ok: false, reason: 'denied' };\n const reg = await navigator.serviceWorker.register(serviceWorkerPath);\n const b64 = vapidPublicKey.replace(/-/g, '+').replace(/_/g, '/');\n const raw = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));\n const sub = await reg.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: Uint8Array.from(raw, (ch) => ch.charCodeAt(0)),\n });\n const res = await channels.subscribePush(reporterToken, sub.toJSON() as MushiPushSubscriptionJSON);\n return res.ok ? { ok: true } : { ok: false, reason: res.error?.code ?? 'error' };\n } catch (err) {\n return { ok: false, reason: err instanceof Error ? err.name || 'error' : 'error' };\n }\n}\n"]}
@@ -0,0 +1 @@
1
+ export { T as MushiPushSubscriptionJSON, X as MushiReporterChannelUnavailable, ak as MushiReporterChannels, Z as MushiReporterNotificationPrefs, _ as MushiReporterPrefsUpdate, a0 as MushiReporterReportDetail, a1 as MushiReporterTimelineItem, a2 as MushiReporterUpdates, al as reporterChannels, am as subscribeBrowserPush } from './types-0j5BJ3aC.cjs';
@@ -0,0 +1 @@
1
+ export { T as MushiPushSubscriptionJSON, X as MushiReporterChannelUnavailable, ak as MushiReporterChannels, Z as MushiReporterNotificationPrefs, _ as MushiReporterPrefsUpdate, a0 as MushiReporterReportDetail, a1 as MushiReporterTimelineItem, a2 as MushiReporterUpdates, al as reporterChannels, am as subscribeBrowserPush } from './types-0j5BJ3aC.js';
@@ -0,0 +1,39 @@
1
+ // src/reporter-channels.ts
2
+ function reporterChannels(client) {
3
+ const r = client.reporterRequest;
4
+ const id = (reportId) => encodeURIComponent(reportId);
5
+ return {
6
+ getUpdates: (token, since) => r("GET", `/v1/reporter/updates${since ? `?since=${encodeURIComponent(since)}` : ""}`, token),
7
+ getReport: (reportId, token) => r("GET", `/v1/reporter/reports/${id(reportId)}`, token),
8
+ markReportRead: (reportId, token) => r("POST", `/v1/reporter/reports/${id(reportId)}/read`, token, {}),
9
+ markAllRead: (token) => r("POST", "/v1/reporter/notifications/read-all", token, {}),
10
+ getPrefs: (token) => r("GET", "/v1/reporter/notification-prefs", token),
11
+ setPrefs: (token, update) => r("PUT", "/v1/reporter/notification-prefs", token, update),
12
+ subscribePush: (token, subscription) => r("POST", "/v1/reporter/push-subscriptions", token, subscription),
13
+ unsubscribePush: (token, endpoint) => r("DELETE", "/v1/reporter/push-subscriptions", token, { endpoint })
14
+ };
15
+ }
16
+ async function subscribeBrowserPush(channels, reporterToken, serviceWorkerPath, vapidPublicKey) {
17
+ try {
18
+ if (!serviceWorkerPath || typeof Notification === "undefined" || !navigator.serviceWorker) {
19
+ return { ok: false, reason: "unsupported" };
20
+ }
21
+ if (!vapidPublicKey) return { ok: false, reason: "not_available" };
22
+ if (await Notification.requestPermission() !== "granted") return { ok: false, reason: "denied" };
23
+ const reg = await navigator.serviceWorker.register(serviceWorkerPath);
24
+ const b64 = vapidPublicKey.replace(/-/g, "+").replace(/_/g, "/");
25
+ const raw = atob(b64 + "=".repeat((4 - b64.length % 4) % 4));
26
+ const sub = await reg.pushManager.subscribe({
27
+ userVisibleOnly: true,
28
+ applicationServerKey: Uint8Array.from(raw, (ch) => ch.charCodeAt(0))
29
+ });
30
+ const res = await channels.subscribePush(reporterToken, sub.toJSON());
31
+ return res.ok ? { ok: true } : { ok: false, reason: res.error?.code ?? "error" };
32
+ } catch (err) {
33
+ return { ok: false, reason: err instanceof Error ? err.name || "error" : "error" };
34
+ }
35
+ }
36
+
37
+ export { reporterChannels, subscribeBrowserPush };
38
+ //# sourceMappingURL=reporter-channels.js.map
39
+ //# sourceMappingURL=reporter-channels.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reporter-channels.ts"],"names":[],"mappings":"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","file":"reporter-channels.js","sourcesContent":["// SPDX-License-Identifier: MIT\n// Copyright (c) 2024–2026 Kenji Sakuramoto (kensaurus) — Mushi Mushi\n/**\n * FILE: reporter-channels.ts\n * PURPOSE: Typed calls for the reporter loop v2 routes (Plan 018 §4, §6.2):\n * the badge / toast feed, mark-read, the report timeline, and the\n * reporter's own email / push opt-in.\n *\n * USAGE:\n * import { reporterChannels } from '@mushi-mushi/core/reporter-channels'\n * const rc = reporterChannels(apiClient)\n * const res = await rc.getUpdates(reporterToken)\n *\n * Shipped as a subpath so the main `@mushi-mushi/core` entry stays inside\n * its size budget. Every call goes through `apiClient.reporterRequest`, which\n * signs the reporter digest headers, times out, retries once and never\n * rejects: failures resolve as `{ ok: false }`.\n *\n * Email and push are opt-in. The server refuses a channel the project has\n * not turned on, or cannot send (`EMAIL_NOT_AVAILABLE` / `PUSH_NOT_AVAILABLE`\n * with `reason: 'not_configured' | 'project_disabled'`), so never show an\n * opt-in control unless `GET /v1/sdk/config` → `reporter.emailEnabled` /\n * `reporter.pushEnabled` is true.\n */\n\nimport type { MushiApiClient, MushiApiResponse } from './types';\n\n/** `GET /v1/reporter/updates` — the unread badge and the next-visit toast. */\nexport interface MushiReporterUpdates {\n unread_total: number;\n /** Up to 3 newest unread events, `report_id` is always the reporter's own report. */\n latest: Array<{ report_id: string; kind: string | null; preview: string; at: string }>;\n server_time: string;\n}\n\nexport type MushiReporterChannelUnavailable = 'not_configured' | 'project_disabled';\n\n/** `GET|PUT /v1/reporter/notification-prefs`. The address is always masked. */\nexport interface MushiReporterNotificationPrefs {\n email: string | null;\n email_verified: boolean;\n /** An address was given; the confirmation link has not been clicked yet. */\n email_pending: boolean;\n unsubscribed: boolean;\n channels: { in_app: boolean; email: boolean; push: boolean };\n push_subscriptions: number;\n available: { email: boolean; push: boolean };\n unavailable_reason: { email: MushiReporterChannelUnavailable | null; push: MushiReporterChannelUnavailable | null };\n /** PUT only: a confirmation email went out. */\n verification_sent?: boolean;\n}\n\n/** `email`: a new address starts double opt-in; `null` forgets the address. */\nexport interface MushiReporterPrefsUpdate {\n email?: string | null;\n channels?: { email?: boolean; push?: boolean };\n}\n\n/** `PushSubscription.toJSON()` shape. */\nexport interface MushiPushSubscriptionJSON {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n}\n\n/** One entry of `GET /v1/reporter/reports/:id` → `timeline`. */\nexport interface MushiReporterTimelineItem {\n kind: string;\n at: string;\n /** English text; pipeline kinds can be re-rendered per locale unless `custom`. */\n text: string;\n /** Developer- or project-written text: show `text` as is. */\n custom?: boolean;\n body?: string;\n version?: string;\n closed_reason?: string;\n comment_id?: number | string;\n}\n\nexport interface MushiReporterReportDetail {\n report: Record<string, unknown> & { id: string; status: string };\n timeline: MushiReporterTimelineItem[];\n can_verify: boolean;\n}\n\nexport interface MushiReporterChannels {\n getUpdates(reporterToken: string, since?: string): Promise<MushiApiResponse<MushiReporterUpdates>>;\n getReport(reportId: string, reporterToken: string): Promise<MushiApiResponse<MushiReporterReportDetail>>;\n /** Mark one report's updates read; resolves the new unread total. */\n markReportRead(reportId: string, reporterToken: string): Promise<MushiApiResponse<{ marked_read: number; unread_total: number | null }>>;\n markAllRead(reporterToken: string): Promise<MushiApiResponse<{ marked_read: number; unread_total: number }>>;\n getPrefs(reporterToken: string): Promise<MushiApiResponse<MushiReporterNotificationPrefs>>;\n setPrefs(reporterToken: string, update: MushiReporterPrefsUpdate): Promise<MushiApiResponse<MushiReporterNotificationPrefs>>;\n subscribePush(reporterToken: string, subscription: MushiPushSubscriptionJSON): Promise<MushiApiResponse<{ subscribed: boolean }>>;\n unsubscribePush(reporterToken: string, endpoint: string): Promise<MushiApiResponse<{ removed: number }>>;\n}\n\nexport function reporterChannels(client: Pick<MushiApiClient, 'reporterRequest'>): MushiReporterChannels {\n const r = client.reporterRequest;\n const id = (reportId: string) => encodeURIComponent(reportId);\n return {\n getUpdates: (token, since) =>\n r('GET', `/v1/reporter/updates${since ? `?since=${encodeURIComponent(since)}` : ''}`, token),\n getReport: (reportId, token) => r('GET', `/v1/reporter/reports/${id(reportId)}`, token),\n markReportRead: (reportId, token) => r('POST', `/v1/reporter/reports/${id(reportId)}/read`, token, {}),\n markAllRead: (token) => r('POST', '/v1/reporter/notifications/read-all', token, {}),\n getPrefs: (token) => r('GET', '/v1/reporter/notification-prefs', token),\n setPrefs: (token, update) => r('PUT', '/v1/reporter/notification-prefs', token, update),\n subscribePush: (token, subscription) => r('POST', '/v1/reporter/push-subscriptions', token, subscription),\n unsubscribePush: (token, endpoint) => r('DELETE', '/v1/reporter/push-subscriptions', token, { endpoint }),\n };\n}\n\n/**\n * Browser push for a reporter: permission prompt, service worker, push\n * subscription, then `subscribePush`. Call it from a click (\"Notify me\"):\n * browsers only show the permission prompt after a user action. Never throws.\n *\n * `serviceWorkerPath` is a worker on the host app's own origin that shows\n * the push payload (`{ title, body, tag, url? }`); `vapidPublicKey` comes\n * from `GET /v1/sdk/config` → `reporter.vapidPublicKey` (null = not offered).\n */\nexport async function subscribeBrowserPush(\n channels: Pick<MushiReporterChannels, 'subscribePush'>,\n reporterToken: string,\n serviceWorkerPath: string | null | undefined,\n vapidPublicKey: string | null | undefined,\n): Promise<{ ok: true } | { ok: false; reason: string }> {\n try {\n if (!serviceWorkerPath || typeof Notification === 'undefined' || !navigator.serviceWorker) {\n return { ok: false, reason: 'unsupported' };\n }\n if (!vapidPublicKey) return { ok: false, reason: 'not_available' };\n if ((await Notification.requestPermission()) !== 'granted') return { ok: false, reason: 'denied' };\n const reg = await navigator.serviceWorker.register(serviceWorkerPath);\n const b64 = vapidPublicKey.replace(/-/g, '+').replace(/_/g, '/');\n const raw = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));\n const sub = await reg.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: Uint8Array.from(raw, (ch) => ch.charCodeAt(0)),\n });\n const res = await channels.subscribePush(reporterToken, sub.toJSON() as MushiPushSubscriptionJSON);\n return res.ok ? { ok: true } : { ok: false, reason: res.error?.code ?? 'error' };\n } catch (err) {\n return { ok: false, reason: err instanceof Error ? err.name || 'error' : 'error' };\n }\n}\n"]}