@openchambery/web 1.19.3-beta.5 → 1.19.3-beta.7
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/dist/assets/{AssistantView-0VuZD1WE.js → AssistantView-5WWa8PoA.js} +1 -1
- package/dist/assets/{ChatContainer-DLVqAYSc.js → ChatContainer-en1EbW0d.js} +4 -4
- package/dist/assets/{ContextPanel-WEaLnfGc.js → ContextPanel-C0u6eApd.js} +3 -3
- package/dist/assets/{DiagramView-BqmWuPGO.js → DiagramView-CY2YANST.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-DmZhTDxm.js → MarkdownRendererImpl-BZIJY2j7.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-1Q8_I0Ys.js → MobileDetailNavigation-BkaqBiT0.js} +1 -1
- package/dist/assets/{MobileShareBridge-DhG_umux.js → MobileShareBridge-BwIzknQy.js} +1 -1
- package/dist/assets/{MobileSurface-DSDyKHqq.js → MobileSurface-C478knqa.js} +1 -1
- package/dist/assets/{MultiRunWindow-Bp5TlAEq.js → MultiRunWindow-Dv_0LieP.js} +1 -1
- package/dist/assets/{SettingsView-CIa0TvNx.js → SettingsView-6PIXrbCy.js} +1 -1
- package/dist/assets/{SettingsWindow-DLD9SpBk.js → SettingsWindow-doUTGdaX.js} +1 -1
- package/dist/assets/{TerminalView-Bi26Ea9z.js → TerminalView-c0r1PHsM.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Dt5qPnmx.js → ToolOutputDialog-DkbWS-Dy.js} +1 -1
- package/dist/assets/{appThemeRegistry-DT_7iUXg.js → appThemeRegistry-BMuOB9ku.js} +2 -2
- package/dist/assets/{gitApi-Dt0-GiBd.js → gitApi-BfTlx_IX.js} +1 -1
- package/dist/assets/{insertionBoundaries-CdbLoTBV.js → insertionBoundaries-C-orat4e.js} +1 -1
- package/dist/assets/{main-jF2oP_Wf.js → main-Co5E8Yio.js} +3 -3
- package/dist/assets/{main-CkPuVRol.js → main-DdAJVJGt.js} +2 -2
- package/dist/assets/{miniChat-oGjSmwle.js → miniChat-iR_M6GWk.js} +2 -2
- package/dist/assets/{mobile-DXmjkSEM.js → mobile-CNa08MNX.js} +2 -2
- package/dist/assets/{multirun-BppWsdIG.js → multirun-F81tf74_.js} +1 -1
- package/dist/assets/{projectMeta-BjgEm9X0.js → projectMeta-BXiTnGSZ.js} +1 -1
- package/dist/assets/{radio-Dv97SxyN.js → radio-DEiFz0Ub.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-Cx0vx0wA.js → renderElectronMiniChatApp-AlzjzIRs.js} +2 -2
- package/dist/assets/{renderMobileApp-BtNv6I2r.js → renderMobileApp-7An0mKkA.js} +5 -5
- package/dist/assets/runtimeConfig-CcreuQib.js +2 -0
- package/dist/assets/{runtimeEndpointReset-bE9HMBRf.js → runtimeEndpointReset-DA8aa4Rv.js} +1 -1
- package/dist/assets/{shikiHighlight-BCt3A4JA.js → shikiHighlight-HsaTZWbS.js} +1 -1
- package/dist/assets/{useAppFontEffects-D7X7pSIl.js → useAppFontEffects-C9Rb1_9R.js} +2 -2
- package/dist/assets/{useDesktopWindowControlsLayout-Cnrx9lrf.js → useDesktopWindowControlsLayout-CiiBxOVh.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-CM0CRezH.js → useEffectiveDirectory-BMjjxnJc.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-CJK1RtBV.js → useMobileNavigationStore-Cm_NMomE.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-CrtZ-ooK.js → useSessionAutoCleanup-BSvS7rhl.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-CxkUt11X.js → useWorkerHighlightedLines-5zsxRMrF.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/index.js +7 -0
- package/server/lib/notifications/APNS.md +35 -0
- package/server/lib/notifications/DOCUMENTATION.md +8 -2
- package/server/lib/notifications/apns-runtime.js +558 -7
- package/server/lib/notifications/apns-runtime.test.js +475 -0
- package/server/lib/notifications/routes.js +55 -0
- package/server/lib/notifications/runtime.js +15 -4
- package/server/lib/notifications/runtime.test.js +108 -0
- package/server/lib/opencode/bootstrap-runtime.js +6 -0
- package/server/lib/opencode/bootstrap-runtime.test.js +21 -0
- package/dist/assets/runtimeConfig-CPeD_UHI.js +0 -2
|
@@ -2,7 +2,10 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { createApnsRuntime } from './apns-runtime.js';
|
|
5
|
+
import { registerNotificationRoutes } from './routes.js';
|
|
5
6
|
import { resolveEffectiveRelayUrl } from '../relay/service.js';
|
|
7
|
+
import { createPushRelayServer } from '../../../../relay-server/src/push/index.js';
|
|
8
|
+
import { createApnsProvider } from '../../../../relay-server/src/push/apns.js';
|
|
6
9
|
|
|
7
10
|
// A real P-256 key so the ES256 signing path (direct mode) runs for real.
|
|
8
11
|
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
@@ -76,6 +79,7 @@ afterEach(() => {
|
|
|
76
79
|
delete process.env.OPENCHAMBER_APNS_TEAM_ID;
|
|
77
80
|
delete process.env.OPENCHAMBER_APNS_P8;
|
|
78
81
|
delete process.env.OPENCHAMBER_APNS_BUNDLE_ID;
|
|
82
|
+
delete process.env.OPENCHAMBER_APNS_ENVIRONMENT;
|
|
79
83
|
});
|
|
80
84
|
|
|
81
85
|
describe('apns runtime bundle ID', () => {
|
|
@@ -641,3 +645,474 @@ describe('apns runtime push relay derivation and re-register', () => {
|
|
|
641
645
|
expect(sent.body).toBe('S');
|
|
642
646
|
});
|
|
643
647
|
});
|
|
648
|
+
|
|
649
|
+
const isLiveActivityRegister = ([url]) => String(url).endsWith('/register-live-activity-token');
|
|
650
|
+
const isLiveActivityUnregister = ([url]) => String(url).endsWith('/unregister-live-activity-token');
|
|
651
|
+
const isLiveActivitySend = ([url]) => String(url).endsWith('/live-activity');
|
|
652
|
+
|
|
653
|
+
const createRouteRegistry = () => {
|
|
654
|
+
const routes = new Map();
|
|
655
|
+
return {
|
|
656
|
+
app: {
|
|
657
|
+
get(routePath, handler) { routes.set(`GET ${routePath}`, handler); },
|
|
658
|
+
post(routePath, handler) { routes.set(`POST ${routePath}`, handler); },
|
|
659
|
+
delete(routePath, handler) { routes.set(`DELETE ${routePath}`, handler); },
|
|
660
|
+
},
|
|
661
|
+
getRoute(method, routePath) { return routes.get(`${method} ${routePath}`); },
|
|
662
|
+
};
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const createMockRes = () => {
|
|
666
|
+
let statusCode = 200;
|
|
667
|
+
let body;
|
|
668
|
+
return {
|
|
669
|
+
status(code) { statusCode = code; return this; },
|
|
670
|
+
json(payload) { body = payload; return this; },
|
|
671
|
+
get statusCode() { return statusCode; },
|
|
672
|
+
get body() { return body; },
|
|
673
|
+
};
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const leakedWarnText = (warn) => warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
|
|
677
|
+
|
|
678
|
+
describe('apns live activity token store', () => {
|
|
679
|
+
it('migrates v1 alert-token files without dropping existing device tokens', async () => {
|
|
680
|
+
const fsPromises = createMemoryFs();
|
|
681
|
+
await fsPromises.writeFile('/tmp/apns-tokens.json', JSON.stringify({
|
|
682
|
+
version: 1,
|
|
683
|
+
tokensBySession: {
|
|
684
|
+
s1: [{ deviceToken: 'alert-token', createdAt: 1, lastSeenAt: 1, platform: 'ios' }],
|
|
685
|
+
},
|
|
686
|
+
}));
|
|
687
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
688
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
689
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
690
|
+
const runtime = createApnsRuntime(makeDeps({ fsPromises }));
|
|
691
|
+
|
|
692
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
|
|
693
|
+
const store = JSON.parse(await fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
694
|
+
expect(store.version).toBe(2);
|
|
695
|
+
expect(store.tokensBySession.s1[0].deviceToken).toBe('alert-token');
|
|
696
|
+
expect(store.liveActivityTokensBySession.s1).toEqual([
|
|
697
|
+
expect.objectContaining({ token: 'la-token', activityId: 'act-1', sessionId: 'ses_1' }),
|
|
698
|
+
]);
|
|
699
|
+
|
|
700
|
+
fetchMock.mockClear();
|
|
701
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
702
|
+
expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['alert-token']);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('registers idempotently and replaces the token for the same activity', async () => {
|
|
706
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
707
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
708
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
709
|
+
const deps = makeDeps();
|
|
710
|
+
const runtime = createApnsRuntime(deps);
|
|
711
|
+
|
|
712
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token-a', 'act-1', 'ses_1');
|
|
713
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token-a', 'act-1', 'ses_1');
|
|
714
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token-b', 'act-1', 'ses_1');
|
|
715
|
+
|
|
716
|
+
const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
717
|
+
expect(store.liveActivityTokensBySession.s1).toHaveLength(1);
|
|
718
|
+
expect(store.liveActivityTokensBySession.s1[0].token).toBe('la-token-b');
|
|
719
|
+
|
|
720
|
+
const unregisterCalls = fetchMock.mock.calls.filter(isLiveActivityUnregister);
|
|
721
|
+
expect(unregisterCalls).toHaveLength(1);
|
|
722
|
+
const unreg = JSON.parse(unregisterCalls[0][1].body);
|
|
723
|
+
expect(unreg.token).toBe('la-token-a');
|
|
724
|
+
expect(unreg.platform).toBe('ios');
|
|
725
|
+
expect(unreg.kind).toBe('liveactivity');
|
|
726
|
+
expect(await verifyRelaySignature(
|
|
727
|
+
unreg.publicKeyJwk,
|
|
728
|
+
`${unreg.ts}.${unreg.token}.${unreg.platform}.${unreg.kind}`,
|
|
729
|
+
unreg.sig,
|
|
730
|
+
)).toBe(true);
|
|
731
|
+
|
|
732
|
+
fetchMock.mockClear();
|
|
733
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
734
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
735
|
+
expect(sent.tokens).toEqual(['la-token-b']);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('refuses to unregister a token owned by a different UI session', async () => {
|
|
739
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
740
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
741
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
742
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
743
|
+
await runtime.addOrUpdateLiveActivityToken('owner', 'la-token', 'act-1', 'ses_1');
|
|
744
|
+
|
|
745
|
+
fetchMock.mockClear();
|
|
746
|
+
await runtime.removeLiveActivityToken('other', 'la-token');
|
|
747
|
+
expect(fetchMock.mock.calls.filter(isLiveActivityUnregister)).toHaveLength(0);
|
|
748
|
+
|
|
749
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
750
|
+
expect(JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body).tokens).toEqual(['la-token']);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('caps live activity tokens per UI session and lazily expires stale entries', async () => {
|
|
754
|
+
const fsPromises = createMemoryFs();
|
|
755
|
+
const now = Date.now();
|
|
756
|
+
await fsPromises.writeFile('/tmp/apns-tokens.json', JSON.stringify({
|
|
757
|
+
version: 2,
|
|
758
|
+
tokensBySession: {},
|
|
759
|
+
liveActivityTokensBySession: {
|
|
760
|
+
s1: [{
|
|
761
|
+
token: 'expired-token',
|
|
762
|
+
activityId: 'act-old',
|
|
763
|
+
sessionId: 'ses_old',
|
|
764
|
+
createdAt: now - 48 * 60 * 60 * 1000,
|
|
765
|
+
lastSeenAt: now - 48 * 60 * 60 * 1000,
|
|
766
|
+
}],
|
|
767
|
+
},
|
|
768
|
+
liveActivityEventVersions: {},
|
|
769
|
+
}));
|
|
770
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
771
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
772
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
773
|
+
const runtime = createApnsRuntime(makeDeps({ fsPromises }));
|
|
774
|
+
|
|
775
|
+
for (let i = 0; i < 9; i += 1) {
|
|
776
|
+
await runtime.addOrUpdateLiveActivityToken('s1', `la-token-${i}`, `act-${i}`, `ses_${i}`);
|
|
777
|
+
}
|
|
778
|
+
const store = JSON.parse(await fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
779
|
+
expect(store.liveActivityTokensBySession.s1).toHaveLength(8);
|
|
780
|
+
expect(store.liveActivityTokensBySession.s1.some((entry) => entry.token === 'expired-token')).toBe(false);
|
|
781
|
+
expect(store.liveActivityTokensBySession.s1.some((entry) => entry.token === 'la-token-0')).toBe(false);
|
|
782
|
+
expect(store.liveActivityTokensBySession.s1[0].token).toBe('la-token-8');
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
describe('apns live activity routes', () => {
|
|
787
|
+
const register = (overrides = {}) => {
|
|
788
|
+
const { app, getRoute } = createRouteRegistry();
|
|
789
|
+
const addOrUpdateLiveActivityToken = vi.fn(async () => {});
|
|
790
|
+
const removeLiveActivityToken = vi.fn(async () => {});
|
|
791
|
+
registerNotificationRoutes(app, {
|
|
792
|
+
getUiSessionTokenFromRequest: () => 'ui-token',
|
|
793
|
+
addOrUpdateLiveActivityToken,
|
|
794
|
+
removeLiveActivityToken,
|
|
795
|
+
...overrides,
|
|
796
|
+
});
|
|
797
|
+
return { getRoute, addOrUpdateLiveActivityToken, removeLiveActivityToken };
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
it('rejects missing UI auth with the apns-token error shape', async () => {
|
|
801
|
+
const { getRoute, addOrUpdateLiveActivityToken, removeLiveActivityToken } = register({
|
|
802
|
+
getUiSessionTokenFromRequest: () => null,
|
|
803
|
+
});
|
|
804
|
+
const postRes = createMockRes();
|
|
805
|
+
await getRoute('POST', '/api/push/live-activity-token')({ body: { token: 't', activityId: 'a', sessionId: 's' } }, postRes);
|
|
806
|
+
expect(postRes.statusCode).toBe(401);
|
|
807
|
+
expect(postRes.body).toEqual({ error: 'UI session missing' });
|
|
808
|
+
const delRes = createMockRes();
|
|
809
|
+
await getRoute('DELETE', '/api/push/live-activity-token')({ body: { token: 't' } }, delRes);
|
|
810
|
+
expect(delRes.statusCode).toBe(401);
|
|
811
|
+
expect(delRes.body).toEqual({ error: 'UI session missing' });
|
|
812
|
+
expect(addOrUpdateLiveActivityToken).not.toHaveBeenCalled();
|
|
813
|
+
expect(removeLiveActivityToken).not.toHaveBeenCalled();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('rejects overlong or incomplete bodies', async () => {
|
|
817
|
+
const { getRoute, addOrUpdateLiveActivityToken, removeLiveActivityToken } = register();
|
|
818
|
+
const postRes = createMockRes();
|
|
819
|
+
await getRoute('POST', '/api/push/live-activity-token')({ body: { token: 't', activityId: 'a' } }, postRes);
|
|
820
|
+
expect(postRes.statusCode).toBe(400);
|
|
821
|
+
expect(postRes.body).toEqual({ error: 'Invalid body' });
|
|
822
|
+
|
|
823
|
+
const longRes = createMockRes();
|
|
824
|
+
await getRoute('POST', '/api/push/live-activity-token')({
|
|
825
|
+
body: { token: 't'.repeat(513), activityId: 'act', sessionId: 'ses' },
|
|
826
|
+
}, longRes);
|
|
827
|
+
expect(longRes.statusCode).toBe(400);
|
|
828
|
+
|
|
829
|
+
const delRes = createMockRes();
|
|
830
|
+
await getRoute('DELETE', '/api/push/live-activity-token')({ body: {} }, delRes);
|
|
831
|
+
expect(delRes.statusCode).toBe(400);
|
|
832
|
+
expect(addOrUpdateLiveActivityToken).not.toHaveBeenCalled();
|
|
833
|
+
expect(removeLiveActivityToken).not.toHaveBeenCalled();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('registers and unregisters through the owning UI session', async () => {
|
|
837
|
+
const { getRoute, addOrUpdateLiveActivityToken, removeLiveActivityToken } = register();
|
|
838
|
+
const postRes = createMockRes();
|
|
839
|
+
await getRoute('POST', '/api/push/live-activity-token')({
|
|
840
|
+
body: { token: ' la-token ', activityId: ' act-1 ', sessionId: ' ses_1 ' },
|
|
841
|
+
}, postRes);
|
|
842
|
+
expect(postRes.body).toEqual({ ok: true });
|
|
843
|
+
expect(addOrUpdateLiveActivityToken).toHaveBeenCalledWith('ui-token', 'la-token', 'act-1', 'ses_1');
|
|
844
|
+
|
|
845
|
+
const delRes = createMockRes();
|
|
846
|
+
await getRoute('DELETE', '/api/push/live-activity-token')({ body: { token: 'la-token' } }, delRes);
|
|
847
|
+
expect(delRes.body).toEqual({ ok: true });
|
|
848
|
+
expect(removeLiveActivityToken).toHaveBeenCalledWith('ui-token', 'la-token');
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
describe('apns live activity relay and direct delivery', () => {
|
|
853
|
+
it('sends a signed end payload without sessionId or user content, then drops local tokens', async () => {
|
|
854
|
+
const fetchMock = vi.fn(async (url) =>
|
|
855
|
+
String(url).endsWith('/register-live-activity-token') || String(url).endsWith('/live-activity')
|
|
856
|
+
? jsonResponse({ ok: true, results: [] })
|
|
857
|
+
: jsonResponse({ ok: true, results: [] }),
|
|
858
|
+
);
|
|
859
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
860
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
861
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
862
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
863
|
+
const sessionId = 'ses_secret_session';
|
|
864
|
+
const token = 'secret-la-token-xyz';
|
|
865
|
+
try {
|
|
866
|
+
await runtime.addOrUpdateLiveActivityToken('s1', token, 'act-1', sessionId);
|
|
867
|
+
await runtime.addOrUpdateApnsToken('s1', 'alert-token');
|
|
868
|
+
|
|
869
|
+
fetchMock.mockClear();
|
|
870
|
+
const before = Date.now();
|
|
871
|
+
await runtime.sendLiveActivityEnd({ sessionId, status: 'complete' });
|
|
872
|
+
const after = Date.now();
|
|
873
|
+
|
|
874
|
+
expect(fetchMock.mock.calls.filter(isSend)).toHaveLength(0);
|
|
875
|
+
const sendCall = fetchMock.mock.calls.find(isLiveActivitySend);
|
|
876
|
+
expect(sendCall[0]).toBe('https://relay.test/v1/push/live-activity');
|
|
877
|
+
const sent = JSON.parse(sendCall[1].body);
|
|
878
|
+
expect(sent.tokens).toEqual([token]);
|
|
879
|
+
expect(sent.event).toBe('end');
|
|
880
|
+
expect(Object.keys(sent.contentState).sort()).toEqual(['endedAt', 'eventVersion', 'status', 'updatedAt']);
|
|
881
|
+
expect(sent.contentState.status).toBe('complete');
|
|
882
|
+
expect(sent.contentState.eventVersion).toBeGreaterThanOrEqual(Math.floor(before));
|
|
883
|
+
expect(sent.contentState.eventVersion).toBeLessThanOrEqual(after);
|
|
884
|
+
expect(sent.dismissalDate).toBeGreaterThanOrEqual(Math.floor(before / 1000) + 15 * 60);
|
|
885
|
+
expect(sent.dismissalDate).toBeLessThanOrEqual(Math.floor(after / 1000) + 15 * 60);
|
|
886
|
+
expect(sent.staleDate).toBeUndefined();
|
|
887
|
+
expect(sent).not.toHaveProperty('title');
|
|
888
|
+
expect(sent).not.toHaveProperty('body');
|
|
889
|
+
expect(sent).not.toHaveProperty('data');
|
|
890
|
+
expect(JSON.stringify(sent)).not.toContain(sessionId);
|
|
891
|
+
expect(JSON.stringify(sent)).not.toContain('alert');
|
|
892
|
+
expect(await verifyRelaySignature(
|
|
893
|
+
sent.publicKeyJwk,
|
|
894
|
+
`${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.event}.${sent.contentState.status}.${sent.contentState.eventVersion}.${sent.contentState.updatedAt}.${sent.contentState.endedAt ?? ''}.${sent.dismissalDate ?? ''}.${sent.staleDate ?? ''}`,
|
|
895
|
+
sent.sig,
|
|
896
|
+
)).toBe(true);
|
|
897
|
+
|
|
898
|
+
expect(leakedWarnText(warn)).not.toContain(token);
|
|
899
|
+
expect(leakedWarnText(warn)).not.toContain(sessionId);
|
|
900
|
+
|
|
901
|
+
fetchMock.mockClear();
|
|
902
|
+
await runtime.sendLiveActivityEnd({ sessionId, status: 'complete' });
|
|
903
|
+
expect(fetchMock.mock.calls.filter(isLiveActivitySend)).toHaveLength(0);
|
|
904
|
+
} finally {
|
|
905
|
+
warn.mockRestore();
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('uses a 60-minute dismissal for error ends', async () => {
|
|
910
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
911
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
912
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
913
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
914
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
|
|
915
|
+
const before = Date.now();
|
|
916
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'error' });
|
|
917
|
+
const after = Date.now();
|
|
918
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
919
|
+
expect(sent.contentState.status).toBe('error');
|
|
920
|
+
expect(sent.dismissalDate).toBeGreaterThanOrEqual(Math.floor(before / 1000) + 60 * 60);
|
|
921
|
+
expect(sent.dismissalDate).toBeLessThanOrEqual(Math.floor(after / 1000) + 60 * 60);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('forwards OPENCHAMBER_APNS_ENVIRONMENT=production as env on live-activity relay send', async () => {
|
|
925
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
926
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
927
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
928
|
+
process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'production';
|
|
929
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
930
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
|
|
931
|
+
fetchMock.mockClear();
|
|
932
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
933
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
934
|
+
expect(sent.env).toBe('production');
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('keeps tokens after a failed relay end so a later attempt can retry', async () => {
|
|
938
|
+
const fetchMock = vi.fn(async (url) => {
|
|
939
|
+
if (String(url).endsWith('/live-activity')) return jsonResponse({ error: true }, 500);
|
|
940
|
+
return jsonResponse({ ok: true, results: [] });
|
|
941
|
+
});
|
|
942
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
943
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
944
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
945
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
946
|
+
try {
|
|
947
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
|
|
948
|
+
await runtime.addOrUpdateApnsToken('s1', 'alert-token');
|
|
949
|
+
fetchMock.mockClear();
|
|
950
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
951
|
+
expect(fetchMock.mock.calls.filter(isLiveActivitySend)).toHaveLength(1);
|
|
952
|
+
|
|
953
|
+
fetchMock.mockClear();
|
|
954
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
955
|
+
expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['alert-token']);
|
|
956
|
+
|
|
957
|
+
fetchMock.mockImplementation(async () => jsonResponse({ ok: true, results: [] }));
|
|
958
|
+
fetchMock.mockClear();
|
|
959
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
960
|
+
expect(JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body).tokens).toEqual(['la-token']);
|
|
961
|
+
} finally {
|
|
962
|
+
warn.mockRestore();
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it('sends direct live-activity headers without mixing alert tokens', async () => {
|
|
967
|
+
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
|
|
968
|
+
const captured = [];
|
|
969
|
+
const http2 = {
|
|
970
|
+
connect: () => ({
|
|
971
|
+
on: () => {},
|
|
972
|
+
close: () => {},
|
|
973
|
+
request: (headers) => {
|
|
974
|
+
const listeners = {};
|
|
975
|
+
const req = {
|
|
976
|
+
on: (event, cb) => { listeners[event] = cb; return req; },
|
|
977
|
+
setEncoding: () => req,
|
|
978
|
+
end: (body) => {
|
|
979
|
+
captured.push({ headers, body });
|
|
980
|
+
queueMicrotask(() => {
|
|
981
|
+
listeners.response?.({ ':status': '200' });
|
|
982
|
+
listeners.end?.();
|
|
983
|
+
});
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
return req;
|
|
987
|
+
},
|
|
988
|
+
}),
|
|
989
|
+
};
|
|
990
|
+
const runtime = createApnsRuntime(
|
|
991
|
+
makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
|
|
992
|
+
);
|
|
993
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_secret');
|
|
994
|
+
await runtime.addOrUpdateApnsToken('s1', 'alert-token');
|
|
995
|
+
|
|
996
|
+
captured.length = 0;
|
|
997
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_secret', status: 'complete' });
|
|
998
|
+
expect(captured).toHaveLength(1);
|
|
999
|
+
expect(captured[0].headers[':path']).toBe('/3/device/la-token');
|
|
1000
|
+
expect(captured[0].headers['apns-topic']).toBe('com.openchamber.app.push-type.liveactivity');
|
|
1001
|
+
expect(captured[0].headers['apns-push-type']).toBe('liveactivity');
|
|
1002
|
+
expect(captured[0].headers.authorization).toMatch(/^bearer /);
|
|
1003
|
+
const body = JSON.parse(captured[0].body);
|
|
1004
|
+
expect(body.aps.event).toBe('end');
|
|
1005
|
+
expect(body.aps.alert).toBeUndefined();
|
|
1006
|
+
expect(body.aps['content-state'].status).toBe('complete');
|
|
1007
|
+
expect(JSON.stringify(body)).not.toContain('ses_secret');
|
|
1008
|
+
expect(JSON.stringify(body)).not.toContain('alert-token');
|
|
1009
|
+
|
|
1010
|
+
captured.length = 0;
|
|
1011
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' });
|
|
1012
|
+
expect(captured).toHaveLength(1);
|
|
1013
|
+
expect(captured[0].headers[':path']).toBe('/3/device/alert-token');
|
|
1014
|
+
expect(captured[0].headers['apns-push-type']).toBe('alert');
|
|
1015
|
+
expect(captured[0].headers['apns-topic']).toBe('com.openchamber.app');
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
describe('apns live activity host-relay compatibility', () => {
|
|
1020
|
+
const LIVE_ACTIVITY_BUNDLE_ID = 'com.yee94.openchamber';
|
|
1021
|
+
let pushServer;
|
|
1022
|
+
|
|
1023
|
+
afterEach(async () => {
|
|
1024
|
+
await pushServer?.stop();
|
|
1025
|
+
pushServer = undefined;
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it('registers through an injected-provider push relay, ends with liveactivity topic, and clears the token', async () => {
|
|
1029
|
+
const sessionId = 'ses_secret_session_id';
|
|
1030
|
+
const token = crypto.createHash('sha256').update('host-relay-live-activity-e2e').digest('hex');
|
|
1031
|
+
const apnsCalls = [];
|
|
1032
|
+
const sent = [];
|
|
1033
|
+
const http2 = {
|
|
1034
|
+
connect() {
|
|
1035
|
+
return {
|
|
1036
|
+
closed: false,
|
|
1037
|
+
on() { return this; },
|
|
1038
|
+
close() { this.closed = true; },
|
|
1039
|
+
request(headers) {
|
|
1040
|
+
const listeners = {};
|
|
1041
|
+
const req = {
|
|
1042
|
+
on(event, cb) { listeners[event] = cb; return req; },
|
|
1043
|
+
close() {},
|
|
1044
|
+
end(body) {
|
|
1045
|
+
apnsCalls.push({ headers, body });
|
|
1046
|
+
queueMicrotask(() => {
|
|
1047
|
+
listeners.response?.({ ':status': '200' });
|
|
1048
|
+
listeners.end?.();
|
|
1049
|
+
});
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
return req;
|
|
1053
|
+
},
|
|
1054
|
+
};
|
|
1055
|
+
},
|
|
1056
|
+
};
|
|
1057
|
+
const innerProvider = createApnsProvider({
|
|
1058
|
+
keyId: 'KEYID12345',
|
|
1059
|
+
teamId: 'TEAMID1234',
|
|
1060
|
+
p8: P8,
|
|
1061
|
+
bundleId: LIVE_ACTIVITY_BUNDLE_ID,
|
|
1062
|
+
http2,
|
|
1063
|
+
});
|
|
1064
|
+
const apnsProvider = {
|
|
1065
|
+
async send(input) {
|
|
1066
|
+
sent.push(input);
|
|
1067
|
+
return innerProvider.send(input);
|
|
1068
|
+
},
|
|
1069
|
+
close() { innerProvider.close(); },
|
|
1070
|
+
};
|
|
1071
|
+
pushServer = createPushRelayServer({
|
|
1072
|
+
host: '127.0.0.1',
|
|
1073
|
+
port: 0,
|
|
1074
|
+
databasePath: ':memory:',
|
|
1075
|
+
apnsProvider,
|
|
1076
|
+
});
|
|
1077
|
+
await pushServer.start();
|
|
1078
|
+
const relayUrl = `${pushServer.url}/v1/push/send`;
|
|
1079
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = relayUrl;
|
|
1080
|
+
delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
|
|
1081
|
+
delete process.env.OPENCHAMBER_APNS_KEY_ID;
|
|
1082
|
+
delete process.env.OPENCHAMBER_APNS_TEAM_ID;
|
|
1083
|
+
delete process.env.OPENCHAMBER_APNS_P8;
|
|
1084
|
+
process.env.OPENCHAMBER_APNS_BUNDLE_ID = LIVE_ACTIVITY_BUNDLE_ID;
|
|
1085
|
+
|
|
1086
|
+
const deps = makeDeps();
|
|
1087
|
+
const runtime = createApnsRuntime(deps);
|
|
1088
|
+
await runtime.addOrUpdateLiveActivityToken('s1', token, 'act-1', sessionId);
|
|
1089
|
+
expect(pushServer.getSnapshot()).toMatchObject({
|
|
1090
|
+
state: 'running',
|
|
1091
|
+
tokenCount: 1,
|
|
1092
|
+
reasons: expect.objectContaining({ authRejected: 0, policyRejected: 0 }),
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
await runtime.sendLiveActivityEnd({ sessionId, status: 'complete' });
|
|
1096
|
+
|
|
1097
|
+
expect(sent).toHaveLength(1);
|
|
1098
|
+
expect(sent[0].pushType).toBe('liveactivity');
|
|
1099
|
+
expect(sent[0].token).toBe(token);
|
|
1100
|
+
expect(apnsCalls).toHaveLength(1);
|
|
1101
|
+
expect(apnsCalls[0].headers['apns-topic']).toBe(`${LIVE_ACTIVITY_BUNDLE_ID}.push-type.liveactivity`);
|
|
1102
|
+
expect(apnsCalls[0].headers['apns-push-type']).toBe('liveactivity');
|
|
1103
|
+
const payload = JSON.parse(apnsCalls[0].body);
|
|
1104
|
+
expect(payload.aps.event).toBe('end');
|
|
1105
|
+
expect(payload.aps.alert).toBeUndefined();
|
|
1106
|
+
expect(JSON.stringify(payload)).not.toContain(sessionId);
|
|
1107
|
+
expect(JSON.stringify(sent[0].payload)).not.toContain(sessionId);
|
|
1108
|
+
expect(pushServer.getSnapshot().tokenCount).toBe(0);
|
|
1109
|
+
const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
1110
|
+
expect(store.liveActivityTokensBySession.s1).toBeUndefined();
|
|
1111
|
+
|
|
1112
|
+
sent.length = 0;
|
|
1113
|
+
apnsCalls.length = 0;
|
|
1114
|
+
await runtime.sendLiveActivityEnd({ sessionId, status: 'complete' });
|
|
1115
|
+
expect(sent).toHaveLength(0);
|
|
1116
|
+
expect(apnsCalls).toHaveLength(0);
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
@@ -22,6 +22,17 @@ const parsePushUnsubscribeBody = (body) => {
|
|
|
22
22
|
return { endpoint: endpoint.trim() };
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
const MAX_LIVE_ACTIVITY_TOKEN_LENGTH = 512;
|
|
26
|
+
const MAX_LIVE_ACTIVITY_ACTIVITY_ID_LENGTH = 128;
|
|
27
|
+
const MAX_LIVE_ACTIVITY_SESSION_ID_LENGTH = 128;
|
|
28
|
+
|
|
29
|
+
const parseLimitedPushId = (value, maxLength) => {
|
|
30
|
+
if (typeof value !== 'string') return '';
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
if (trimmed.length === 0 || trimmed.length > maxLength) return '';
|
|
33
|
+
return trimmed;
|
|
34
|
+
};
|
|
35
|
+
|
|
25
36
|
export const NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
|
|
26
37
|
|
|
27
38
|
export const registerNotificationRoutes = (app, dependencies) => {
|
|
@@ -37,6 +48,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|
|
37
48
|
removePushSubscription,
|
|
38
49
|
addOrUpdateApnsToken,
|
|
39
50
|
removeApnsToken,
|
|
51
|
+
addOrUpdateLiveActivityToken,
|
|
52
|
+
removeLiveActivityToken,
|
|
40
53
|
updateUiVisibility,
|
|
41
54
|
clearPendingPushBadge,
|
|
42
55
|
isUiVisible,
|
|
@@ -188,6 +201,48 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|
|
188
201
|
return res.json({ ok: true });
|
|
189
202
|
});
|
|
190
203
|
|
|
204
|
+
app.post('/api/push/live-activity-token', async (req, res) => {
|
|
205
|
+
await ensureSessionWatcher();
|
|
206
|
+
|
|
207
|
+
const uiToken = uiAuthController?.ensureSessionToken
|
|
208
|
+
? await uiAuthController.ensureSessionToken(req, res)
|
|
209
|
+
: getUiSessionTokenFromRequest(req);
|
|
210
|
+
if (!uiToken) {
|
|
211
|
+
return res.status(401).json({ error: 'UI session missing' });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const token = parseLimitedPushId(req.body?.token, MAX_LIVE_ACTIVITY_TOKEN_LENGTH);
|
|
215
|
+
const activityId = parseLimitedPushId(req.body?.activityId, MAX_LIVE_ACTIVITY_ACTIVITY_ID_LENGTH);
|
|
216
|
+
const sessionId = parseLimitedPushId(req.body?.sessionId, MAX_LIVE_ACTIVITY_SESSION_ID_LENGTH);
|
|
217
|
+
if (!token || !activityId || !sessionId) {
|
|
218
|
+
return res.status(400).json({ error: 'Invalid body' });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (typeof addOrUpdateLiveActivityToken === 'function') {
|
|
222
|
+
await addOrUpdateLiveActivityToken(uiToken, token, activityId, sessionId);
|
|
223
|
+
}
|
|
224
|
+
return res.json({ ok: true });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
app.delete('/api/push/live-activity-token', async (req, res) => {
|
|
228
|
+
const uiToken = uiAuthController?.ensureSessionToken
|
|
229
|
+
? await uiAuthController.ensureSessionToken(req, res)
|
|
230
|
+
: getUiSessionTokenFromRequest(req);
|
|
231
|
+
if (!uiToken) {
|
|
232
|
+
return res.status(401).json({ error: 'UI session missing' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const token = parseLimitedPushId(req.body?.token, MAX_LIVE_ACTIVITY_TOKEN_LENGTH);
|
|
236
|
+
if (!token) {
|
|
237
|
+
return res.status(400).json({ error: 'Invalid body' });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (typeof removeLiveActivityToken === 'function') {
|
|
241
|
+
await removeLiveActivityToken(uiToken, token);
|
|
242
|
+
}
|
|
243
|
+
return res.json({ ok: true });
|
|
244
|
+
});
|
|
245
|
+
|
|
191
246
|
app.post('/api/push/visibility', async (req, res) => {
|
|
192
247
|
const uiToken = uiAuthController?.ensureSessionToken
|
|
193
248
|
? await uiAuthController.ensureSessionToken(req, res)
|
|
@@ -11,6 +11,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
11
11
|
broadcastUiNotification,
|
|
12
12
|
sendPushToAllUiSessions,
|
|
13
13
|
sendApnsToAllUiSessions,
|
|
14
|
+
sendLiveActivityEnd,
|
|
14
15
|
isAnyInteractiveClientVisible,
|
|
15
16
|
buildOpenCodeUrl,
|
|
16
17
|
getOpenCodeAuthHeaders,
|
|
@@ -369,14 +370,24 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
369
370
|
|
|
370
371
|
if (payload.type === 'message.updated') {
|
|
371
372
|
const info = payload.properties?.info;
|
|
372
|
-
|
|
373
|
+
const isAssistantTerminal = Boolean(
|
|
373
374
|
sessionId
|
|
374
375
|
&& info?.role === 'assistant'
|
|
375
|
-
&& (info?.finish === 'stop' || info?.finish === 'error')
|
|
376
|
-
|
|
377
|
-
) {
|
|
376
|
+
&& (info?.finish === 'stop' || info?.finish === 'error'),
|
|
377
|
+
);
|
|
378
|
+
if (isAssistantTerminal && await shouldSkipSystemSessionNotification(sessionId, notificationDirectory)) {
|
|
378
379
|
return;
|
|
379
380
|
}
|
|
381
|
+
if (isAssistantTerminal) {
|
|
382
|
+
try {
|
|
383
|
+
await sendLiveActivityEnd?.({
|
|
384
|
+
sessionId,
|
|
385
|
+
status: info.finish === 'error' ? 'error' : 'complete',
|
|
386
|
+
});
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.warn('[Live Activity] end failed:', error?.message ?? error);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
380
391
|
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
|
|
381
392
|
const settings = await readSettingsFromDisk();
|
|
382
393
|
|