@crowdedkingdoms/crowdyjs 15.0.0 → 15.1.0-test.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.
package/MIGRATION.md CHANGED
@@ -1,3 +1,68 @@
1
+ # CrowdyJS v15.1 — password management (additive)
2
+
3
+ **Nothing breaks.** Four mutations the API has served all along are now wrapped,
4
+ so a game shipping this SDK has a first-class way to let a player set or change
5
+ a password:
6
+
7
+ | Method | Requires | For |
8
+ |---|---|---|
9
+ | `auth.requestPasswordReset(email)` | nothing | "I forgot my password" |
10
+ | `auth.resetPassword({ token, newPassword })` | the emailed token | completing that |
11
+ | `auth.changePassword({ currentPassword, newPassword })` | a session **and** the current password | an ordinary change |
12
+ | `auth.setInitialPassword(newPassword)` | a session, and the account must have **no** password | adding a first password |
13
+
14
+ **Why four and not one.** Each is defined by what the caller has already
15
+ *proven* — an emailed token, the current password, or the session — and
16
+ collapsing any pair deletes the proof. In particular `setInitialPassword` is not
17
+ `changePassword` with the check removed: it **refuses** when a password already
18
+ exists, and that refusal is what stops a stolen session from replacing a
19
+ credential the owner still knows. Route on the refusal instead of retrying.
20
+
21
+ **`setInitialPassword` emails a security notification to the account address**
22
+ on success, and that is deliberately the mitigation rather than a refusal: a
23
+ stolen session can already attach durable attacker-controlled access through
24
+ `linkIdentity`, so refusing here would close nothing and would leave the
25
+ legitimate user of a magic-link or social-only account with no door at all. If
26
+ you build UI for this, tell the user the email is coming.
27
+
28
+ **Three new error predicates**, because the three refusals need different
29
+ handling and a caller should not have to work out which is which:
30
+
31
+ - `isPasswordAlreadySetError(e)` — `setInitialPassword` refused; use
32
+ `changePassword`.
33
+ - `isNoPasswordSetError(e)` — `changePassword` refused because there is none;
34
+ use `setInitialPassword`.
35
+ - `isInvalidCurrentPasswordError(e)` — the current password is wrong; ask again.
36
+
37
+ **Use these rather than reading `extensions.code` yourself, and the reason is
38
+ worth knowing.** Each refusal has its own code — `PASSWORD_ALREADY_SET`,
39
+ `PASSWORD_NOT_SET`, `INVALID_CURRENT_PASSWORD` — only from **ck-api v1.60.0**.
40
+ Before that release the first two shared `UNAUTHENTICATED` with a genuinely
41
+ expired session and the third arrived as `INTERNAL_SERVER_ERROR`, so a caller
42
+ keying on the code signed the user out over a typo. Each predicate accepts the
43
+ new code **and** the older wording, so one pinned build works against a tier on
44
+ either side of that line. `isAlreadyRegisteredError` gained the same treatment
45
+ (`EMAIL_ALREADY_REGISTERED`).
46
+
47
+ None of the three means the session is gone. Sign a user out on
48
+ `UNAUTHENTICATED`, which from v1.60.0 says only that.
49
+
50
+ ```ts
51
+ try {
52
+ await client.auth.setInitialPassword(pw);
53
+ } catch (e) {
54
+ if (isPasswordAlreadySetError(e)) {
55
+ // They have one already — ask for it rather than replacing it blind.
56
+ await client.auth.changePassword({ currentPassword: current, newPassword: pw });
57
+ } else throw e;
58
+ }
59
+ ```
60
+
61
+ `resetPassword` and `changePassword` do **not** revoke existing sessions. Follow
62
+ either with `auth.logoutAllDevices()` if that is the intent.
63
+
64
+ ---
65
+
1
66
  # CrowdyJS v15 — the dev auth bypass is gone (breaking)
2
67
 
3
68
  `client.auth.devLogin()` is **removed**, and so is the `devToken` field on
@@ -37,8 +102,9 @@ login, and two error predicates, because these two conditions are **not**
37
102
  distinguishable by GraphQL error code:
38
103
 
39
104
  - `isAlreadyRegisteredError(e)` — `register` refused because the address already
40
- has an account. The server raises a `ConflictException` and it arrives as
41
- `INTERNAL_SERVER_ERROR`, so a caller keying on `CONFLICT` matches nothing.
105
+ has an account. It carries `EMAIL_ALREADY_REGISTERED` from ck-api v1.60.0;
106
+ before that it arrived as `INTERNAL_SERVER_ERROR`, so a caller keying on
107
+ `CONFLICT` matched nothing. The predicate accepts both.
42
108
  - `isPasswordUnconfirmedError(e)` — `login` refused because the password is real
43
109
  but unconfirmed on an account with another verified sign-in method. The remedy
44
110
  is the emailed link, not a different password.
@@ -523,8 +589,16 @@ validation error; every other sub-client is unaffected.
523
589
 
524
590
  # CrowdyJS v8 — Passwordless & federated sign-in (BREAKING)
525
591
 
