@oxyhq/core 12.10.6 → 12.11.1

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 (139) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +66 -6
  3. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  4. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  5. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  6. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  7. package/dist/cjs/i18n/locales/en-US.json +13 -4
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  9. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  10. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  11. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  12. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  16. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  24. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  25. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  26. package/dist/cjs/index.js +31 -5
  27. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  28. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  29. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  30. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  31. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  32. package/dist/cjs/mixins/index.js +5 -0
  33. package/dist/cjs/session/SessionClient.js +40 -4
  34. package/dist/cjs/session/accountDialogController.js +288 -41
  35. package/dist/cjs/session/identityPin.js +256 -0
  36. package/dist/cjs/session/identitySession.js +135 -0
  37. package/dist/cjs/session/projectSessionState.js +54 -15
  38. package/dist/cjs/session/refresh.js +73 -8
  39. package/dist/cjs/utils/commonsDelivery.js +59 -0
  40. package/dist/cjs/utils/oauthPkce.js +33 -3
  41. package/dist/esm/.tsbuildinfo +1 -1
  42. package/dist/esm/HttpService.js +66 -6
  43. package/dist/esm/boot/sessionColdBoot.js +142 -37
  44. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  45. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  46. package/dist/esm/i18n/locales/de-DE.json +13 -1
  47. package/dist/esm/i18n/locales/en-US.json +13 -4
  48. package/dist/esm/i18n/locales/es-ES.json +13 -4
  49. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  50. package/dist/esm/i18n/locales/it-IT.json +13 -1
  51. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  52. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  53. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  54. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  55. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  56. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  57. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  58. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  61. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  62. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  63. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  64. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  65. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  66. package/dist/esm/index.js +14 -1
  67. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  68. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  69. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  70. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  71. package/dist/esm/mixins/OxyServices.user.js +1 -0
  72. package/dist/esm/mixins/index.js +5 -0
  73. package/dist/esm/session/SessionClient.js +40 -4
  74. package/dist/esm/session/accountDialogController.js +288 -41
  75. package/dist/esm/session/identityPin.js +249 -0
  76. package/dist/esm/session/identitySession.js +131 -0
  77. package/dist/esm/session/projectSessionState.js +54 -15
  78. package/dist/esm/session/refresh.js +73 -8
  79. package/dist/esm/utils/commonsDelivery.js +54 -0
  80. package/dist/esm/utils/oauthPkce.js +31 -2
  81. package/dist/types/.tsbuildinfo +1 -1
  82. package/dist/types/HttpService.d.ts +18 -1
  83. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  84. package/dist/types/index.d.ts +12 -4
  85. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  86. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  87. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  88. package/dist/types/mixins/OxyServices.user.d.ts +2 -0
  89. package/dist/types/mixins/index.d.ts +2 -1
  90. package/dist/types/session/SessionClient.d.ts +28 -0
  91. package/dist/types/session/accountDialogController.d.ts +187 -9
  92. package/dist/types/session/identityPin.d.ts +87 -0
  93. package/dist/types/session/identitySession.d.ts +105 -0
  94. package/dist/types/session/projectSessionState.d.ts +21 -19
  95. package/dist/types/session/refresh.d.ts +32 -1
  96. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  97. package/dist/types/utils/oauthPkce.d.ts +21 -1
  98. package/package.json +3 -3
  99. package/src/HttpService.ts +67 -6
  100. package/src/__tests__/inSessionRefresh.test.ts +67 -0
  101. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  102. package/src/boot/sessionColdBoot.ts +183 -37
  103. package/src/i18n/locales/ar-SA.json +13 -1
  104. package/src/i18n/locales/ca-ES.json +13 -1
  105. package/src/i18n/locales/de-DE.json +13 -1
  106. package/src/i18n/locales/en-US.json +13 -4
  107. package/src/i18n/locales/es-ES.json +13 -4
  108. package/src/i18n/locales/fr-FR.json +13 -1
  109. package/src/i18n/locales/it-IT.json +13 -1
  110. package/src/i18n/locales/ja-JP.json +13 -1
  111. package/src/i18n/locales/ko-KR.json +13 -1
  112. package/src/i18n/locales/pt-PT.json +13 -1
  113. package/src/i18n/locales/zh-CN.json +13 -1
  114. package/src/index.ts +49 -0
  115. package/src/mixins/OxyServices.auth.ts +464 -24
  116. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  117. package/src/mixins/OxyServices.notifications.ts +142 -0
  118. package/src/mixins/OxyServices.privacy.ts +6 -0
  119. package/src/mixins/OxyServices.user.ts +3 -0
  120. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  121. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  122. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  123. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
  124. package/src/mixins/index.ts +6 -0
  125. package/src/session/SessionClient.ts +57 -4
  126. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  127. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  128. package/src/session/__tests__/identityPin.test.ts +131 -0
  129. package/src/session/__tests__/identitySession.test.ts +194 -0
  130. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  131. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  132. package/src/session/accountDialogController.ts +435 -44
  133. package/src/session/identityPin.ts +310 -0
  134. package/src/session/identitySession.ts +206 -0
  135. package/src/session/projectSessionState.ts +61 -13
  136. package/src/session/refresh.ts +103 -9
  137. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  138. package/src/utils/commonsDelivery.ts +132 -0
  139. package/src/utils/oauthPkce.ts +56 -3
