@acta-markets/ts-sdk 0.0.21-beta → 0.0.23-beta

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.
Files changed (36) hide show
  1. package/dist/chain/instructions.position.d.ts +1 -0
  2. package/dist/chain/instructions.position.js +1 -0
  3. package/dist/cjs/chain/instructions.position.js +1 -0
  4. package/dist/cjs/generated/errors/actaContract.js +4 -1
  5. package/dist/cjs/generated/instructions/settlePosition.js +11 -3
  6. package/dist/cjs/idl/acta_contract.json +11 -1
  7. package/dist/cjs/idl/hash.js +1 -1
  8. package/dist/cjs/ws/auth.js +14 -11
  9. package/dist/cjs/ws/client.js +100 -1
  10. package/dist/cjs/ws/client.test.js +1 -1
  11. package/dist/cjs/ws/flows.js +20 -0
  12. package/dist/cjs/ws/index.js +1 -0
  13. package/dist/cjs/ws/referral.js +57 -0
  14. package/dist/cjs/ws/referral.test.js +55 -0
  15. package/dist/generated/errors/actaContract.d.ts +3 -1
  16. package/dist/generated/errors/actaContract.js +3 -0
  17. package/dist/generated/instructions/settlePosition.d.ts +5 -1
  18. package/dist/generated/instructions/settlePosition.js +11 -3
  19. package/dist/idl/acta_contract.json +11 -1
  20. package/dist/idl/hash.d.ts +1 -1
  21. package/dist/idl/hash.js +1 -1
  22. package/dist/ws/auth.d.ts +10 -7
  23. package/dist/ws/auth.js +14 -11
  24. package/dist/ws/client.d.ts +40 -1
  25. package/dist/ws/client.js +101 -2
  26. package/dist/ws/client.test.js +1 -1
  27. package/dist/ws/flows.d.ts +13 -1
  28. package/dist/ws/flows.js +19 -0
  29. package/dist/ws/index.d.ts +1 -0
  30. package/dist/ws/index.js +1 -0
  31. package/dist/ws/referral.d.ts +53 -0
  32. package/dist/ws/referral.js +51 -0
  33. package/dist/ws/referral.test.d.ts +1 -0
  34. package/dist/ws/referral.test.js +53 -0
  35. package/dist/ws/types.d.ts +142 -48
  36. package/package.json +1 -1
package/dist/ws/auth.d.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  * - Client signs UTF-8 bytes of that text and responds:
7
7
  * `AuthChallenge { challenge, signature: base58(ed25519(utf8(challenge))), pubkey }`
8
8
  *
9
+ * The same `signMessage` primitive is reused for any UTF-8-bytes signing
10
+ * (e.g. `acta:redeem:v1:{pubkey}:{code}` for invite redemption).
11
+ *
9
12
  * Source of truth (server):
10
13
  * - rust-backend/rfq-server/src/server/ws.rs
11
14
  * - rust-backend/rfq-server/src/session/handler.rs
@@ -13,8 +16,8 @@
13
16
  export interface AuthProvider {
14
17
  /** Base58-encoded public key (address). */
15
18
  getPublicKey(): Promise<string>;
16
- /** Base58-encoded ed25519 signature over UTF-8 bytes of the challenge text. */
17
- signChallenge(challenge: string): Promise<string>;
19
+ /** Base58-encoded ed25519 signature over UTF-8 bytes of the message. */
20
+ signMessage(message: string): Promise<string>;
18
21
  }
19
22
  /**
20
23
  * Auth provider backed by a `CryptoKeyPair`.
@@ -31,7 +34,7 @@ export declare class KeypairAuthProvider implements AuthProvider {
31
34
  /** Create from Solana CLI keypair JSON (array of numbers). */
32
35
  static fromJson(json: number[] | string): Promise<KeypairAuthProvider>;
33
36
  getPublicKey(): Promise<string>;
34
- signChallenge(challenge: string): Promise<string>;
37
+ signMessage(message: string): Promise<string>;
35
38
  }