526
- **Crowded Kingdoms is passwordless.** Email + password login is removed. Update
527
- your sign-in flow to one of:
592
+ > **SUPERSEDED BY 15.0.0 do not follow this section as current product.**
593
+ > Email + password sign-in came BACK in 15.0.0: `auth.login` and
594
+ > `auth.register` exist, and the `devLogin` bypass was removed from every tier
595
+ > on 2026-08-20. What is still true from v8 is that magic link and social
596
+ > sign-in are supported; what is false is that they are the ONLY options.
597
+ > This section is kept as the record of the v8 break. See the 15.0.0 notes at
598
+ > the top of this file.
599
+
600
+ **At v8, Crowded Kingdoms was passwordless.** Email + password login was removed
601
+ in that version. The v8 migration was to one of:
528
602
 
529
603
  - **Magic link (email):**
530
604
  ```ts
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # CrowdyJS
2
2
 
3
3
  The official browser-first TypeScript SDK for **Crowded Kingdoms**. CrowdyJS
4
- gives you typed clients for the whole platform: passwordless identity and
4
+ gives you typed clients for the whole platform: identity and
5
5
  studio administration (the Management surface), world data and the abstract
6
6
  game model (the Game surface), and the UDP proxy realtime stream — all over
7
7
  one unified GraphQL API.
@@ -76,7 +76,7 @@ app and drive the world/UDP surface from a per-game client — see
76
76
 
77
77
  ## Authentication: session token vs app-scoped tokens
78
78
 
79
- Passwordless sign-in returns an **identity session token** good for account,
79
+ Sign-in returns an **identity session token** good for account,
80
80
  studio admin and token minting, and **rejected for gameplay**. Each game is
81
81
  entered with a short-lived **app-scoped token** confined to that one app, so a
82
82
  game stack never receives the player's full session.
@@ -91,7 +91,8 @@ const overworld = createCrowdyClient({
91
91
  httpUrl: apiUrl,
92
92
  tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:session'),
93
93
  });
94
- // Passwordless sign-in (magic link, social/OIDC, or dev bypass) yields the session token.
94
+ // Sign-in (email + password via auth.login / auth.register, magic link, or social/OIDC)
95
+ // yields the session token. There is no dev bypass: devLogin was removed in 15.0.0.
95
96
  await overworld.auth.requestLoginLink({ email, redirectUri });
96
97
  await overworld.auth.completeLoginLink(tokenFromLink);
97
98
 
@@ -139,7 +140,7 @@ Notes:
139
140
  restoring auth from a non-default storage).
140
141
 
141
142
  Deeper reading: [Portals & app-scoped tokens](https://docs.crowdedkingdoms.com/management-api/portals-and-app-tokens)
142
- and [Sign in (passwordless)](https://docs.crowdedkingdoms.com/management-api/authentication).
143
+ and [Sign in](https://docs.crowdedkingdoms.com/management-api/authentication).
143
144
 
144
145
  ### Token refresh during gameplay
145
146
 
@@ -156,7 +157,7 @@ lifecycle to preserve.
156
157
 
157
158
  ## Game-loop lifecycle
158
159
 
159
- 1. Sign in (passwordless) on the identity client with `client.auth`, or
160
+ 1. Sign in on the identity client with `client.auth` (`login` / `register`), or
160
161
  restore a stored session with `client.session.restore()`. This yields the
161
162
  **session token**, which gameplay rejects.
162
163
  2. Mint an **app-scoped token** for the app (`identity.portal.mintAppToken(appId)`,
@@ -178,7 +179,7 @@ lifecycle to preserve.
178
179
 
179
180
  | Sub-client | What it does |
180
181
  |---|---|
181
- | `client.auth` | Passwordless sign-in (magic link, social/OIDC, dev bypass), log out, and linked identities (`myIdentities`, `linkIdentity`/`unlinkIdentity`). |
182
+ | `client.auth` | Sign-in: `login` / `register` (email + password), magic link, social/OIDC. Passwords: `requestPasswordReset` / `resetPassword`, `changePassword`, and `setInitialPassword` for an account created by magic link or a social provider (added in 15.1.0). Log out, and linked identities (`myIdentities`, `linkIdentity`/`unlinkIdentity`). **No dev bypass** — `devLogin` was removed in 15.0.0 and `DEV_AUTH_BYPASS` is gone from every tier. |
182
183
  | `client.users` | `me`, `updateGamertag`, profile reads. |
183
184
  | `client.session` | Token store, `restore()`, `getToken()`, manual `setToken()`. |
184
185
  | `client.portal` | App-scoped token minting (`mintAppToken`) and the cross-origin PKCE entry flow (`beginEntry` / `handleAuthorizeRequest` / `completeEntry` / `refresh`). |
@@ -14,15 +14,27 @@ import type { AuthState } from '../auth-state.js';
14
14
  * `register` have been first-class in the API throughout, and the C++ load
15
15
  * tester has used them all along. Only this SDK pretended otherwise, and the
16
16
  * gap sent integrators to the dev bypass — which no longer exists on any tier.
17
+ * The same gap ran one method deeper until 2026-08-21: password MANAGEMENT
18
+ * (reset, change, and adding a first password) was served by the API and
19
+ * wrapped here by nothing, so a game shipping this SDK had no first-class way
20
+ * to let a player set or change a password.
17
21
  *
18
22
  * Part of the management surface.
19
23
  *
20
24
  * **Public (no session):** {@link register}, {@link login},
21
25
  * {@link requestLoginLink}, {@link completeLoginLink}, {@link socialLoginStart},
22
26
  * {@link socialLoginComplete}, {@link availableLoginProviders},
23
- * {@link checkAuthMethod}. **Require a session:** {@link logout},
24
- * {@link logoutAllDevices}, {@link myIdentities}, {@link linkIdentity},
25
- * {@link unlinkIdentity}.
27
+ * {@link checkAuthMethod}, {@link requestPasswordReset}, {@link resetPassword}.
28
+ * **Require a session:** {@link logout}, {@link logoutAllDevices},
29
+ * {@link myIdentities}, {@link linkIdentity}, {@link unlinkIdentity},
30
+ * {@link changePassword}, {@link setInitialPassword}.
31
+ *
32
+ * **Which password method:** the four are distinguished by what the caller has
33
+ * already proven, not by what they want to do. Signed in with a password →
34
+ * {@link changePassword}. Signed in with none → {@link setInitialPassword}.
35
+ * Not signed in, or signed in and cannot remember it →
36
+ * {@link requestPasswordReset} then {@link resetPassword}.
37
+ * {@link checkAuthMethod} answers `hasPassword` for an address before sign-in.
26
38
  */
27
39
  export interface AuthUser {
28
40
  userId: string;
@@ -47,11 +59,16 @@ export interface UserIdentity {
47
59
  /**
48
60
  * `register` refused because the address already has an account.
49
61
  *
50
- * Matched on WORDING rather than on an error code, which looks fragile and is
51
- * the only thing that works: the server raises a Nest `ConflictException` and it
52
- * arrives over GraphQL as `INTERNAL_SERVER_ERROR` verified against a live
53
- * tier so a caller keying on `CONFLICT` matches nothing and treats a routine
54
- * "this account exists" as a server fault.
62
+ * `EMAIL_ALREADY_REGISTERED` from v1.60.0. Before that it arrived as
63
+ * `INTERNAL_SERVER_ERROR`, so a caller keying on the code matched nothing and
64
+ * treated a routine "this account exists" as a server fault; the wording branch
65
+ * is what still works against those tiers.
66
+ *
67
+ * Note what this does NOT accept: a bare `CONFLICT`. From v1.60.0 that is the
68
+ * code a generic 409 carries, and a generic code cannot identify a specific
69
+ * condition — a predicate that accepted it would report any future conflict in
70
+ * this mutation as "already registered". Only a code minted for this outcome
71
+ * will do.
55
72
  */
56
73
  export declare function isAlreadyRegisteredError(error: unknown): boolean;
57
74
  /**
@@ -59,8 +76,55 @@ export declare function isAlreadyRegisteredError(error: unknown): boolean;
59
76
  * account that has another verified sign-in method. The remedy is the emailed
60
77
  * confirmation link, not a different password — so this must not be reported to
61
78
  * the user as "wrong password".
79
+ *
80
+ * **The one refusal here with no code of its own, and the only wording-only
81
+ * predicate left.** ck-api v1.60.0 gave the other four a dedicated
82
+ * `extensions.code`; this one is still a plain `UnauthorizedException`, so it
83
+ * arrives as `UNAUTHENTICATED` — the same code as an expired session, whose
84
+ * remedy (sign in again) is the opposite of this one's. The message is
85
+ * therefore the only discriminator, and the absence of a `codeOf` branch below
86
+ * is deliberate rather than an oversight: there is no code to read.
62
87
  */
63
88
  export declare function isPasswordUnconfirmedError(error: unknown): boolean;
89
+ /**
90
+ * {@link setInitialPassword} refused because the account already has a
91
+ * password. The remedy is {@link changePassword}, which verifies the current
92
+ * one.
93
+ *
94
+ * The refusal is deliberate and load-bearing: without it, `setInitialPassword`
95
+ * would be `changePassword` with the current-password check deleted, which is
96
+ * the check that stops a stolen session from silently locking the owner out.
97
+ * So a caller must route to `changePassword` rather than retrying.
98
+ *
99
+ * `PASSWORD_ALREADY_SET` from ck-api v1.60.0. Before that the schema said this
100
+ * "throws CONFLICT" and it reached clients as `INTERNAL_SERVER_ERROR`, so the
101
+ * message was the only thing to match — which is why the wording branch is
102
+ * still here.
103
+ */
104
+ export declare function isPasswordAlreadySetError(error: unknown): boolean;
105
+ /**
106
+ * {@link changePassword} refused because there is no password to change — a
107
+ * magic-link or social-only account. The remedy is {@link setInitialPassword}
108
+ * while signed in, or {@link requestPasswordReset}.
109
+ *
110
+ * `PASSWORD_NOT_SET` from ck-api v1.60.0. Before that this and
111
+ * {@link isInvalidCurrentPasswordError} both arrived as `UNAUTHENTICATED`,
112
+ * which is ALSO what an expired session looks like — so a caller keying on the
113
+ * code signed the user out when they had merely typed the wrong current
114
+ * password, or offered a password field on an account that has none. That is
115
+ * the defect the new codes exist to remove, and the wording branch is what
116
+ * still separates them on a tier that has not deployed it.
117
+ */
118
+ export declare function isNoPasswordSetError(error: unknown): boolean;
119
+ /**
120
+ * {@link changePassword} refused because the current password is wrong. The
121
+ * remedy is to ask again — the session is fine.
122
+ *
123
+ * `INVALID_CURRENT_PASSWORD` (HTTP 403) from ck-api v1.60.0. See
124
+ * {@link isNoPasswordSetError} for what it used to be and why the wording
125
+ * branch stays.
126
+ */
127
+ export declare function isInvalidCurrentPasswordError(error: unknown): boolean;
64
128
  export declare class AuthAPI {
65
129
  private readonly graphql;
66
130
  private readonly session;
@@ -131,6 +195,84 @@ export declare class AuthAPI {
131
195
  checkAuthMethod(email: string): Promise<{
132
196
  hasPassword: boolean;
133
197
  }>;
198
+ /**
199
+ * Email a password-reset link to the address. Public.
200
+ *
201
+ * Always resolves `true` whether or not the address has an account, so it
202
+ * cannot be used to enumerate users — which also means a `true` here is not
203
+ * evidence an email was sent.
204
+ *
205
+ * This is the ownership-proven way to add a password to an account that has
206
+ * none, and the only one for a user who is not signed in. A user who IS
207
+ * signed in should use {@link setInitialPassword} instead and skip the inbox.
208
+ */
209
+ requestPasswordReset(email: string): Promise<boolean>;
210
+ /**
211
+ * Complete a password reset with the token from the emailed link. Public —
212
+ * the token is the authorization.
213
+ *
214
+ * Throws if the token is invalid or expired. **Existing sessions are not
215
+ * revoked**, so a reset does not by itself evict anyone already signed in;
216
+ * follow it with {@link logoutAllDevices} if that is what you want.
217
+ */
218
+ resetPassword(input: {
219
+ token: string;
220
+ newPassword: string;
221
+ }): Promise<boolean>;
222
+ /**
223
+ * Change the signed-in user's password, verifying the current one. Requires a
224
+ * session.
225
+ *
226
+ * **This is not the method for an account that has no password** — a
227
+ * magic-link or social-only account, which cannot supply a current one. That
228
+ * is {@link setInitialPassword}, and the two are kept apart deliberately:
229
+ * the current-password check here is what stops a stolen session from
230
+ * changing a credential the owner still knows.
231
+ *
232
+ * Three outcomes need telling apart and the error CODE cannot do it, because
233
+ * a wrong current password, an account with no password, and an expired
234
+ * session all arrive as `UNAUTHENTICATED`:
235
+ * {@link isInvalidCurrentPasswordError} (ask again),
236
+ * {@link isNoPasswordSetError} (send them to `setInitialPassword`), and
237
+ * neither (the session is gone — sign in again).
238
+ *
239
+ * **Existing sessions are not revoked.**
240
+ */
241
+ changePassword(input: {
242
+ currentPassword: string;
243
+ newPassword: string;
244
+ }): Promise<boolean>;
245
+ /**
246
+ * Add a password to the signed-in account when it does not have one yet.
247
+ * Requires a session.
248
+ *
249
+ * For an account created by magic link or a social provider, which until this
250
+ * existed had no in-product route to password sign-in at all — the only door
251
+ * was {@link requestPasswordReset}, an email round trip to add a credential to
252
+ * an account you are already signed in to. The session is the proof of account
253
+ * control, so **the password works immediately**: there is no confirmation
254
+ * email to wait for, and the password identity is written verified (an
255
+ * unverified one would be refused by {@link login} while another verified
256
+ * method exists, which is a dead end that looks like success).
257
+ *
258
+ * **Refuses when a password already exists** — {@link isPasswordAlreadySetError}
259
+ * detects it — rather than replacing it. Without that refusal this would be
260
+ * {@link changePassword} with the current-password check deleted. Route to
261
+ * `changePassword`, or to `requestPasswordReset` if the user has forgotten it.
262
+ *
263
+ * **A security notification is emailed to the account address** whenever this
264
+ * succeeds. That is the mitigation, and it is deliberately a notification
265
+ * rather than a refusal: a stolen session can already attach durable
266
+ * attacker-controlled access via {@link linkIdentity}, so refusing here would
267
+ * remove the legitimate user's only door without closing the class. Do not
268
+ * suppress or reword that email's role when you describe this to a user —
269
+ * "we have emailed you about this change" is part of the feature. The
270
+ * notification is best-effort on the server, so a `true` return is not proof
271
+ * the email was delivered.
272
+ *
273
+ * **Existing sessions are not revoked.**
274
+ */
275
+ setInitialPassword(newPassword: string): Promise<boolean>;
134
276
  /** The signed-in user's linked sign-in identities. Requires a session. */
135
277
  myIdentities(): Promise<UserIdentity[]>;
136
278
  /** Link an additional federated identity (from a social callback). */
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/domains/auth.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAGlD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,QAAQ,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAGhE;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAGlE;AAsFD,qBAAa,OAAO;IAEhB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADP,OAAO,EAAE,aAAa,EACtB,OAAO,EAAE,SAAS;IAGrC,6EAA6E;IACvE,uBAAuB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAKlD;;;;;;;OAOG;IACG,gBAAgB,CAAC,KAAK,EAAE;QAC5B,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC;IAK9B,0EAA0E;IACpE,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAS7D;;;OAGG;IACG,gBAAgB,CACpB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAOnD,iFAAiF;IAC3E,mBAAmB,CAAC,KAAK,EAAE;QAC/B,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC,YAAY,CAAC;IASzB;;;;;;;OAOG;IACG,KAAK,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,CAAC;IAQ9E;;;;;;;;;OASG;IACG,QAAQ,CAAC,KAAK,EAAE;QACpB,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,YAAY,CAAC;IAQzB;;;OAGG;IACG,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,WAAW,EAAE,OAAO,CAAA;KAAE,CAAC;IAOvE,0EAA0E;IACpE,YAAY,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAK7C,sEAAsE;IAChE,YAAY,CAAC,KAAK,EAAE;QACxB,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC,YAAY,CAAC;IAKzB,2EAA2E;IACrE,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAO1D,mEAAmE;IAC7D,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAMhC,oEAAoE;IAC9D,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC;IAK1C,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAIpC,+CAA+C;IAC/C,QAAQ,IAAI,MAAM,GAAG,IAAI;CAG1B"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/domains/auth.ts"],"names":[],"mappings":"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"}
@@ -1,28 +1,117 @@
1
1
  import { parse } from 'graphql';
2
2
  import { LogoutAllDevicesDocument, LogoutDocument } from '../generated/graphql.js';
3
+ /**
4
+ * The `extensions.code` of the first GraphQL error, if there is one.
5
+ *
6
+ * Every predicate below asks the CODE first and the wording second, and the
7
+ * order is not a style choice. ck-api v1.60.0 fixed a mapping defect that made
8
+ * these codes unusable: `@nestjs/apollo` translates four HTTP statuses and
9
+ * collapses the rest, so a 409 arrived as `INTERNAL_SERVER_ERROR` and both of
10
+ * `changePassword`'s refusals arrived as `UNAUTHENTICATED` — the same code an
11
+ * expired session produces. The wording fallback is therefore not legacy
12
+ * clutter: a tier that has not deployed v1.60.0 still answers the old way, and
13
+ * a game pinning this SDK exactly may meet either. Delete it when no tier
14
+ * predates v1.60.0, and not before.
15
+ */
16
+ /**
17
+ * `extensions.code` off anything error-shaped, whether it is a
18
+ * {@link CrowdyGraphQLError}, one raw GraphQL error entry, or the `code` getter
19
+ * the former lifts to the top level — all three reach a caller here, depending
20
+ * on whether the error was rethrown or destructured on the way.
21
+ */
22
+ function codeOf(error) {
23
+ const shape = error;
24
+ if (typeof shape?.extensions?.code === 'string')
25
+ return shape.extensions.code;
26
+ return typeof shape?.code === 'string' ? shape.code : undefined;
27
+ }
28
+ function messageOf(error) {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
3
31
  /**
4
32
  * `register` refused because the address already has an account.
5
33
  *
6
- * Matched on WORDING rather than on an error code, which looks fragile and is
7
- * the only thing that works: the server raises a Nest `ConflictException` and it
8
- * arrives over GraphQL as `INTERNAL_SERVER_ERROR` verified against a live
9
- * tier so a caller keying on `CONFLICT` matches nothing and treats a routine
10
- * "this account exists" as a server fault.
34
+ * `EMAIL_ALREADY_REGISTERED` from v1.60.0. Before that it arrived as
35
+ * `INTERNAL_SERVER_ERROR`, so a caller keying on the code matched nothing and
36
+ * treated a routine "this account exists" as a server fault; the wording branch
37
+ * is what still works against those tiers.
38
+ *
39
+ * Note what this does NOT accept: a bare `CONFLICT`. From v1.60.0 that is the
40
+ * code a generic 409 carries, and a generic code cannot identify a specific
41
+ * condition — a predicate that accepted it would report any future conflict in
42
+ * this mutation as "already registered". Only a code minted for this outcome
43
+ * will do.
11
44
  */
12
45
  export function isAlreadyRegisteredError(error) {
13
- const message = error instanceof Error ? error.message : String(error);
14
- return /account with this email already exists/i.test(message);
46
+ return (codeOf(error) === 'EMAIL_ALREADY_REGISTERED' ||
47
+ /account with this email already exists/i.test(messageOf(error)));
15
48
  }
16
49
  /**
17
50
  * `login` refused because the password is real but not yet confirmed, on an
18
51
  * account that has another verified sign-in method. The remedy is the emailed
19
52
  * confirmation link, not a different password — so this must not be reported to
20
53
  * the user as "wrong password".
54
+ *
55
+ * **The one refusal here with no code of its own, and the only wording-only
56
+ * predicate left.** ck-api v1.60.0 gave the other four a dedicated
57
+ * `extensions.code`; this one is still a plain `UnauthorizedException`, so it
58
+ * arrives as `UNAUTHENTICATED` — the same code as an expired session, whose
59
+ * remedy (sign in again) is the opposite of this one's. The message is
60
+ * therefore the only discriminator, and the absence of a `codeOf` branch below
61
+ * is deliberate rather than an oversight: there is no code to read.
21
62
  */
22
63
  export function isPasswordUnconfirmedError(error) {
23
64
  const message = error instanceof Error ? error.message : String(error);
24
65
  return /confirm your email to enable password sign-in/i.test(message);
25
66
  }
67
+ /**
68
+ * {@link setInitialPassword} refused because the account already has a
69
+ * password. The remedy is {@link changePassword}, which verifies the current
70
+ * one.
71
+ *
72
+ * The refusal is deliberate and load-bearing: without it, `setInitialPassword`
73
+ * would be `changePassword` with the current-password check deleted, which is
74
+ * the check that stops a stolen session from silently locking the owner out.
75
+ * So a caller must route to `changePassword` rather than retrying.
76
+ *
77
+ * `PASSWORD_ALREADY_SET` from ck-api v1.60.0. Before that the schema said this
78
+ * "throws CONFLICT" and it reached clients as `INTERNAL_SERVER_ERROR`, so the
79
+ * message was the only thing to match — which is why the wording branch is
80
+ * still here.
81
+ */
82
+ export function isPasswordAlreadySetError(error) {
83
+ return (codeOf(error) === 'PASSWORD_ALREADY_SET' ||
84
+ /this account already has a password/i.test(messageOf(error)));
85
+ }
86
+ /**
87
+ * {@link changePassword} refused because there is no password to change — a
88
+ * magic-link or social-only account. The remedy is {@link setInitialPassword}
89
+ * while signed in, or {@link requestPasswordReset}.
90
+ *
91
+ * `PASSWORD_NOT_SET` from ck-api v1.60.0. Before that this and
92
+ * {@link isInvalidCurrentPasswordError} both arrived as `UNAUTHENTICATED`,
93
+ * which is ALSO what an expired session looks like — so a caller keying on the
94
+ * code signed the user out when they had merely typed the wrong current
95
+ * password, or offered a password field on an account that has none. That is
96
+ * the defect the new codes exist to remove, and the wording branch is what
97
+ * still separates them on a tier that has not deployed it.
98
+ */
99
+ export function isNoPasswordSetError(error) {
100
+ return (codeOf(error) === 'PASSWORD_NOT_SET' ||
101
+ /no password is set on this account/i.test(messageOf(error)));
102
+ }
103
+ /**
104
+ * {@link changePassword} refused because the current password is wrong. The
105
+ * remedy is to ask again — the session is fine.
106
+ *
107
+ * `INVALID_CURRENT_PASSWORD` (HTTP 403) from ck-api v1.60.0. See
108
+ * {@link isNoPasswordSetError} for what it used to be and why the wording
109
+ * branch stays.
110
+ */
111
+ export function isInvalidCurrentPasswordError(error) {
112
+ return (codeOf(error) === 'INVALID_CURRENT_PASSWORD' ||
113
+ /invalid current password/i.test(messageOf(error)));
114
+ }
26
115
  const AUTH_RESPONSE_FIELDS = 'token gameTokenId user { userId email gamertag }';
27
116
  const IDENTITY_FIELDS = 'identityId provider subject email emailVerified createdAt lastLoginAt';
28
117
  // `devToken` is NOT selected. The field returned the magic-link token in
@@ -40,6 +129,22 @@ const SocialLoginCompleteDocument = parse(`mutation SocialLoginComplete($input:
40
129
  // naming a field the caller never wrote.
41
130
  const LoginDocument = parse(`mutation Login($loginUserInput: LoginUserInput!) { login(loginUserInput: $loginUserInput) { ${AUTH_RESPONSE_FIELDS} } }`);
42
131
  const RegisterDocument = parse(`mutation Register($registerUserInput: RegisterUserInput!) { register(registerUserInput: $registerUserInput) { ${AUTH_RESPONSE_FIELDS} } }`);
132
+ // PASSWORD MANAGEMENT. Four mutations, and they are four rather than one or two
133
+ // on purpose: each is defined by what the CALLER has already proven, and
134
+ // collapsing any pair would delete the proof.
135
+ //
136
+ // requestPasswordReset / resetPassword proof = the emailed token
137
+ // changePassword proof = the current password
138
+ // setInitialPassword proof = the session, and there is no
139
+ // password to verify
140
+ //
141
+ // `changePassword` takes its two arguments FLAT rather than in an input object,
142
+ // and `resetPassword` takes `resetPasswordInput` rather than `input`. Both are
143
+ // the server's spelling; see the note above `LoginDocument`.
144
+ const RequestPasswordResetDocument = parse(`mutation RequestPasswordReset($email: String!) { requestPasswordReset(email: $email) }`);
145
+ const ResetPasswordDocument = parse(`mutation ResetPassword($resetPasswordInput: ResetPasswordInput!) { resetPassword(resetPasswordInput: $resetPasswordInput) }`);
146
+ const ChangePasswordDocument = parse(`mutation ChangePassword($currentPassword: String!, $newPassword: String!) { changePassword(currentPassword: $currentPassword, newPassword: $newPassword) }`);
147
+ const SetInitialPasswordDocument = parse(`mutation SetInitialPassword($newPassword: String!) { setInitialPassword(newPassword: $newPassword) }`);
43
148
  const CheckAuthMethodDocument = parse(`query CheckAuthMethod($input: CheckAuthMethodInput!) { checkAuthMethod(input: $input) { hasPassword } }`);
44
149
  const AvailableLoginProvidersDocument = parse(`query AvailableLoginProviders { availableLoginProviders }`);
45
150
  const MyIdentitiesDocument = parse(`query MyIdentities { myIdentities { ${IDENTITY_FIELDS} } }`);
@@ -139,6 +244,96 @@ export class AuthAPI {
139
244
  });