@@ -14,6 +14,40 @@
14
14
  * primitive can be reused from either without double-planting.
15
15
  */
16
16
  import { deviceTokenMintResponseSchema, deviceHubTicketIssueResponseSchema, deviceHubTicketRedeemResponseSchema, safeParseContract, } from '@oxyhq/contracts';
17
+ /**
18
+ * The server's `401 account_not_on_device` for a PINNED mint: the requested
19
+ * `accountId` is not (or is no longer) a live account of this device session.
20
+ *
21
+ * Distinguished from every other mint 401 because the remedy is different: the
22
+ * device secret is FINE — it is the identity binding that went stale (the
23
+ * account was signed out on this device, or revoked). An identity-bound caller
24
+ * must re-establish its session from the local key rather than drop/clear the
25
+ * device credential.
26
+ */
27
+ export class AccountNotOnDeviceError extends Error {
28
+ constructor(accountId, cause) {
29
+ super(`account_not_on_device: ${accountId} is not a live account of this device session`);
30
+ this.accountId = accountId;
31
+ this.cause = cause;
32
+ this.name = 'AccountNotOnDeviceError';
33
+ /** HTTP status of the originating response; mirrors the ApiError shape. */
34
+ this.status = 401;
35
+ }
36
+ }
37
+ /**
38
+ * Structural (never `instanceof`) read of a normalized mint error: the thrown
39
+ * value may be a plain ApiError-shaped object or come from another realm.
40
+ */
41
+ function isAccountNotOnDevice(error) {
42
+ if (!error || typeof error !== 'object') {
43
+ return false;
44
+ }
45
+ const { status, message } = error;
46
+ if (status !== 401) {
47
+ return false;
48
+ }
49
+ return typeof message === 'string' && message.includes('account_not_on_device');
50
+ }
17
51
  export function OxyServicesDeviceBootMixin(Base) {
18
52
  return class extends Base {
19
53
  /**
@@ -37,11 +71,22 @@ export function OxyServicesDeviceBootMixin(Base) {
37
71
  * boot's worst-case time-to-route. A transient failure surfaces once and the
38
72
  * scheduler/401 path retries it later.
39
73
  *
74
+ * `options.accountId` PINS the mint to one account of the device instead of
75
+ * whichever account is currently active. It exists for identity-bound
76
+ * clients (Commons), whose authenticated user is fixed by a local
77
+ * cryptographic key and must never follow an account switch made by another
78
+ * app on the same device. The server never mutates `activeAccountId` for a
79
+ * pinned mint — the returned `state` still reports the device's true active
80
+ * account — and rejects a non-member/dead account with
81
+ * `401 account_not_on_device`, surfaced here as {@link AccountNotOnDeviceError}.
82
+ *
83
+ * @throws {AccountNotOnDeviceError} when a pinned mint's account is not on the device.
40
84
  * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
41
85
  */
42
- async mintFromDeviceSecret(deviceId, deviceSecret) {
86
+ async mintFromDeviceSecret(deviceId, deviceSecret, options) {
87
+ const accountId = options?.accountId;
43
88
  try {
44
- const res = await this.makeRequest('POST', '/session/device/token', { deviceId, deviceSecret },
89
+ const res = await this.makeRequest('POST', '/session/device/token', { deviceId, deviceSecret, ...(accountId ? { accountId } : {}) },
45
90
  // `bypassQueue`: this mint is the control-plane call the auth lane
46
91
  // depends on — it must run even when every RequestQueue slot is parked
47
92
  // awaiting it, or the whole client deadlocks. See RequestOptions.bypassQueue.
@@ -53,7 +98,11 @@ export function OxyServicesDeviceBootMixin(Base) {
53
98
  return parsed;
54
99
  }
55
100
  catch (error) {
56
- throw this.handleError(error);
101
+ const normalized = this.handleError(error);
102
+ if (accountId && isAccountNotOnDevice(normalized)) {
103
+ throw new AccountNotOnDeviceError(accountId, normalized);
104
+ }
105
+ throw normalized;
57
106
  }
58
107
  }
59
108
  /** Mint a one-time hub sync ticket (bearer required). */
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shape of an Expo push token: `ExponentPushToken[…]` (what
3
+ * `getExpoPushTokenAsync()` returns today) or the equivalent `ExpoPushToken[…]`
4
+ * spelling. Deliberately plain ASCII — `@oxyhq/core` ships to Hermes, where
5
+ * Unicode property escapes throw at runtime.
6
+ */
7
+ const EXPO_PUSH_TOKEN_PATTERN = /^Expo(nent)?PushToken\[[^[\]\s]+\]$/;
8
+ export function OxyServicesNotificationsMixin(Base) {
9
+ return class extends Base {
10
+ /**
11
+ * Register this installation's **Expo** push token for the authenticated
12
+ * identity (`POST /notifications/push-token`, bearer required).
13
+ *
14
+ * Idempotent server-side: re-registering the same token for the same
15
+ * identity refreshes the existing row rather than creating a duplicate, so
16
+ * callers can safely re-register on every cold boot.
17
+ *
18
+ * @throws When `expoPushToken` is not an Expo push token — a raw APNs/FCM
19
+ * device token fails here rather than becoming a silently undeliverable
20
+ * registration.
21
+ */
22
+ async registerPushToken(input) {
23
+ try {
24
+ if (!EXPO_PUSH_TOKEN_PATTERN.test(input.expoPushToken)) {
25
+ throw new Error('registerPushToken expects an Expo push token ("ExponentPushToken[...]", from getExpoPushTokenAsync). ' +
26
+ 'A raw APNs/FCM device token from getDevicePushTokenAsync cannot be delivered to.');
27
+ }
28
+ await this.makeRequest('POST', '/notifications/push-token', {
29
+ token: input.expoPushToken,
30
+ platform: input.platform,
31
+ // Omitted entirely when absent so the body stays exactly what the
32
+ // endpoint has always received (the server reads presence).
33
+ ...(input.deviceId ? { deviceId: input.deviceId } : {}),
34
+ ...(input.clientId ? { clientId: input.clientId } : {}),
35
+ }, { cache: false });
36
+ }
37
+ catch (error) {
38
+ throw this.handleError(error);
39
+ }
40
+ }
41
+ /**
42
+ * Retire an **Expo** push token for the authenticated identity
43
+ * (`DELETE /notifications/push-token`, bearer required).
44
+ *
45
+ * Call it when the user turns notifications off, when the vault identity is
46
+ * replaced, and on sign-out — otherwise the installation keeps receiving
47
+ * approval requests for an identity it no longer holds.
48
+ *
49
+ * @param expoPushToken - The exact token previously registered.
50
+ */
51
+ async unregisterPushToken(expoPushToken) {
52
+ try {
53
+ await this.makeRequest('DELETE', '/notifications/push-token', { token: expoPushToken }, { cache: false });
54
+ }
55
+ catch (error) {
56
+ throw this.handleError(error);
57
+ }
58
+ }
59
+ };
60
+ }
@@ -149,6 +149,9 @@ export function OxyServicesPrivacyMixin(Base) {
149
149
  cache: false,
150
150
  });
151
151
  this.clearCacheEntry('GET:/privacy/restricted');
152
+ // The restriction changed the viewer's graph (`restrictedIds`) — bust the
153
+ // cached consolidated `GET /users/me/graph` so the next read reflects it.
154
+ this.clearCacheEntry('GET:/users/me/graph');
152
155
  return result;
153
156
  }
154
157
  catch (error) {
@@ -172,6 +175,9 @@ export function OxyServicesPrivacyMixin(Base) {
172
175
  cache: false,
173
176
  });
174
177
  this.clearCacheEntry('GET:/privacy/restricted');
178
+ // Symmetric to restrictUser: the unrestrict changed the viewer's
179
+ // `restrictedIds`, so bust the consolidated `GET /users/me/graph` cache.
180
+ this.clearCacheEntry('GET:/users/me/graph');
175
181
  return result;
176
182
  }
177
183
  catch (error) {
@@ -818,6 +818,7 @@ export function OxyServicesUserMixin(Base) {
818
818
  followingIds: graph?.followingIds || [],
819
819
  mutualIds: graph?.mutualIds || [],
820
820
  blockedIds: graph?.blockedIds || [],
821
+ restrictedIds: graph?.restrictedIds || [],
821
822
  };
822
823
  }
823
824
  catch (error) {
@@ -24,6 +24,7 @@ import { OxyServicesUtilityMixin } from './OxyServices.utility.js';
24
24
  import { OxyServicesFeaturesMixin } from './OxyServices.features.js';
25
25
  import { OxyServicesTopicsMixin } from './OxyServices.topics.js';
26
26
  import { OxyServicesContactsMixin } from './OxyServices.contacts.js';
27
+ import { OxyServicesNotificationsMixin } from './OxyServices.notifications.js';
27
28
  import { OxyServicesAppDataMixin } from './OxyServices.appData.js';
28
29
  import { OxyServicesCivicMixin } from './OxyServices.civic.js';
29
30
  import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
@@ -71,6 +72,10 @@ const MIXIN_PIPELINE = [
71
72
  OxyServicesFeaturesMixin,
72
73
  OxyServicesTopicsMixin,
73
74
  OxyServicesContactsMixin,
75
+ // Push-token registration: the one SDK-owned register/unregister pair every
76
+ // Oxy app uses, and what lets a "Sign in with Oxy" request be delivered to a
77
+ // known Commons installation instead of falling back to a QR.
78
+ OxyServicesNotificationsMixin,
74
79
  OxyServicesAppDataMixin,
75
80
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
76
81
  OxyServicesCivicMixin,
@@ -30,6 +30,15 @@ export class SessionClient {
30
30
  getState() {
31
31
  return this.state;
32
32
  }
33
+ /**
34
+ * The account this client's bearer is pinned to, or `null` when it follows the
35
+ * device's active account (the default). Resolvers are expected to be a plain
36
+ * synchronous read of already-resolved state (see
37
+ * {@link SessionClientOptions.getPinnedAccountId}).
38
+ */
39
+ pinnedAccountId() {
40
+ return this.options.getPinnedAccountId?.() ?? null;
41
+ }
33
42
  subscribe(listener) {
34
43
  this.listeners.add(listener);
35
44
  return () => {
@@ -118,15 +127,26 @@ export class SessionClient {
118
127
  }
119
128
  const previousState = this.state;
120
129
  this.state = next;
130
+ const pinnedAccountId = this.pinnedAccountId();
121
131
  // Plant the sync-supplied active token (it is for `next.activeAccountId`)
122
132
  // now — before the notify below — so the bearer matches the new active
123
133
  // account when subscribers observe it. Guarded on difference to avoid a
124
134
  // redundant token-change notification on an unchanged token (bootstrap
125
- // restate).
126
- if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
135
+ // restate). An identity-bound client only accepts it when the active account
136
+ // IS its pinned account otherwise the token belongs to somebody else's
137
+ // switch and must never displace the pinned bearer.
138
+ if (activeToken &&
139
+ next.activeAccountId !== null &&
140
+ (pinnedAccountId === null || next.activeAccountId === pinnedAccountId) &&
141
+ activeToken !== this.host.getAccessToken()) {
127
142
  this.host.setTokens(activeToken);
128
143
  }
129
- const transport = this.options.transport;
144
+ // The transport's entire job is converging the bearer on `activeAccountId`,
145
+ // which is precisely what an identity-bound client must not do: its token is
146
+ // minted for the pinned account by the cold boot / re-mint lane. Bypass it
147
+ // while pinned (also removing the mint-before-notify gate, which exists only
148
+ // to keep the bearer and the ACTIVE account in step).
149
+ const transport = pinnedAccountId === null ? this.options.transport : null;
130
150
  const activeAccountId = next.activeAccountId;
131
151
  // Mint before notifying when the bearer does not already belong to the new
132
152
  // active account: no bearer at all, an opaque bearer, OR a bearer for a
@@ -198,12 +218,15 @@ export class SessionClient {
198
218
  // socket push): `applyState` no-ops without planting, but the token still
199
219
  // needs planting. Guard on the sync's active account STILL being the current
200
220
  // active account so a stale response cannot adopt a token for an account a
201
- // newer state already switched away from.
221
+ // newer state already switched away from — and, when pinned, on that account
222
+ // being the PINNED one (same rule as the applied path in `applyState`).
223
+ const pinnedAccountId = this.pinnedAccountId();
202
224
  if (!applied &&
203
225
  sync.activeToken &&
204
226
  this.state &&
205
227
  sync.state.activeAccountId !== null &&
206
228
  sync.state.activeAccountId === this.state.activeAccountId &&
229
+ (pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
207
230
  sync.activeToken.accessToken !== this.host.getAccessToken()) {
208
231
  this.host.setTokens(sync.activeToken.accessToken);
209
232
  }
@@ -237,12 +260,19 @@ export class SessionClient {
237
260
  * sign-in. This adds, then switches to the target so the UI lands on the
238
261
  * account the user just authenticated.
239
262
  *
263
+ * An identity-bound (pinned) client only ADDS: its own session is minted for
264
+ * the pinned account explicitly, so switching the device would gratuitously
265
+ * re-elect the active account under every OTHER app on this device — a
266
+ * mutation a pinned client must never make.
267
+ *
240
268
  * @param accountId - The signed-in account id (e.g. `session.user.id`). When
241
269
  * omitted, falls back to the host's current-account ref. If neither
242
270
  * resolves, the add still applies and no switch is performed.
243
271
  */
244
272
  async registerAndActivate(accountId) {
245
273
  await this.addCurrentAccount();
274
+ if (this.pinnedAccountId() !== null)
275
+ return;
246
276
  const target = accountId ?? this.host.getCurrentAccountId();
247
277
  if (target && this.state?.activeAccountId !== target) {
248
278
  await this.switchAccount(target);
@@ -346,6 +376,12 @@ export class SessionClient {
346
376
  const applied = this.applyState(payload, 'push');
347
377
  if (!applied)
348
378
  return;
379
+ // An identity-bound client tracks the pushed state but NEVER chases the
380
+ // device's active account: re-fetching here would only pull an
381
+ // `activeToken` for somebody else's switch, which the plant guards would
382
+ // then discard. Skip the round-trip entirely.
383
+ if (this.pinnedAccountId() !== null)
384
+ return;
349
385
  // A push changed the active account on another device/tab — re-fetch state
350
386
  // to plant the access token for the newly-active account. When this tab is
351
387
  // still signed out, applyState mints via ensureActiveToken first; bootstrap