@learncard/network-plugin 3.2.2 → 4.0.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.
@@ -360,6 +360,45 @@ describe('connection prompt methods', () => {
360
360
  });
361
361
  });
362
362
 
363
+ describe('updateContract patch shapes', () => {
364
+ beforeEach(() => vi.clearAllMocks());
365
+
366
+ it.each(['read', 'write'] as const)(
367
+ 'rejects overlapping %s patches without a server mutation',
368
+ async area => {
369
+ const mutate = vi.fn();
370
+ const client = {
371
+ ...getMockClient(),
372
+ contracts: { updateConsentFlowContract: { mutate } },
373
+ };
374
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
375
+ const learnCard = getMockLearnCard();
376
+ const plugin = await getLearnCardNetworkPlugin(
377
+ learnCard,
378
+ 'https://network.example/trpc'
379
+ );
380
+
381
+ await expect(
382
+ plugin.methods?.updateContract(learnCard, 'contract:1', {
383
+ [area]: {
384
+ credentials: { categories: { Transcript: { required: true } } },
385
+ personal: {},
386
+ },
387
+ contract: {
388
+ [area]: {
389
+ credentials: { categories: { Certificate: { required: true } } },
390
+ personal: {},
391
+ },
392
+ },
393
+ })
394
+ ).rejects.toThrow(
395
+ 'Contract read/write updates must use either the top-level or nested contract shape'
396
+ );
397
+ expect(mutate).not.toHaveBeenCalled();
398
+ }
399
+ );
400
+ });
401
+
363
402
  describe('credential refresh methods', () => {
364
403
  beforeEach(() => {
365
404
  vi.clearAllMocks();
@@ -613,6 +652,38 @@ describe('credential refresh methods', () => {
613
652
  expect(client.utilities.getDid.query).not.toHaveBeenCalled();
614
653
  });
615
654
 
655
+ it('sendBoost throws instead of signing when the boost is a display-only anchor', async () => {
656
+ const client = getMockClient();
657
+ client.boost.getBoost.query.mockResolvedValue({
658
+ boost: getUnsignedBoostTemplate({ displayOnlyAnchor: true, display: {} }),
659
+ });
660
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
661
+ const { learnCard } = getMockIssuingLearnCard();
662
+ const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
663
+
664
+ await expect(
665
+ plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1')
666
+ ).rejects.toThrow('display-only anchor');
667
+
668
+ expect(learnCard.invoke.issueCredential).not.toHaveBeenCalled();
669
+ });
670
+
671
+ it('sendBoost signs normally when the boost has its own display object (not a display-only anchor)', async () => {
672
+ const client = getMockClient();
673
+ client.boost.getBoost.query.mockResolvedValue({
674
+ boost: getUnsignedBoostTemplate({
675
+ display: { backgroundColor: '#fff', backgroundImage: '', displayType: 'card' },
676
+ }),
677
+ });
678
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
679
+ const { learnCard } = getMockIssuingLearnCard();
680
+ const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
681
+
682
+ await plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1');
683
+
684
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalled();
685
+ });
686
+
616
687
  it('sendBoost receipt preserves the exact credentialStatus descriptor from the signed VC', async () => {
617
688
  const client = getMockClient();
618
689
  vi.mocked(getBrainClient).mockResolvedValue(client as never);
@@ -43,6 +43,8 @@ vi.mock('@learncard/types', async importOriginal => ({
43
43
  },
44
44
  },
45
45
  UnsignedVCValidator: {
46
+ parse: (await importOriginal<typeof import('@learncard/types')>()).UnsignedVCValidator
47
+ .parse,
46
48
  spa: async (value: any) => ({
47
49
  success: true,
48
50
  data: typeof value === 'string' ? JSON.parse(value) : value,
@@ -174,6 +176,56 @@ const getPlugin = async (learnCard: any, client: any) => {
174
176
  };
175
177
 
176
178
  describe('unified send with refresh: true (managed branch)', () => {
179
+ it('includes revocation when an old server requires fallback suspension allocation', async () => {
180
+ const client = getMockClient();
181
+ const learnCard = getMockIssuingLearnCard();
182
+ const plugin = await getPlugin(learnCard, client);
183
+ await plugin.methods?.send(learnCard, {
184
+ type: 'boost',
185
+ recipient: 'userb',
186
+ templateUri: 'did:web:network.example:boost:1',
187
+ refresh: true,
188
+ statusPurposes: ['suspension'],
189
+ });
190
+ expect(client.boost.allocateCredentialStatus.mutate).toHaveBeenCalledWith({
191
+ statusPurposes: ['revocation', 'suspension'],
192
+ });
193
+ });
194
+
195
+ it.each([false, true])(
196
+ 'uses server-bound status without allocating again (array=%s)',
197
+ async arrayShape => {
198
+ const client = getMockClient();
199
+ const learnCard = getMockIssuingLearnCard();
200
+ const entry = SERVER_MANAGED_RESPONSE.refresh.credentialStatus;
201
+ client.boost.prepareRefreshableSend.mutate.mockResolvedValue(
202
+ Object.assign(
203
+ {},
204
+ {
205
+ ...defaultPrepared,
206
+ statusEntries: [entry],
207
+ }
208
+ )
209
+ );
210
+ client.boost.getBoost.query.mockResolvedValue({
211
+ boost: getUnsignedBoostTemplate({ credentialStatus: arrayShape ? [entry] : entry }),
212
+ });
213
+ const plugin = await getPlugin(learnCard, client);
214
+ await plugin.methods?.send(learnCard, {
215
+ type: 'boost',
216
+ recipient: 'userb',
217
+ templateUri: 'did:web:network.example:boost:1',
218
+ refresh: true,
219
+ });
220
+ expect(client.boost.allocateCredentialStatus.mutate).not.toHaveBeenCalled();
221
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
222
+ expect.objectContaining({
223
+ credentialStatus: arrayShape ? [entry] : entry,
224
+ })
225
+ );
226
+ }
227
+ );
228
+
177
229
  beforeEach(() => {
178
230
  vi.clearAllMocks();
179
231
  });
@@ -219,9 +271,10 @@ describe('unified send with refresh: true (managed branch)', () => {
219
271
  // The server runs every guard, creates/reuses the boost and allocates the
220
272
  // refresh in one call; the SDK never allocates client-side.
221
273
  expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledExactlyOnceWith({
274
+ statusBinding: 'server',
222
275
  recipient: 'userb',
223
276
  templateUri: 'did:web:network.example:boost:1',
224
- templateData: { name: 'Refreshable Badge' },
277
+ display: undefined,
225
278
  });
226
279
  expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
227
280
 
@@ -249,6 +302,47 @@ describe('unified send with refresh: true (managed branch)', () => {
249
302
  expect(learnCard.invoke.createDagJwe).not.toHaveBeenCalled();
250
303
  });
251
304
 
305
+ it('throws instead of signing when templateUri points at a display-only anchor', async () => {
306
+ const client = getMockClient();
307
+ const learnCard = getMockIssuingLearnCard();
308
+ client.boost.getBoost.query.mockResolvedValue({
309
+ boost: getUnsignedBoostTemplate({ displayOnlyAnchor: true, display: {} }),
310
+ });
311
+ const plugin = await getPlugin(learnCard, client);
312
+
313
+ await expect(
314
+ plugin.methods?.send(learnCard, {
315
+ type: 'boost',
316
+ recipient: 'userb',
317
+ templateUri: 'did:web:network.example:boost:1',
318
+ refresh: true,
319
+ })
320
+ ).rejects.toThrow('display-only anchor');
321
+
322
+ expect(learnCard.invoke.issueCredential).not.toHaveBeenCalled();
323
+ expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('signs normally from templateUri when the Boost has its own display object (not a display-only anchor)', async () => {
327
+ const client = getMockClient();
328
+ const learnCard = getMockIssuingLearnCard();
329
+ client.boost.getBoost.query.mockResolvedValue({
330
+ boost: getUnsignedBoostTemplate({
331
+ display: { backgroundColor: '#fff', backgroundImage: '', displayType: 'card' },
332
+ }),
333
+ });
334
+ const plugin = await getPlugin(learnCard, client);
335
+
336
+ await plugin.methods?.send(learnCard, {
337
+ type: 'boost',
338
+ recipient: 'userb',
339
+ templateUri: 'did:web:network.example:boost:1',
340
+ refresh: true,
341
+ });
342
+
343
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalled();
344
+ });
345
+
252
346
  it('prepares the inline boost before signing and reuses its prepared URI for delivery', async () => {
253
347
  const client = getMockClient();
254
348
  const learnCard = getMockIssuingLearnCard();
@@ -272,8 +366,10 @@ describe('unified send with refresh: true (managed branch)', () => {
272
366
  const forwarded = client.boost.send.mutate.mock.calls[0]?.[0] as any;
273
367
 
274
368
  expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledExactlyOnceWith({
369
+ statusBinding: 'server',
275
370
  recipient: 'userb',
276
- template,
371
+ templateUri: undefined,
372
+ display: undefined,
277
373
  contractUri: 'contract:1',
278
374
  credentialId: 'urn:uuid:inline-credential-id',
279
375
  });
@@ -297,6 +393,34 @@ describe('unified send with refresh: true (managed branch)', () => {
297
393
  expect(client.credentialRefresh.sendRefreshableCredential.mutate).not.toHaveBeenCalled();
298
394
  });
299
395
 
396
+ it('preserves inline external status without requesting server binding or allocating indexes', async () => {
397
+ const client = getMockClient();
398
+ const learnCard = getMockIssuingLearnCard();
399
+ const plugin = await getPlugin(learnCard, client);
400
+ const credentialStatus = {
401
+ ...SERVER_MANAGED_RESPONSE.refresh.credentialStatus,
402
+ id: 'https://provider.example/list#94567',
403
+ statusListCredential: 'https://provider.example/list',
404
+ };
405
+ const response = { ...SERVER_MANAGED_RESPONSE, statusManagement: 'external' as const };
406
+ client.boost.send.mutate.mockResolvedValue(response);
407
+ const result = await plugin.methods?.send(learnCard, {
408
+ type: 'boost',
409
+ recipient: 'userb',
410
+ template: { credential: getUnsignedBoostTemplate({ credentialStatus }) },
411
+ refresh: true,
412
+ statusPurposes: ['suspension'],
413
+ });
414
+ expect(client.boost.prepareRefreshableSend.mutate.mock.calls[0]?.[0]).not.toHaveProperty(
415
+ 'statusBinding'
416
+ );
417
+ expect(client.boost.allocateCredentialStatus.mutate).not.toHaveBeenCalled();
418
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
419
+ expect.objectContaining({ credentialStatus })
420
+ );
421
+ expect(result).toEqual(response);
422
+ });
423
+
300
424
  it('signs with the prepared credential ID, refresh service and holder DID', async () => {
301
425
  const client = getMockClient();
302
426
  const learnCard = getMockIssuingLearnCard();
@@ -420,7 +544,7 @@ describe('unified send with refresh: true (managed branch)', () => {
420
544
  expect(forwarded.signedCredential.credentialSubject.id).toEqual(defaultPrepared.holderDid);
421
545
  });
422
546
 
423
- it('includes claim data and integration in the keyed preparation fingerprint', async () => {
547
+ it('includes public integration metadata but not claim data in preparation', async () => {
424
548
  const client = getMockClient();
425
549
  const learnCard = getMockIssuingLearnCard();
426
550
  const plugin = await getPlugin(learnCard, client);
@@ -435,11 +559,13 @@ describe('unified send with refresh: true (managed branch)', () => {
435
559
  });
436
560
  expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledWith(
437
561
  expect.objectContaining({
438
- templateData: { grade: 'A' },
439
562
  integrationId: 'integration-1',
440
563
  idempotencyKey: 'award-1',
441
564
  })
442
565
  );
566
+ expect(client.boost.prepareRefreshableSend.mutate.mock.calls[0]?.[0]).not.toHaveProperty(
567
+ 'templateData'
568
+ );
443
569
  });
444
570
 
445
571
  it('delegates unchanged to the server signing-authority path when local signing is unavailable', async () => {
@@ -463,6 +589,25 @@ describe('unified send with refresh: true (managed branch)', () => {
463
589
  expect(learnCard.invoke.issueCredential).toBeUndefined();
464
590
  });
465
591
 
592
+ it('forwards statusPurposes to the server signing-authority path when local signing is unavailable', async () => {
593
+ const client = getMockClient();
594
+ const learnCard = getMockIssuingLearnCard();
595
+ delete learnCard.invoke.issueCredential;
596
+ const plugin = await getPlugin(learnCard, client);
597
+
598
+ await plugin.methods?.send(learnCard, {
599
+ type: 'boost',
600
+ recipient: 'userb',
601
+ templateUri: 'did:web:network.example:boost:1',
602
+ refresh: true,
603
+ statusPurposes: ['suspension'],
604
+ });
605
+
606
+ expect(client.boost.send.mutate).toHaveBeenCalledWith(
607
+ expect.objectContaining({ statusPurposes: ['suspension'] })
608
+ );
609
+ });
610
+
466
611
  it.each([
467
612
  {},
468
613
  { templateUri: 'did:web:network.example:boost:1' },
package/src/types.ts CHANGED
@@ -1,10 +1,14 @@
1
+ import type { CampaignAudienceResult } from '@learncard/types';
1
2
  import type { SendContractRequest, ConsentFlowContractRequestForProfile } from '@learncard/types';
2
3
  import type { LCNClient } from '@learncard/network-brain-client';
3
4
  import type {
4
5
  Campaign,
5
6
  CampaignAudienceInput,
7
+ AnnouncementPreferences,
6
8
  CampaignEvent,
7
9
  CampaignStats,
10
+ CampaignDispatchPreview,
11
+ CampaignDispatchOptions,
8
12
  CampaignStatus,
9
13
  CreateCampaignInput,
10
14
  UpdateCampaignInput,
@@ -14,6 +18,10 @@ import type {
14
18
  PaginatedCampaigns,
15
19
  PaginatedCampaignAudience,
16
20
  PublishCredentialSharePreview,
21
+ ConsentFlowContractChanges,
22
+ ConsentFlowContractUpdateResult,
23
+ ConsentFlowContractVersion,
24
+ ConsentFlowConsentStatus,
17
25
  } from '@learncard/types';
18
26
  import {
19
27
  LCNProfile,
@@ -80,7 +88,6 @@ import {
80
88
  PaginatedConsentFlowDataForDid,
81
89
  PaginatedContractCredentials,
82
90
  AutoBoostConfig,
83
- SendInput,
84
91
  SendResponse,
85
92
  AuthGrantType,
86
93
  AuthGrantQuery,
@@ -155,7 +162,11 @@ import {
155
162
  PublishCredentialRefreshInput,
156
163
  PublishCredentialRefreshResult,
157
164
  GetCredentialRefreshHistoryInput,
165
+ type GetCredentialRefreshReceiptInput,
166
+ type CredentialRefreshReceipt,
158
167
  GetCredentialRefreshHistoryResult,
168
+ type SendBoostInput,
169
+ type CredentialRefreshUpdateReason,
159
170
  } from '@learncard/types';
160
171
  import { LearnCard, Plugin } from '@learncard/core';
161
172
  import { ProofOptions } from '@learncard/didkit-plugin';
@@ -213,6 +224,32 @@ export type SendBoostResultFor<Options> = [EnableRefreshOf<Options>] extends [tr
213
224
  ? string | SendBoostRefreshResult
214
225
  : string;
215
226
 
227
+ /**
228
+ * `send` input extended with the LC-2345 holder-mode `encryption` field. A local
229
+ * intersection rather than a `@learncard/types` edit, because today the field is
230
+ * validated and enforced entirely client-side (rejected for inbox recipients,
231
+ * `signedCredential`, and servers that don't return `recipientKeyIds`).
232
+ */
233
+ export type SendBoostInputWithEncryption = SendBoostInput & {
234
+ /**
235
+ * `'holder'` requests student-only encryption: the SDK signs and encrypts
236
+ * locally so the server never receives plaintext. Requires `refresh: true` and a
237
+ * profile or DID recipient. Defaults to `'network'`.
238
+ */
239
+ encryption?: 'network' | 'holder';
240
+ };
241
+
242
+ /** Options for {@link LearnCardNetworkPluginMethods.publishHolderEncryptedRefreshable}. */
243
+ export type PublishHolderEncryptedRefreshableOptions = {
244
+ /** Applied locally with the same templating `send` uses; never sent to the server. */
245
+ templateData?: Record<string, unknown>;
246
+ /** Replaces server-inferred materiality: the server cannot compare encrypted content across versions. */
247
+ notify: boolean;
248
+ updateSummary?: string;
249
+ updateReason?: CredentialRefreshUpdateReason;
250
+ idempotencyKey?: string;
251
+ };
252
+
216
253
  /** @group LearnCardNetwork Plugin */
217
254
  export type LearnCardNetworkPluginDependentMethods = {
218
255
  getDidAuthVp: (options?: ProofOptions) => Promise<VP | string>;
@@ -222,6 +259,8 @@ export type LearnCardNetworkPluginDependentMethods = {
222
259
  ) => Promise<VC>;
223
260
  clearDidWebCache?: () => Promise<void>;
224
261
  createDagJwe: (cleartext: any, recipients: string[]) => Promise<JWE>;
262
+ /** Encrypts only to explicit recipients, without adding the caller. */
263
+ createDagJweExact?: <T>(cleartext: T, recipients: string[]) => Promise<JWE>;
225
264
  decryptDagJwe: (jwe: JWE, jwks: any[]) => Promise<any>;
226
265
  resolveDid: (did: string) => Promise<DidDocument>;
227
266
  };
@@ -611,6 +650,17 @@ export type LearnCardNetworkPluginMethods = {
611
650
  publishCredentialRefresh: (
612
651
  input: PublishCredentialRefreshInput
613
652
  ) => Promise<PublishCredentialRefreshResult>;
653
+ /**
654
+ * Holder-mode (LC-2345) counterpart of `publishCredentialRefresh`: builds and
655
+ * signs a new version locally from `receipt`'s bindings, encrypts it exactly to
656
+ * the current holder + manager recipients, and sends only the ciphertext
657
+ * handoff. Rejects when local signing is unavailable.
658
+ */
659
+ publishHolderEncryptedRefreshable: (
660
+ receipt: ManagedCredentialRefreshReceipt,
661
+ credential: UnsignedVC,
662
+ options: PublishHolderEncryptedRefreshableOptions
663
+ ) => Promise<PublishCredentialRefreshResult>;
614
664
  /**
615
665
  * Cursor-paginated, metadata-only issuer audit history for a managed refresh.
616
666
  * Never includes credential bodies or encrypted payloads.
@@ -618,6 +668,10 @@ export type LearnCardNetworkPluginMethods = {
618
668
  getCredentialRefreshHistory: (
619
669
  input: GetCredentialRefreshHistoryInput
620
670
  ) => Promise<GetCredentialRefreshHistoryResult>;
671
+ /** Read the authenticated public binding for a pending managed delivery. */
672
+ getCredentialRefreshReceipt: (
673
+ input: GetCredentialRefreshReceiptInput
674
+ ) => Promise<CredentialRefreshReceipt | null>;
621
675
  revokeBoostRecipient: (
622
676
  boostUri: string,
623
677
  recipientProfileId: string,
@@ -668,10 +722,14 @@ export type LearnCardNetworkPluginMethods = {
668
722
  ) => Promise<{ boostUri: string; challenge: string }>;
669
723
  claimBoostWithLink: (boostUri: string, challenge: string) => Promise<string>;
670
724
 
671
- send: (input: SendInput) => Promise<SendResponse>;
725
+ send: (input: SendBoostInputWithEncryption) => Promise<SendResponse>;
672
726
 
673
727
  createContract: (contract: {
674
728
  contract: ConsentFlowContract;
729
+ reasonForAccessing?: string;
730
+ needsGuardianConsent?: boolean;
731
+ redirectUrl?: string;
732
+ frontDoorBoostUri?: string;
675
733
  name: string;
676
734
  subtitle?: string;
677
735
  description?: string;
@@ -681,14 +739,42 @@ export type LearnCardNetworkPluginMethods = {
681
739
  recipients?: string[];
682
740
  autoboosts?: AutoBoostConfig[];
683
741
  }) => Promise<string>;
684
- addAutoBoostsToContract: (
685
- contractUri: string,
686
- autoboosts: AutoBoostConfig[]
687
- ) => Promise<boolean>;
688
- removeAutoBoostsFromContract: (contractUri: string, boostUris: string[]) => Promise<boolean>;
742
+ /**
743
+ * Updates a live request at the same URI, or previews it without saving.
744
+ * Scope maps merge with the current request. Set an entry to null to remove it;
745
+ * expiresAt: null clears expiry. AutoBoost and writer arrays replace their lists.
746
+ * Pass expectedVersion from the original draft to reject intervening publications.
747
+ * @example
748
+ * await learnCard.invoke.updateContract(uri, {
749
+ * read: { credentials: { categories: {
750
+ * Transcript: { required: true, reason: 'to calculate your GPA' },
751
+ * } } },
752
+ * }, { reason: 'We added transcripts so we can show your GPA on your résumé.' });
753
+ */
754
+ updateContract: (
755
+ uri: string,
756
+ changes: ConsentFlowContractChanges & NonNullable<ConsentFlowContractChanges['contract']>,
757
+ options?: {
758
+ reason?: string;
759
+ reconsentDeadline?: string;
760
+ dryRun?: boolean;
761
+ expectedVersion?: number;
762
+ }
763
+ ) => Promise<ConsentFlowContractUpdateResult>;
689
764
  addContractRecipient: (contractUri: string, recipient: string) => Promise<boolean>;
690
765
  removeContractRecipient: (contractUri: string, recipient: string) => Promise<boolean>;
766
+ /** Gets the current request, including its version.
767
+ * @example const contract = await learnCard.invoke.getContract(uri);
768
+ */
691
769
  getContract: (uri: string) => Promise<ConsentFlowContractDetails>;
770
+ /** Lists a request's saved versions, newest first.
771
+ * @example const versions = await learnCard.invoke.getContractVersions(uri);
772
+ */
773
+ getContractVersions: (uri: string) => Promise<ConsentFlowContractVersion[]>;
774
+ /** Checks a user's access state and provides a review link when approval is needed.
775
+ * @example const { needsReconsent, reviewUrl } = await learnCard.invoke.getConsentStatus(uri, profileId);
776
+ */
777
+ getConsentStatus: (uri: string, profileId: string) => Promise<ConsentFlowConsentStatus>;
692
778
  getContracts: (
693
779
  options?: Partial<PaginationOptionsType> & { query?: ConsentFlowContractQuery }
694
780
  ) => Promise<PaginatedConsentFlowContracts>;
@@ -736,6 +822,13 @@ export type LearnCardNetworkPluginMethods = {
736
822
  audienceVersion?: number;
737
823
  }
738
824
  ) => Promise<boolean>;
825
+ /** Accepts the reviewed update using the user's selected data.
826
+ * @example await learnCard.invoke.acceptContractUpdate(termsUri, { version: 2, terms, audienceVersion: currentContract.audienceVersion });
827
+ */
828
+ acceptContractUpdate: (
829
+ termsUri: string,
830
+ update: { version: number; terms: ConsentFlowTerms; audienceVersion?: number }
831
+ ) => Promise<boolean>;
739
832
  withdrawConsent: (uri: string) => Promise<boolean>;
740
833
  getConsentFlowTransactions: (
741
834
  uri: string,
@@ -1130,6 +1223,11 @@ export type LearnCardNetworkPluginMethods = {
1130
1223
  }>;
1131
1224
 
1132
1225
  isAppStoreAdmin: () => Promise<boolean>;
1226
+ getAnnouncementPreferences: () => Promise<AnnouncementPreferences>;
1227
+ updateAnnouncementPreferences: (
1228
+ updates: Partial<AnnouncementPreferences>
1229
+ ) => Promise<AnnouncementPreferences>;
1230
+ unsubscribeFromAnnouncements: (token: string) => Promise<boolean>;
1133
1231
  campaignsIsAdmin: () => Promise<boolean>;
1134
1232
  campaignsCreate: (input: CreateCampaignInput) => Promise<Campaign>;
1135
1233
  campaignsUpdate: (input: UpdateCampaignInput) => Promise<Campaign>;
@@ -1137,12 +1235,26 @@ export type LearnCardNetworkPluginMethods = {
1137
1235
  campaignsList: (input?: ListCampaignsInput) => Promise<PaginatedCampaigns>;
1138
1236
  campaignsDelete: (campaignId: string) => Promise<boolean>;
1139
1237
  campaignsSetStatus: (campaignId: string, status: CampaignStatus) => Promise<Campaign>;
1140
- campaignsPreviewAudience: (input: CampaignAudienceInput) => Promise<{ count: number }>;
1141
- campaignsAddAudience: (input: CampaignAudienceInput) => Promise<{ count: number }>;
1238
+ campaignsPreviewAudience: (input: CampaignAudienceInput) => Promise<CampaignAudienceResult>;
1239
+ campaignsAddAudience: (input: CampaignAudienceInput) => Promise<CampaignAudienceResult>;
1142
1240
  campaignsPageAudience: (input: PageCampaignAudienceInput) => Promise<PaginatedCampaignAudience>;
1143
1241
  campaignsGetStats: (campaignId: string) => Promise<CampaignStats>;
1242
+ /** Test selected channels only for the signed-in administrator, regardless of campaign status. */
1243
+ campaignsSendTestToMe: (campaignId: string) => Promise<boolean>;
1244
+ /** Start paced notification delivery for an eligible live campaign. */
1245
+ /** Preview eligible inbox recipients before confirming an audience notification send. */
1246
+ campaignsPreviewDispatch: (campaignId: string) => Promise<CampaignDispatchPreview>;
1247
+ /** Supply the preview token to reject stale or repeated confirmations. */
1248
+ campaignsDispatch: (
1249
+ campaignId: string,
1250
+ options?: CampaignDispatchOptions
1251
+ ) => Promise<{ dispatchId: string }>;
1144
1252
  getMyCampaigns: () => Promise<MyCampaign[]>;
1145
- recordCampaignEvent: (campaignId: string, event: CampaignEvent) => Promise<boolean>;
1253
+ recordCampaignEvent: (
1254
+ campaignId: string,
1255
+ event: CampaignEvent,
1256
+ testId?: string
1257
+ ) => Promise<boolean>;
1146
1258
  enrollInCampaign: (campaignId: string) => Promise<boolean>;
1147
1259
  adminUpdateListingStatus: (listingId: string, status: AppListingStatus) => Promise<boolean>;
1148
1260
  adminUpdatePromotionLevel: (