140
245
  return data.checkAuthMethod;
141
246
  }
247
+ /**
248
+ * Email a password-reset link to the address. Public.
249
+ *
250
+ * Always resolves `true` whether or not the address has an account, so it
251
+ * cannot be used to enumerate users — which also means a `true` here is not
252
+ * evidence an email was sent.
253
+ *
254
+ * This is the ownership-proven way to add a password to an account that has
255
+ * none, and the only one for a user who is not signed in. A user who IS
256
+ * signed in should use {@link setInitialPassword} instead and skip the inbox.
257
+ */
258
+ async requestPasswordReset(email) {
259
+ const data = await this.graphql.request(RequestPasswordResetDocument, {
260
+ email,
261
+ });
262
+ return data.requestPasswordReset;
263
+ }
264
+ /**
265
+ * Complete a password reset with the token from the emailed link. Public —
266
+ * the token is the authorization.
267
+ *
268
+ * Throws if the token is invalid or expired. **Existing sessions are not
269
+ * revoked**, so a reset does not by itself evict anyone already signed in;
270
+ * follow it with {@link logoutAllDevices} if that is what you want.
271
+ */
272
+ async resetPassword(input) {
273
+ const data = await this.graphql.request(ResetPasswordDocument, {
274
+ resetPasswordInput: input,
275
+ });
276
+ return data.resetPassword;
277
+ }
278
+ /**
279
+ * Change the signed-in user's password, verifying the current one. Requires a
280
+ * session.
281
+ *
282
+ * **This is not the method for an account that has no password** — a
283
+ * magic-link or social-only account, which cannot supply a current one. That
284
+ * is {@link setInitialPassword}, and the two are kept apart deliberately:
285
+ * the current-password check here is what stops a stolen session from
286
+ * changing a credential the owner still knows.
287
+ *
288
+ * Three outcomes need telling apart and the error CODE cannot do it, because
289
+ * a wrong current password, an account with no password, and an expired
290
+ * session all arrive as `UNAUTHENTICATED`:
291
+ * {@link isInvalidCurrentPasswordError} (ask again),
292
+ * {@link isNoPasswordSetError} (send them to `setInitialPassword`), and
293
+ * neither (the session is gone — sign in again).
294
+ *
295
+ * **Existing sessions are not revoked.**
296
+ */
297
+ async changePassword(input) {
298
+ const data = await this.graphql.request(ChangePasswordDocument, input);
299
+ return data.changePassword;
300
+ }
301
+ /**
302
+ * Add a password to the signed-in account when it does not have one yet.
303
+ * Requires a session.
304
+ *
305
+ * For an account created by magic link or a social provider, which until this
306
+ * existed had no in-product route to password sign-in at all — the only door
307
+ * was {@link requestPasswordReset}, an email round trip to add a credential to
308
+ * an account you are already signed in to. The session is the proof of account
309
+ * control, so **the password works immediately**: there is no confirmation
310
+ * email to wait for, and the password identity is written verified (an
311
+ * unverified one would be refused by {@link login} while another verified
312
+ * method exists, which is a dead end that looks like success).
313
+ *
314
+ * **Refuses when a password already exists** — {@link isPasswordAlreadySetError}
315
+ * detects it — rather than replacing it. Without that refusal this would be
316
+ * {@link changePassword} with the current-password check deleted. Route to
317
+ * `changePassword`, or to `requestPasswordReset` if the user has forgotten it.
318
+ *
319
+ * **A security notification is emailed to the account address** whenever this
320
+ * succeeds. That is the mitigation, and it is deliberately a notification
321
+ * rather than a refusal: a stolen session can already attach durable
322
+ * attacker-controlled access via {@link linkIdentity}, so refusing here would
323
+ * remove the legitimate user's only door without closing the class. Do not
324
+ * suppress or reword that email's role when you describe this to a user —
325
+ * "we have emailed you about this change" is part of the feature. The
326
+ * notification is best-effort on the server, so a `true` return is not proof
327
+ * the email was delivered.
328
+ *
329
+ * **Existing sessions are not revoked.**
330
+ */
331
+ async setInitialPassword(newPassword) {
332
+ const data = await this.graphql.request(SetInitialPasswordDocument, {
333
+ newPassword,
334
+ });
335
+ return data.setInitialPassword;
336
+ }
142
337
  /** The signed-in user's linked sign-in identities. Requires a session. */