36
39
  /**
37
40
  * Wallet-like provider (browser wallets, custom signers, etc.)
@@ -43,7 +46,7 @@ export declare class WalletAuthProvider implements AuthProvider {
43
46
  private readonly wallet;
44
47
  constructor(wallet: WalletLike);
45
48
  getPublicKey(): Promise<string>;
46
- signChallenge(challenge: string): Promise<string>;
49
+ signMessage(message: string): Promise<string>;
47
50
  }
48
51
  export type WalletLike = {
49
52
  /** Preferred: base58 address string. */
@@ -60,8 +63,8 @@ export type WalletLike = {
60
63
  */
61
64
  export declare class CustomAuthProvider implements AuthProvider {
62
65
  private readonly getPublicKeyFn;
63
- private readonly signChallengeFn;
64
- constructor(getPublicKey: () => Promise<string>, signChallenge: (challenge: string) => Promise<string>);
66
+ private readonly signMessageFn;
67
+ constructor(getPublicKey: () => Promise<string>, signMessage: (message: string) => Promise<string>);
65
68
  getPublicKey(): Promise<string>;
66
- signChallenge(challenge: string): Promise<string>;
69
+ signMessage(message: string): Promise<string>;
67
70
  }
package/dist/ws/auth.js CHANGED
@@ -6,6 +6,9 @@
6
6
  * - Client signs UTF-8 bytes of that text and responds:
7
7
  * `AuthChallenge { challenge, signature: base58(ed25519(utf8(challenge))), pubkey }`
8
8
  *
9
+ * The same `signMessage` primitive is reused for any UTF-8-bytes signing
10
+ * (e.g. `acta:redeem:v1:{pubkey}:{code}` for invite redemption).
11
+ *
9
12
  * Source of truth (server):
10
13
  * - rust-backend/rfq-server/src/server/ws.rs
11
14
  * - rust-backend/rfq-server/src/session/handler.rs
@@ -49,9 +52,9 @@ export class KeypairAuthProvider {
49
52
  async getPublicKey() {
50
53
  return this.address;
51
54
  }
52
- async signChallenge(challenge) {
53
- const challengeBytes = utf8ToBytes(challenge);
54
- const signatureBytes = await signBytes(this.privateKey, challengeBytes);
55
+ async signMessage(message) {
56
+ const messageBytes = utf8ToBytes(message);
57
+ const signatureBytes = await signBytes(this.privateKey, messageBytes);
55
58
  return bytesToBase58(signatureBytes);
56
59
  }
57
60
  }
@@ -72,10 +75,10 @@ export class WalletAuthProvider {
72
75
  throw new Error("Wallet not connected (missing public key)");
73
76
  return pk;
74
77
  }
75
- async signChallenge(challenge) {
76
- const challengeBytes = utf8ToBytes(challenge);
78
+ async signMessage(message) {
79
+ const messageBytes = utf8ToBytes(message);
77
80
  // Some wallet APIs expect a mutable `Uint8Array`; codecs return `ReadonlyUint8Array`.
78
- const sig = await this.wallet.signMessage(new Uint8Array(challengeBytes));
81
+ const sig = await this.wallet.signMessage(new Uint8Array(messageBytes));
79
82
  return bytesToBase58(sig);
80
83
  }
81
84
  }
@@ -84,15 +87,15 @@ export class WalletAuthProvider {
84
87
  */
85
88
  export class CustomAuthProvider {
86
89
  getPublicKeyFn;
87
- signChallengeFn;
88
- constructor(getPublicKey, signChallenge) {
90
+ signMessageFn;
91
+ constructor(getPublicKey, signMessage) {
89
92
  this.getPublicKeyFn = getPublicKey;
90
- this.signChallengeFn = signChallenge;
93
+ this.signMessageFn = signMessage;
91
94
  }
92
95
  async getPublicKey() {
93
96
  return this.getPublicKeyFn();
94
97
  }
95
- async signChallenge(challenge) {
96
- return this.signChallengeFn(challenge);
98
+ async signMessage(message) {
99
+ return this.signMessageFn(message);
97
100
  }
98
101
  }
@@ -2,7 +2,7 @@
2
2
  import type { AuthProvider } from "./auth";
3
3
  import type { SignerLike } from "../chain/orders";
4
4
  import type { Address } from "@solana/addresses";
5
- import type { ActiveRfqInfo, ChainEventMessage, EarnSummaryData, TokenMarketsInfoData, GlobalStats, MarketDescriptorInfo, MarketInfo, MyActiveRfqInfo, MyActiveRfqsMessage, OrderStatusMessage, PositionInfo, QuoteAcknowledgedMessage, QuoteBestStatusMessage, QuoteCancelledMessage, QuoteMessage, QuoteRefreshRequestedMessage, QuoteOutbidMessage, QuoteReceivedMessage, QuoteSelectedMessage, QuotesUpdateMessage, RfqBroadcastMessage, RfqClosedMessage, RfqCreatedMessage, RfqSkippedMessage, RfqRequestMessage, RfqAvailableAgainMessage, QuoteExpiredMessage, QuoteFilledMessage, IndicativePricesMessage, IndicativePricesRequestMessage, IndicativePricesResponseMessage, GetIndicativePricesMessage, MakerBalancesMessage, MakerMarketsMessage, MakerPositionsMessage, MyCapsMessage, MyQuotesMessage, MyTradesMessage, RequestId, ServerError, ServerMessage, SnapshotMessage, StatsDelta, SubscriptionsMessage, TokenInfo, TradeInfo, UuidString, VersionMismatchMessage, WelcomeMessage, WsChannel } from "./types";
5
+ import type { ActiveRfqInfo, ChainEventMessage, EarnSummaryData, TokenMarketsInfoData, GlobalStats, MarketDescriptorInfo, MarketInfo, MyActiveRfqInfo, MyActiveRfqsMessage, OrderStatusMessage, PositionInfo, QuoteAcknowledgedMessage, QuoteBestStatusMessage, QuoteCancelledMessage, QuoteMessage, ReplaceQuoteMessage, QuoteRefreshRequestedMessage, QuoteOutbidMessage, QuoteReceivedMessage, QuoteSelectedMessage, QuotesUpdateMessage, RfqBroadcastMessage, RfqClosedMessage, RfqCreatedMessage, RfqSkippedMessage, RfqRequestMessage, RfqAvailableAgainMessage, QuoteExpiredMessage, QuoteFilledMessage, IndicativePricesMessage, IndicativePricesRequestMessage, IndicativePricesResponseMessage, GetIndicativePricesMessage, MakerBalancesMessage, MakerMarketsMessage, MakerPositionsMessage, MyCapsMessage, MyQuotesMessage, MyTradesMessage, RequestId, ServerError, ServerMessage, SnapshotMessage, StatsDelta, SubscriptionsMessage, TokenInfo, TradeInfo, UuidString, VersionMismatchMessage, WelcomeMessage, WsChannel, InviteRedeemedData, ReferralCodeClaimedData, MyReferralInfoData } from "./types";
6
6
  export type ConnectionState = "disconnected" | "connecting" | "authenticating" | "authenticated" | "error";
7
7
  export type ClientRole = "taker" | "maker";
8
8
  export type PendingMessagesOverflowPolicy = "drop_oldest" | "drop_newest" | "throw";
@@ -145,6 +145,10 @@ export type ActaWsClientEvents = {
145
145
  positionSettled: (event: Extract<ChainEventMessage, {
146
146
  event_type: "PositionSettled";
147
147
  }>) => void;
148
+ requireInvite: () => void;
149
+ inviteRedeemed: (data: InviteRedeemedData) => void;
150
+ referralCodeClaimed: (data: ReferralCodeClaimedData) => void;
151
+ myReferralInfo: (data: MyReferralInfoData) => void;
148
152
  };
149
153
  type EventMap = Record<string, (...args: any[]) => void>;
150
154
  declare class TypedEventEmitter<TEvents extends EventMap> {
@@ -277,6 +281,18 @@ export declare class ActaWsClient extends TypedEventEmitter<ActaWsClientEvents>
277
281
  active_only?: boolean;
278
282
  }): RequestId;
279
283
  logout(): void;
284
+ /**
285
+ * Redeem an invite code. Authentication is proven by the session;
286
+ * no additional signature is required. Validation runs client-side —
287
+ * invalid inputs throw `ReferralCodeError` without a round-trip.
288
+ */
289
+ redeemInvite(rawCode: string): RequestId;
290
+ /**
291
+ * Claim a vanity referral code (one-shot per taker). Validation
292
+ * runs client-side — invalid inputs throw `ReferralCodeError`.
293
+ */
294
+ claimReferralCode(rawCode: string): Promise<RequestId>;
295
+ getMyReferralInfo(): RequestId;
280
296
  getOrderStatus(orderIdHex: string): RequestId;
281
297
  cancelRfq(rfqId: string): RequestId;
282
298
  submitQuote(quote: QuoteMessage): void;
@@ -295,6 +311,27 @@ export declare class ActaWsClient extends TypedEventEmitter<ActaWsClientEvents>
295
311
  orderId: Uint8Array;
296
312
  makerSigner: SignerLike;
297
313
  }): Promise<void>;
314
+ /**
315
+ * Replace an in-flight quote on the same RFQ. Server treats this as
316
+ * cancel-old + place-new atomically; maker signs the NEW `order_id`.
317
+ */
318
+ submitReplaceQuote(msg: ReplaceQuoteMessage): void;
319
+ /** Convenience: compute new `order_id`, sign it, and send `ReplaceQuote`. */
320
+ submitReplaceQuoteSigned(args: {
321
+ oldOrderId: Uint8Array;
322
+ rfqId: string;
323
+ strike: number;
324
+ price: number;
325
+ validUntil: number;
326
+ nonce: number;
327
+ orderId: Uint8Array;
328
+ makerSigner: SignerLike;
329
+ }): Promise<void>;
330
+ /**
331
+ * Send multiple signed quotes in one WS frame. Each entry must already be
332
+ * a fully-built `QuoteMessage` (use `buildSignedQuoteMessage` per quote).
333
+ */
334
+ submitBatchQuotes(quotes: QuoteMessage[]): void;
298
335
  cancelQuote(rfqId: string): RequestId;
299
336
  subscribe(channels: WsChannel[], opts?: {
300
337
  underlying_mints?: string[];
@@ -311,6 +348,8 @@ export declare class ActaWsClient extends TypedEventEmitter<ActaWsClientEvents>
311
348
  }): RequestId;
312
349
  addChannels(channels: WsChannel[]): RequestId;
313
350
  removeChannels(channels: WsChannel[]): RequestId;
351
+ /** Query the server's view of this session's subscriptions. Response: `subscriptions` event. */
352
+ getSubscriptions(): RequestId;
314
353
  ping(): void;
315
354
  resumeAuth(sessionId: string): void;
316
355
  private doConnect;
package/dist/ws/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Acta WebSocket client (rfq-server). */
2
- import { buildSignedQuoteMessage, buildAcceptQuoteMessage } from "./flows";
2
+ import { buildSignedQuoteMessage, buildSignedReplaceQuoteMessage, buildAcceptQuoteMessage, } from "./flows";
3
3
  import { assertWsU64Safe, validateQuantityBySizeRule } from "./wirePolicy";
4
+ import { parseReferralCode, ReferralCodeError } from "./referral";
4
5
  function toGenericServerError(err) {
5
6
  const message = err instanceof Error ? err.message : String(err);
6
7
  return { type: "generic", data: { code: "client_error", message } };
@@ -403,6 +404,51 @@ export class ActaWsClient extends TypedEventEmitter {
403
404
  logout() {
404
405
  this.send({ type: "Logout" });
405
406
  }
407
+ // ========================================================================
408
+ // Referral / invite
409
+ // ========================================================================
410
+ /**
411
+ * Redeem an invite code. Authentication is proven by the session;
412
+ * no additional signature is required. Validation runs client-side —
413
+ * invalid inputs throw `ReferralCodeError` without a round-trip.
414
+ */
415
+ redeemInvite(rawCode) {
416
+ this.ensureAuthenticated();
417
+ const parsed = parseReferralCode(rawCode);
418
+ if (!parsed.ok)
419
+ throw new ReferralCodeError(parsed.error);
420
+ const requestId = this.nextRequestId();
421
+ this.send({
422
+ type: "RedeemInvite",
423
+ data: { request_id: requestId, code: parsed.code },
424
+ });
425
+ return requestId;
426
+ }
427
+ /**
428
+ * Claim a vanity referral code (one-shot per taker). Validation
429
+ * runs client-side — invalid inputs throw `ReferralCodeError`.
430
+ */
431
+ async claimReferralCode(rawCode) {
432
+ this.ensureAuthenticated();
433
+ const parsed = parseReferralCode(rawCode);
434
+ if (!parsed.ok)
435
+ throw new ReferralCodeError(parsed.error);
436
+ const requestId = this.nextRequestId();
437
+ this.send({
438
+ type: "ClaimReferralCode",
439
+ data: { request_id: requestId, code: parsed.code },
440
+ });
441
+ return requestId;
442
+ }
443
+ getMyReferralInfo() {
444
+ this.ensureAuthenticated();
445
+ const requestId = this.nextRequestId();
446
+ this.send({
447
+ type: "GetMyReferralInfo",
448
+ data: { request_id: requestId },
449
+ });
450
+ return requestId;
451
+ }
406
452
  getOrderStatus(orderIdHex) {
407
453
  this.ensureAuthenticated();
408
454
  const requestId = this.nextRequestId();
@@ -447,6 +493,40 @@ export class ActaWsClient extends TypedEventEmitter {
447
493
  });
448
494
  this.submitQuote(quote);
449
495
  }
496
+ /**
497
+ * Replace an in-flight quote on the same RFQ. Server treats this as
498
+ * cancel-old + place-new atomically; maker signs the NEW `order_id`.
499
+ */
500
+ submitReplaceQuote(msg) {
501
+ this.ensureAuthenticated();
502
+ this.send({ type: "ReplaceQuote", data: msg });
503
+ }
504
+ /** Convenience: compute new `order_id`, sign it, and send `ReplaceQuote`. */
505
+ async submitReplaceQuoteSigned(args) {
506
+ assertWsU64Safe(args.strike, "strike");
507
+ assertWsU64Safe(args.price, "price");
508
+ assertWsU64Safe(args.validUntil, "validUntil");
509
+ assertWsU64Safe(args.nonce, "nonce");
510
+ const msg = await buildSignedReplaceQuoteMessage({
511
+ oldOrderId: args.oldOrderId,
512
+ rfqId: args.rfqId,
513
+ strike: args.strike,
514
+ price: args.price,
515
+ validUntil: args.validUntil,
516
+ nonce: args.nonce,
517
+ orderId: args.orderId,
518
+ makerSigner: args.makerSigner,
519
+ });
520
+ this.submitReplaceQuote(msg);
521
+ }
522
+ /**
523
+ * Send multiple signed quotes in one WS frame. Each entry must already be
524
+ * a fully-built `QuoteMessage` (use `buildSignedQuoteMessage` per quote).
525
+ */
526
+ submitBatchQuotes(quotes) {
527
+ this.ensureAuthenticated();
528
+ this.send({ type: "BatchQuotes", data: { quotes } });
529
+ }
450
530
  cancelQuote(rfqId) {
451
531
  this.ensureAuthenticated();
452
532
  const requestId = this.nextRequestId();
@@ -540,6 +620,13 @@ export class ActaWsClient extends TypedEventEmitter {
540
620
  this.send({ type: "RemoveChannels", data: { request_id, channels } });
541
621
  return request_id;
542
622
  }
623
+ /** Query the server's view of this session's subscriptions. Response: `subscriptions` event. */
624
+ getSubscriptions() {
625
+ this.ensureAuthenticated();
626
+ const request_id = this.nextRequestId();
627
+ this.send({ type: "GetSubscriptions", data: { request_id } });
628
+ return request_id;
629
+ }
543
630
  ping() {
544
631
  if (this.ws?.readyState === WS_OPEN) {
545
632
  this.send({ type: "Ping" });
@@ -874,6 +961,18 @@ export class ActaWsClient extends TypedEventEmitter {
874
961
  this.emit("subscriptionUpdated", d);
875
962
  }
876
963
  break;
964
+ case "RequireInvite":
965
+ this.emit("requireInvite");
966
+ break;
967
+ case "InviteRedeemed":
968
+ this.emit("inviteRedeemed", message.data);
969
+ break;
970
+ case "ReferralCodeClaimed":
971
+ this.emit("referralCodeClaimed", message.data);
972
+ break;
973
+ case "MyReferralInfo":
974
+ this.emit("myReferralInfo", message.data);
975
+ break;
877
976
  }
878
977
  }
879
978
  /** Taker-only: request current indicative prices for a market + position_type. */
@@ -899,7 +998,7 @@ export class ActaWsClient extends TypedEventEmitter {
899
998
  try {
900
999
  const [pubkey, signature] = await Promise.all([
901
1000
  this.authProvider.getPublicKey(),
902
- this.authProvider.signChallenge(challenge),
1001
+ this.authProvider.signMessage(challenge),
903
1002
  ]);
904
1003
  this.send({
905
1004
  type: "AuthChallenge",
@@ -51,7 +51,7 @@ function parseClientMessage(payload) {
51
51
  function makeAuthProvider(pubkey = "pubkey", signature = "signature") {
52
52
  return {
53
53
  getPublicKey: jest.fn().mockResolvedValue(pubkey),
54
- signChallenge: jest.fn().mockResolvedValue(signature),
54
+ signMessage: jest.fn().mockResolvedValue(signature),
55
55
  };
56
56
  }
57
57
  function makeHarness(overrides = {}) {
@@ -4,7 +4,7 @@
4
4
  * - WS wire uses hex string for `order_id` and base58 for signatures
5
5
  */
6
6
  import type { Address } from "@solana/addresses";
7
- import type { AcceptQuoteMessage, QuoteMessage, RfqBroadcastMessage } from "./types";
7
+ import type { AcceptQuoteMessage, QuoteMessage, ReplaceQuoteMessage, RfqBroadcastMessage } from "./types";
8
8
  import type { SignerLike } from "../chain/orders";
9
9
  export declare function buildSignedQuoteMessage(args: {
10
10
  rfqId: string;
@@ -15,6 +15,18 @@ export declare function buildSignedQuoteMessage(args: {
15
15
  orderId: Uint8Array;
16
16
  makerSigner: SignerLike;
17
17
  }): Promise<QuoteMessage>;
18
+ export declare function buildSignedReplaceQuoteMessage(args: {
19
+ /** orderId of the quote being replaced (32 bytes). */
20
+ oldOrderId: Uint8Array;
21
+ rfqId: string;
22
+ strike: number;
23
+ price: number;
24
+ validUntil: number;
25
+ nonce: number;
26
+ /** NEW 32-byte orderId for the replacement quote. */
27
+ orderId: Uint8Array;
28
+ makerSigner: SignerLike;
29
+ }): Promise<ReplaceQuoteMessage>;
18
30
  /**
19
31
  * Strict maker helper: build a fully-valid quote from a server RFQ broadcast.
20
32
  *
package/dist/ws/flows.js CHANGED
@@ -24,6 +24,25 @@ export async function buildSignedQuoteMessage(args) {
24
24
  signature: signatureBase58,
25
25
  };
26
26
  }
27
+ export async function buildSignedReplaceQuoteMessage(args) {
28
+ assertWsU64Safe(args.strike, "strike");
29
+ assertWsU64Safe(args.price, "price");
30
+ assertWsU64Safe(args.validUntil, "validUntil");
31
+ assertWsU64Safe(args.nonce, "nonce");
32
+ assertOrderId32(args.oldOrderId);
33
+ assertOrderId32(args.orderId);
34
+ const { signatureBase58 } = await signOrderIdBase58(args.makerSigner, args.orderId);
35
+ return {
36
+ old_order_id: orderIdToHex(args.oldOrderId),
37
+ rfq_id: args.rfqId,
38
+ strike: args.strike,
39
+ price: args.price,
40
+ valid_until: args.validUntil,
41
+ nonce: args.nonce,
42
+ order_id: orderIdToHex(args.orderId),
43
+ signature: signatureBase58,
44
+ };
45
+ }
27
46
  function positionTypeToU8(positionType) {
28
47
  switch (positionType) {
29
48
  case "covered_call":
@@ -6,3 +6,4 @@ export * from "./sponsoredTx";
6
6
  export * from "./apy";
7
7
  export * from "./discovery";
8
8
  export * from "./sizing";
9
+ export * from "./referral";
package/dist/ws/index.js CHANGED
@@ -6,3 +6,4 @@ export * from "./sponsoredTx";
6
6
  export * from "./apy";
7
7
  export * from "./discovery";
8
8
  export * from "./sizing";
9
+ export * from "./referral";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Referral / invite redemption helpers.
3
+ *
4
+ * Mirrors the server-side validation defined in
5
+ * rust-backend/acta-types/src/invite.rs (ReferralCode::parse).
6
+ * Client-side validation must stay bit-identical to the server on
7
+ * the same input. A hardcoded fixture table is kept here and on the
8
+ * rust side; if either drifts, the cross-impl test will catch it.
9
+ */
10
+ export declare const REFERRAL_CODE_MIN_LEN = 4;
11
+ export declare const REFERRAL_CODE_MAX_LEN = 16;
12
+ declare const brand: unique symbol;
13
+ /**
14
+ * A normalized, validated referral code. The only way to obtain one
15
+ * is through `parseReferralCode`, which enforces trim + ASCII
16
+ * uppercase and length/charset bounds identical to the server's
17
+ * `ReferralCode::parse`.
18
+ */
19
+ export type ReferralCode = string & {
20
+ readonly [brand]: "ReferralCode";
21
+ };
22
+ export type ReferralCodeFormatError = {
23
+ kind: "length";
24
+ min: number;
25
+ max: number;
26
+ } | {
27
+ kind: "charset";
28
+ };
29
+ export declare class ReferralCodeError extends Error {
30
+ readonly detail: ReferralCodeFormatError;
31
+ constructor(detail: ReferralCodeFormatError);
32
+ }
33
+ /**
34
+ * Trim + ASCII uppercase. No length or charset check. Useful for
35
+ * showing a live preview as the user types.
36
+ */
37
+ export declare function normalizeReferralCode(input: string): string;
38
+ /**
39
+ * Parse + validate + normalize a user-supplied referral code.
40
+ * Mirrors `ReferralCode::parse` on the server. A successful result
41
+ * carries the canonical branded `ReferralCode`.
42
+ */
43
+ export declare function parseReferralCode(input: string): {
44
+ ok: true;
45
+ code: ReferralCode;
46
+ } | {
47
+ ok: false;
48
+ error: ReferralCodeFormatError;
49
+ };
50
+ export type InviteErrorReason = "invalid_code" | "code_exhausted" | "code_expired" | "code_owner_inactive" | "code_owner_blacklisted" | "already_registered" | "internal_error";
51
+ export type ClaimErrorReason = "not_registered" | "invalid_format" | "code_taken" | "reserved" | "internal_error";
52
+ export type TakerStatus = "pending" | "active";
53
+ export {};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Referral / invite redemption helpers.
3
+ *
4
+ * Mirrors the server-side validation defined in
5
+ * rust-backend/acta-types/src/invite.rs (ReferralCode::parse).
6
+ * Client-side validation must stay bit-identical to the server on
7
+ * the same input. A hardcoded fixture table is kept here and on the
8
+ * rust side; if either drifts, the cross-impl test will catch it.
9
+ */
10
+ export const REFERRAL_CODE_MIN_LEN = 4;
11
+ export const REFERRAL_CODE_MAX_LEN = 16;
12
+ export class ReferralCodeError extends Error {
13
+ detail;
14
+ constructor(detail) {
15
+ super(detail.kind === "length"
16
+ ? `code length must be between ${detail.min} and ${detail.max}`
17
+ : "code must contain only ASCII letters and digits");
18
+ this.detail = detail;
19
+ this.name = "ReferralCodeError";
20
+ }
21
+ }
22
+ /**
23
+ * Trim + ASCII uppercase. No length or charset check. Useful for
24
+ * showing a live preview as the user types.
25
+ */
26
+ export function normalizeReferralCode(input) {
27
+ return input.trim().toUpperCase();
28
+ }
29
+ /**
30
+ * Parse + validate + normalize a user-supplied referral code.
31
+ * Mirrors `ReferralCode::parse` on the server. A successful result
32
+ * carries the canonical branded `ReferralCode`.
33
+ */
34
+ export function parseReferralCode(input) {
35
+ const normalized = normalizeReferralCode(input);
36
+ if (normalized.length < REFERRAL_CODE_MIN_LEN ||
37
+ normalized.length > REFERRAL_CODE_MAX_LEN) {
38
+ return {
39
+ ok: false,
40
+ error: {
41
+ kind: "length",
42
+ min: REFERRAL_CODE_MIN_LEN,
43
+ max: REFERRAL_CODE_MAX_LEN,
44
+ },
45
+ };
46
+ }
47
+ if (!/^[A-Z0-9]+$/.test(normalized)) {
48
+ return { ok: false, error: { kind: "charset" } };
49
+ }
50
+ return { ok: true, code: normalized };
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { normalizeReferralCode, parseReferralCode } from "./referral";
2
+ // =============================================================================
3
+ // Cross-impl fixture.
4
+ //
5
+ // Bit-for-bit identical to the fixture in
6
+ // rust-backend/acta-types/src/invite.rs::tests
7
+ // If either side changes, update both.
8
+ // =============================================================================
9
+ const ACCEPT_FIXTURES = [
10
+ ["NIKITA", "NIKITA"],
11
+ ["nikita", "NIKITA"],
12
+ [" abc1 ", "ABC1"],
13
+ ["ABCD", "ABCD"],
14
+ ["1234567890ABCDEF", "1234567890ABCDEF"],
15
+ ];
16
+ describe("parseReferralCode", () => {
17
+ test.each(ACCEPT_FIXTURES)("accepts %j → %j", (input, expected) => {
18
+ const res = parseReferralCode(input);
19
+ expect(res.ok).toBe(true);
20
+ if (res.ok)
21
+ expect(res.code).toBe(expected);
22
+ });
23
+ test("rejects too short", () => {
24
+ const res = parseReferralCode("abc");
25
+ expect(res.ok).toBe(false);
26
+ if (!res.ok)
27
+ expect(res.error.kind).toBe("length");
28
+ });
29
+ test("rejects too long", () => {
30
+ const res = parseReferralCode("A".repeat(17));
31
+ expect(res.ok).toBe(false);
32
+ if (!res.ok)
33
+ expect(res.error.kind).toBe("length");
34
+ });
35
+ test("rejects non-ascii", () => {
36
+ const res = parseReferralCode("абвгд");
37
+ expect(res.ok).toBe(false);
38
+ // Cyrillic passes length but fails charset.
39
+ if (!res.ok)
40
+ expect(res.error.kind).toBe("charset");
41
+ });
42
+ test("rejects punctuation", () => {
43
+ const res = parseReferralCode("ABC-12");
44
+ expect(res.ok).toBe(false);
45
+ if (!res.ok)
46
+ expect(res.error.kind).toBe("charset");
47
+ });
48
+ });
49
+ describe("normalizeReferralCode", () => {
50
+ test("trims and uppercases", () => {
51
+ expect(normalizeReferralCode(" nikita ")).toBe("NIKITA");
52
+ });
53
+ });