@mpgd/game-services 0.15.3 → 0.17.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,247 @@
1
+ /** Error messages intentionally contain no token, proof, or installation ID. */
2
+ export class GuestSessionCoordinatorError extends Error {
3
+ code;
4
+ constructor(code) {
5
+ super(`Guest session operation failed: ${code}.`);
6
+ this.name = 'GuestSessionCoordinatorError';
7
+ this.code = code;
8
+ }
9
+ }
10
+ const defaultCredentialKey = 'mpgd.guest.refresh';
11
+ /**
12
+ * Serializes token mutation and closes synchronously on logout. Native secure
13
+ * storage failures never fall back to ordinary game storage or mint a new guest.
14
+ */
15
+ export function createGuestSessionCoordinator(input) {
16
+ if (typeof input.installationId !== 'string' || input.installationId.trim() === '') {
17
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
18
+ }
19
+ const credentialKey = input.credentialKey ?? defaultCredentialKey;
20
+ if (credentialKey.trim() === '') {
21
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
22
+ }
23
+ const now = input.now ?? Date.now;
24
+ let current;
25
+ let status = 'idle';
26
+ let tail = Promise.resolve();
27
+ let startInFlight;
28
+ let refreshInFlight;
29
+ let logoutInFlight;
30
+ function enqueue(operation) {
31
+ const next = tail.then(operation, operation);
32
+ tail = next.then(() => undefined, () => undefined);
33
+ return next;
34
+ }
35
+ function assertOpen() {
36
+ if (status === 'closed') {
37
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_CLOSED');
38
+ }
39
+ }
40
+ function requireCurrent() {
41
+ if (status !== 'active' || current === undefined) {
42
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_NOT_READY');
43
+ }
44
+ return current;
45
+ }
46
+ async function loadStoredRefreshToken() {
47
+ try {
48
+ const stored = await input.credentials.load({ key: credentialKey });
49
+ if (stored !== null && (typeof stored !== 'string' || stored === '')) {
50
+ throw new Error('Invalid stored refresh token.');
51
+ }
52
+ return stored;
53
+ }
54
+ catch {
55
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_CREDENTIAL_LOAD_FAILED');
56
+ }
57
+ }
58
+ async function fromBackend(operation) {
59
+ try {
60
+ return await operation();
61
+ }
62
+ catch {
63
+ // Backend exceptions can contain URLs, proofs, or tokens. The public
64
+ // coordinator surface intentionally exposes only a stable error code.
65
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_BACKEND_FAILED');
66
+ }
67
+ }
68
+ async function persist(next) {
69
+ try {
70
+ await input.credentials.save({ key: credentialKey, value: next.refreshToken });
71
+ }
72
+ catch {
73
+ // Keep the latest token only for an explicit revoke; never expose it as
74
+ // an active session after native persistence failed.
75
+ current = next;
76
+ if (status !== 'closed') {
77
+ status = 'failed';
78
+ }
79
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_CREDENTIAL_SAVE_FAILED');
80
+ }
81
+ current = next;
82
+ if (status !== 'closed') {
83
+ status = 'active';
84
+ }
85
+ }
86
+ return {
87
+ async start() {
88
+ assertOpen();
89
+ if (status === 'active' && current !== undefined) {
90
+ return Promise.resolve(view(current));
91
+ }
92
+ if (startInFlight !== undefined) {
93
+ return startInFlight;
94
+ }
95
+ if (status === 'failed') {
96
+ return Promise.reject(new GuestSessionCoordinatorError('GUEST_SESSION_NOT_READY'));
97
+ }
98
+ startInFlight = enqueue(async () => {
99
+ assertOpen();
100
+ const stored = await loadStoredRefreshToken();
101
+ const next = validateSession(stored === null
102
+ ? await fromBackend(() => input.backend.issueGuest({ installationId: input.installationId }))
103
+ : await fromBackend(() => input.backend.refresh({ refreshToken: stored })));
104
+ await persist(next);
105
+ assertOpen();
106
+ return view(next);
107
+ }).finally(() => { startInFlight = undefined; });
108
+ return startInFlight;
109
+ },
110
+ async refresh() {
111
+ assertOpen();
112
+ if (refreshInFlight !== undefined) {
113
+ return refreshInFlight;
114
+ }
115
+ refreshInFlight = enqueue(async () => {
116
+ const previous = requireCurrent();
117
+ const next = validateSession(await fromBackend(() => input.backend.refresh({
118
+ refreshToken: previous.refreshToken,
119
+ })));
120
+ assertSameOwner(previous, next);
121
+ await persist(next);
122
+ assertOpen();
123
+ return view(next);
124
+ }).finally(() => { refreshInFlight = undefined; });
125
+ return refreshInFlight;
126
+ },
127
+ async bindAccount(binding) {
128
+ assertOpen();
129
+ if (typeof binding.externalProof !== 'string' || binding.externalProof.trim() === ''
130
+ || typeof binding.idempotencyKey !== 'string' || binding.idempotencyKey.trim() === '') {
131
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
132
+ }
133
+ return enqueue(async () => {
134
+ const previous = requireCurrent();
135
+ const result = await fromBackend(() => input.backend.bindAccount({
136
+ refreshToken: previous.refreshToken,
137
+ externalProof: binding.externalProof,
138
+ idempotencyKey: binding.idempotencyKey,
139
+ }));
140
+ if (typeof result !== 'object' || result === null || !('status' in result)) {
141
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
142
+ }
143
+ if (result.status === 'conflict') {
144
+ assertOpen();
145
+ return { status: 'conflict' };
146
+ }
147
+ if (result.status !== 'bound' && result.status !== 'already-bound') {
148
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
149
+ }
150
+ const next = validateSession(result.session);
151
+ assertSameOwner(previous, next);
152
+ if (next.identityLevel !== 'authenticated') {
153
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
154
+ }
155
+ await persist(next);
156
+ assertOpen();
157
+ return { status: result.status };
158
+ });
159
+ },
160
+ getHeaders() {
161
+ assertOpen();
162
+ const active = requireCurrent();
163
+ if (Date.parse(active.accessExpiresAt) <= now()) {
164
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_NOT_READY');
165
+ }
166
+ return { authorization: `Bearer ${active.accessToken}` };
167
+ },
168
+ logout() {
169
+ if (logoutInFlight !== undefined) {
170
+ return logoutInFlight;
171
+ }
172
+ status = 'closed';
173
+ const attempt = enqueue(async () => {
174
+ let token = current?.refreshToken;
175
+ if (token === undefined) {
176
+ try {
177
+ token = (await input.credentials.load({ key: credentialKey })) ?? undefined;
178
+ }
179
+ catch {
180
+ // Keep the credential intact so a later logout can retry the load.
181
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_LOGOUT_UNCERTAIN');
182
+ }
183
+ }
184
+ if (token !== undefined) {
185
+ try {
186
+ await input.backend.revoke({ refreshToken: token });
187
+ }
188
+ catch {
189
+ // Do not delete a still-live refresh token on revoke failure.
190
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_LOGOUT_UNCERTAIN');
191
+ }
192
+ }
193
+ try {
194
+ await input.credentials.remove({ key: credentialKey });
195
+ }
196
+ catch {
197
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_LOGOUT_UNCERTAIN');
198
+ }
199
+ current = undefined;
200
+ });
201
+ logoutInFlight = attempt;
202
+ void attempt.catch(() => {
203
+ // A failed revoke or remove can be retried on this closed coordinator.
204
+ logoutInFlight = undefined;
205
+ });
206
+ return logoutInFlight;
207
+ },
208
+ };
209
+ }
210
+ function view(session) {
211
+ return {
212
+ serverUserId: session.serverUserId,
213
+ sessionId: session.sessionId,
214
+ identityLevel: session.identityLevel,
215
+ accessExpiresAt: session.accessExpiresAt,
216
+ };
217
+ }
218
+ // A server may already have consumed the previous refresh token. Persist a
219
+ // well-formed rotated token even when the device clock makes access look expired.
220
+ function validateSession(value) {
221
+ if (!isRecord(value)
222
+ || typeof value.serverUserId !== 'string' || value.serverUserId.trim() === ''
223
+ || typeof value.sessionId !== 'string' || value.sessionId.trim() === ''
224
+ || (value.identityLevel !== 'guest' && value.identityLevel !== 'authenticated')
225
+ || typeof value.accessToken !== 'string' || value.accessToken.trim() === ''
226
+ || typeof value.refreshToken !== 'string' || value.refreshToken.trim() === ''
227
+ || typeof value.accessExpiresAt !== 'string'
228
+ || !Number.isFinite(Date.parse(value.accessExpiresAt))) {
229
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
230
+ }
231
+ return {
232
+ serverUserId: value.serverUserId,
233
+ sessionId: value.sessionId,
234
+ identityLevel: value.identityLevel,
235
+ accessToken: value.accessToken,
236
+ refreshToken: value.refreshToken,
237
+ accessExpiresAt: value.accessExpiresAt,
238
+ };
239
+ }
240
+ function isRecord(value) {
241
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
242
+ }
243
+ function assertSameOwner(previous, next) {
244
+ if (previous.serverUserId !== next.serverUserId) {
245
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_OWNERSHIP_CONFLICT');
246
+ }
247
+ }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  export * from './admob-ssv.js';
2
2
  export * from './app-store-verifier.js';