143
338
  async myIdentities() {
144
339
  const data = await this.graphql.request(MyIdentitiesDocument);
@@ -1 +1 @@
1
- {"version":3,"file":"crowdyStudio.d.ts","sourceRoot":"","sources":["../../src/domains/crowdyStudio.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAQlD,OAAO,EAIL,KAAK,8BAA8B,EACnC,KAAK,oCAAoC,EAEzC,KAAK,mBAAmB,EAExB,KAAK,2BAA2B,EAChC,KAAK,wBAAwB,EAC7B,KAAK,0BAA0B,EAC/B,KAAK,yBAAyB,EAC9B,KAAK,gCAAgC,EACrC,KAAK,4BAA4B,EAClC,MAAM,4BAA4B,CAAC;AA0BpC;;;;;;GAMG;AACH,qBAAa,eAAgB,YAAW,2BAA2B;IAGrD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0C;gBAEvC,OAAO,EAAE,aAAa;IAE7C,YAAY,CAChB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,0BAA0B,EAAE,CAAC;IAUlC,UAAU,CACd,KAAK,EAAE,wBAAwB,GAAG;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GACtD,OAAO,CAAC,mBAAmB,CAAC;IAQzB,aAAa,CACjB,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC;IAoBzB,WAAW,CACf,KAAK,EAAE,4BAA4B,GAClC,OAAO,CAAC,mBAAmB,CAAC;IAwDzB,wBAAwB,CAC5B,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,yBAAyB,EAAE,CAAC;IAUjC,uBAAuB,CAC3B,KAAK,EAAE,gCAAgC,GACtC,OAAO,CAAC,yBAAyB,CAAC;IAc/B,eAAe,CACnB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,yBAAyB,EAAE,CAAC;IASjC,mBAAmB,CACvB,KAAK,EAAE,oCAAoC,GAC1C,OAAO,CAAC,mBAAmB,CAAC;IAuB/B,OAAO,CAAC,QAAQ;YAKF,OAAO;CAiBtB"}
1
+ {"version":3,"file":"crowdyStudio.d.ts","sourceRoot":"","sources":["../../src/domains/crowdyStudio.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAQlD,OAAO,EAIL,KAAK,8BAA8B,EACnC,KAAK,oCAAoC,EAEzC,KAAK,mBAAmB,EAExB,KAAK,2BAA2B,EAChC,KAAK,wBAAwB,EAC7B,KAAK,0BAA0B,EAC/B,KAAK,yBAAyB,EAC9B,KAAK,gCAAgC,EACrC,KAAK,4BAA4B,EAClC,MAAM,4BAA4B,CAAC;AA0BpC;;;;;;GAMG;AACH,qBAAa,eAAgB,YAAW,2BAA2B;IAGrD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0C;gBAEvC,OAAO,EAAE,aAAa;IAE7C,YAAY,CAChB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,0BAA0B,EAAE,CAAC;IAUlC,UAAU,CACd,KAAK,EAAE,wBAAwB,GAAG;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GACtD,OAAO,CAAC,mBAAmB,CAAC;IAQzB,aAAa,CACjB,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC;IAoBzB,WAAW,CACf,KAAK,EAAE,4BAA4B,GAClC,OAAO,CAAC,mBAAmB,CAAC;IAiEzB,wBAAwB,CAC5B,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,yBAAyB,EAAE,CAAC;IAUjC,uBAAuB,CAC3B,KAAK,EAAE,gCAAgC,GACtC,OAAO,CAAC,yBAAyB,CAAC;IAc/B,eAAe,CACnB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,yBAAyB,EAAE,CAAC;IASjC,mBAAmB,CACvB,KAAK,EAAE,oCAAoC,GAC1C,OAAO,CAAC,mBAAmB,CAAC;IAuB/B,OAAO,CAAC,QAAQ;YAKF,OAAO;CAiBtB"}
@@ -76,9 +76,18 @@ export class CrowdyStudioAPI {
76
76
  return this.remember(fromProjectDto(data.crowdyStudioProjectSave, input.gridId));
77
77
  }
78
78
  catch (error) {
79
+ // THE CODE IS `CROWDY_STUDIO_REVISION_CONFLICT`, not `CONFLICT`. This
80
+ // asked for `CONFLICT` and could therefore never match, so a real remote
81
+ // conflict never became a `CrowdyStudioRevisionConflictError` and the
82
+ // editor's "the remote moved" recovery — refetch, then offer to keep your
83
+ // version — was unreachable from the server side. The SDL description is
84
+ // where it came from: it said "returns CONFLICT with
85
+ // CROWDY_STUDIO_REVISION_CONFLICT", which reads as a code plus a detail
86
+ // and is one code with a long name. `crowdy-agent/graphql-transport.ts`
87
+ // had it right all along, in this same package.
79
88
  if (error instanceof CrowdyGraphQLError &&
80
- error.code === 'CONFLICT' &&
81
- error.message.includes('CROWDY_STUDIO_REVISION_CONFLICT')) {
89
+ (error.code === 'CROWDY_STUDIO_REVISION_CONFLICT' ||
90
+ error.message.includes('CROWDY_STUDIO_REVISION_CONFLICT'))) {
82
91
  let remoteProject;
83
92
  try {
84
93
  remoteProject = await this.getProject({