3
+ export * from './app-store-recovery.js';
3
4
  export * from './apps-in-toss-evidence-verification.js';
4
5
  export * from './apps-in-toss-partner-api.js';
5
6
  export * from './client.js';
6
7
  export * from './contract.js';
7
8
  export * from './evidence-verification.js';
9
+ export * from './guest-session.js';
8
10
  export * from './google-play-purchase.js';
11
+ export * from './google-play-publisher-client.js';
9
12
  export * from './microsoft-store-identity.js';
10
13
  export * from './microsoft-store-purchase.js';
14
+ export * from './monetization-recovery.js';
11
15
  export * from './notification-delivery.js';
12
16
  export * from './platform-order.js';
13
17
  export * from './progress-link.js';
package/dist/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  export * from './admob-ssv.js';
2
2
  export * from './app-store-verifier.js';
3
+ export * from './app-store-recovery.js';
3
4
  export * from './apps-in-toss-evidence-verification.js';
4
5
  export * from './apps-in-toss-partner-api.js';
5
6
  export * from './client.js';
6
7
  export * from './contract.js';
7
8
  export * from './evidence-verification.js';
9
+ export * from './guest-session.js';
8
10
  export * from './google-play-purchase.js';
11
+ export * from './google-play-publisher-client.js';
9
12
  export * from './microsoft-store-identity.js';
10
13
  export * from './microsoft-store-purchase.js';
14
+ export * from './monetization-recovery.js';
11
15
  export * from './notification-delivery.js';
12
16
  export * from './platform-order.js';
13
17
  export * from './progress-link.js';
@@ -0,0 +1,63 @@
1
+ import type { PurchaseResult, RewardedAdResult } from '@mpgd/platform';
2
+ import { type CreateGameServicesClientInput } from './client.js';
3
+ import type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult } from './operations.js';
4
+ import type { ClaimAdRewardRequest, ClaimAdRewardResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
5
+ interface OperationIdentity {
6
+ /** The store must enforce uniqueness across users, not just within one account. */
7
+ readonly key: string;
8
+ readonly playerId: string;
9
+ readonly target: string;
10
+ readonly deploymentTarget: string;
11
+ readonly revision: number;
12
+ /** Fixed when a platform result is first journaled, before backend dispatch. */
13
+ readonly platformCompletedAt?: string;
14
+ }
15
+ export type MonetizationOperationRecord = (OperationIdentity & {
16
+ readonly kind: 'purchase';
17
+ readonly input: GameServicesPurchaseInput;
18
+ readonly platform?: PurchaseResult;
19
+ readonly request?: VerifyPurchaseRequest;
20
+ readonly response?: VerifyPurchaseResponse;
21
+ readonly result?: GameServicesPurchaseResult;
22
+ }) | (OperationIdentity & {
23
+ readonly kind: 'rewarded-ad';
24
+ readonly input: GameServicesRewardedAdInput;
25
+ readonly platform?: RewardedAdResult;
26
+ readonly request?: ClaimAdRewardRequest;
27
+ readonly response?: ClaimAdRewardResponse;
28
+ readonly result?: GameServicesRewardedAdResult;
29
+ });
30
+ /** Games must supply a durable, atomic implementation; an in-memory map is not recovery. */
31
+ export interface MonetizationOperationStore {
32
+ /** Atomically insert or return the existing operation for this global key. */
33
+ reserve(record: MonetizationOperationRecord): Promise<{
34
+ readonly created: boolean;
35
+ readonly record: MonetizationOperationRecord;
36
+ }>;
37
+ read(key: string): Promise<MonetizationOperationRecord | undefined>;
38
+ /** Compare-and-swap; reject stale revisions and changes to immutable identity. */
39
+ replace(expectedRevision: number, record: MonetizationOperationRecord): Promise<void>;
40
+ /** Return only unresolved operations; keep terminal records addressable by read for deduplication. */
41
+ listRecoverable(playerId: string): Promise<readonly MonetizationOperationRecord[]>;
42
+ }
43
+ export interface CreateRecoverableMonetizationClientInput extends CreateGameServicesClientInput {
44
+ readonly operationStore: MonetizationOperationStore;
45
+ }
46
+ export interface MonetizationOperationSummary {
47
+ readonly kind: MonetizationOperationRecord['kind'];
48
+ readonly idempotencyKey: string;
49
+ readonly status: 'pending' | 'granted' | 'rejected' | 'cancelled' | 'failed' | 'skipped' | 'unavailable' | 'action-required';
50
+ readonly finalizationPending: boolean;
51
+ }
52
+ export interface RecoverableMonetizationClient extends GameServicesOperationClient {
53
+ recoverPurchaseResult(idempotencyKey: string, platform: PurchaseResult): Promise<GameServicesPurchaseResult>;
54
+ recoverRewardResult(idempotencyKey: string, platform: RewardedAdResult): Promise<GameServicesRewardedAdResult>;
55
+ reconcile(): Promise<readonly MonetizationOperationSummary[]>;
56
+ }
57
+ /**
58
+ * Client-side recovery coordinates UI callbacks and authoritative backend calls.
59
+ * It never grants a wallet locally. Journal evidence can contain receipts/tokens,
60
+ * so the game's durable store must encrypt and access-control its records.
61
+ */
62
+ export declare function createRecoverableMonetizationClient(input: CreateRecoverableMonetizationClientInput): RecoverableMonetizationClient;
63
+ export {};