@elytracms/studio 0.0.51 → 0.0.52

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/convex/auth.ts CHANGED
@@ -1,11 +1,140 @@
1
1
  import { convexAuth } from '@convex-dev/auth/server'
2
2
  import { Password } from '@convex-dev/auth/providers/Password'
3
+ import type { AnyDataModel, GenericMutationCtx } from 'convex/server'
3
4
  import { ConvexError } from 'convex/values'
5
+ import type { GenericId } from 'convex/values'
4
6
  import { classifyInviteSignup, normalizeInviteEmail } from '@elytracms/core/persistence'
7
+ import type { InviteSignupDecision } from '@elytracms/core/persistence'
5
8
 
6
9
  /** The provider list `convexAuth` accepts, derived from its own signature. */
7
10
  type ConvexAuthProviders = Parameters<typeof convexAuth>[0]['providers']
8
11
 
12
+ /** The (non-schema-bound) mutation ctx the `convexAuth` callbacks receive. */
13
+ type AuthCallbackCtx = GenericMutationCtx<AnyDataModel>
14
+
15
+ /**
16
+ * Gather the invite-gate facts for a principal and classify them (EC-324).
17
+ * The ONE decision lives in the pure `classifyInviteSignup`
18
+ * (`@elytracms/core/persistence`, shared with its tests):
19
+ *
20
+ * - **existing member** → normal re-sign-in; no-op (also recovers a member-less
21
+ * account it created earlier and never lets the deployment lock itself out).
22
+ * - **first user** (no members yet) → bootstrap as `admin`.
23
+ * - **invited** → grant the invited role (+ consume the invite).
24
+ * - **uninvited** → REJECT (closes the EC-304 open-sign-up hole).
25
+ */
26
+ async function decideInviteGate(
27
+ ctx: AuthCallbackCtx,
28
+ args: { userId: GenericId<'users'>; email: string | null },
29
+ ): Promise<{ decision: InviteSignupDecision; invite: { _id: string; role: string } | null }> {
30
+ // The convexAuth callback ctx is `AnyDataModel` (not schema-bound), so use
31
+ // `.filter()` rather than `.withIndex()` here. The members/invites tables are
32
+ // tiny (single-tenant), and this runs only at account creation / sign-in.
33
+ const existingMember = await ctx.db
34
+ .query('members')
35
+ .filter((q) => q.eq(q.field('userId'), args.userId))
36
+ .first()
37
+ const anyMember = existingMember ?? (await ctx.db.query('members').first())
38
+ const email = args.email ? normalizeInviteEmail(args.email) : null
39
+ const invite = email
40
+ ? await ctx.db
41
+ .query('invites')
42
+ .filter((q) => q.eq(q.field('email'), email))
43
+ .first()
44
+ : null
45
+
46
+ const decision = classifyInviteSignup({
47
+ hasAnyMember: anyMember !== null,
48
+ existingRole: existingMember?.role ?? null,
49
+ invite: invite ? { role: invite.role } : null,
50
+ })
51
+ return { decision, invite }
52
+ }
53
+
54
+ /**
55
+ * EC-358 — the ACCOUNT-CREATION gate. Wired as `afterUserCreatedOrUpdated`,
56
+ * which Convex Auth calls inside the SAME `auth:store` mutation that creates
57
+ * the principal's rows — after the `users` insert, **before** the
58
+ * `authAccounts` insert (`upsertUserAndAccount`). A Convex mutation is one
59
+ * atomic transaction, so throwing here rolls the `users` insert back and the
60
+ * `authAccounts` row is never written: an uninvited sign-up leaves ZERO rows.
61
+ *
62
+ * Why this hook: the EC-324 gate lived only in `beforeSessionCreation`, which
63
+ * runs in a LATER store mutation — by then the password `authAccounts` + `users`
64
+ * rows were already committed, so a rejected uninvited sign-up left an orphan
65
+ * account carrying an attacker-chosen password that a later invite would
66
+ * silently promote (proven live 2026-07-08). The alternative pre-create hook,
67
+ * `createOrUpdateUser`, REPLACES the library's user-creation/linking mechanics
68
+ * wholesale — a far bigger seam than this scope allows; this hook keeps the
69
+ * default mechanics and only adds the veto.
70
+ *
71
+ * Only REJECT throws here; membership granting/consuming stays in
72
+ * `beforeSessionCreation` (the account-creation callback also fires for
73
+ * verification/OAuth account flows where a session may not follow immediately).
74
+ * For credentials providers this fires exactly on `createAccount` (sign-UP),
75
+ * not on re-sign-in.
76
+ */
77
+ export async function gateAccountCreation(
78
+ ctx: AuthCallbackCtx,
79
+ args: { userId: GenericId<'users'>; profile: { email?: string } },
80
+ ): Promise<void> {
81
+ const email = typeof args.profile.email === 'string' ? args.profile.email : null
82
+ const { decision } = await decideInviteGate(ctx, { userId: args.userId, email })
83
+ if (decision.kind === 'reject') throw new ConvexError(decision.reason)
84
+ }
85
+
86
+ /**
87
+ * Invite-based onboarding gate + first-principal bootstrap (EC-324 / EC-150).
88
+ * Wired as `beforeSessionCreation`: fires on EVERY sign-in (not just account
89
+ * creation), right before the session is persisted — throwing here rejects the
90
+ * sign-in. See {@link decideInviteGate} for the decision table.
91
+ *
92
+ * The `ConvexError` message ("not-invited: …") survives to the client even in
93
+ * production, so the sign-in screen tells the truth instead of the old
94
+ * "password too short". The `ConvexAuthAdapter` classifies it to a distinct
95
+ * `not-invited` `SignInResult` reason.
96
+ *
97
+ * Single-tenant: one `members` table per deployment. Invite mechanism: a plain
98
+ * email allowlist (`invites` table) — the simplest server-enforced design, no
99
+ * token infrastructure.
100
+ */
101
+ export async function gateSessionCreation(
102
+ ctx: AuthCallbackCtx,
103
+ args: { userId: GenericId<'users'> },
104
+ ): Promise<void> {
105
+ const now = new Date().toISOString()
106
+ const user = await ctx.db.get(args.userId)
107
+ const rawEmail = (user as { email?: string } | null)?.email
108
+ const { decision, invite } = await decideInviteGate(ctx, {
109
+ userId: args.userId,
110
+ email: rawEmail ?? null,
111
+ })
112
+
113
+ switch (decision.kind) {
114
+ case 'existing-member':
115
+ return
116
+ case 'bootstrap-admin':
117
+ await ctx.db.insert('members', {
118
+ userId: args.userId,
119
+ role: 'admin',
120
+ createdAt: now,
121
+ updatedAt: now,
122
+ })
123
+ return
124
+ case 'grant-role':
125
+ await ctx.db.insert('members', {
126
+ userId: args.userId,
127
+ role: decision.role,
128
+ createdAt: now,
129
+ updatedAt: now,
130
+ })
131
+ if (invite) await ctx.db.delete(invite._id as GenericId<'invites'>)
132
+ return
133
+ case 'reject':
134
+ throw new ConvexError(decision.reason)
135
+ }
136
+ }
137
+
9
138
  /**
10
139
  * EC-252 — the studio's Convex Auth (EC-150) as a **default + override seam**. Only the
11
140
  * PROVIDER choice is legitimately consumer-specific; the first-principal admin bootstrap
@@ -30,69 +159,13 @@ export function defineStudioAuth(options?: { providers?: ConvexAuthProviders })
30
159
  return convexAuth({
31
160
  providers: options?.providers ?? [Password],
32
161
  callbacks: {
33
- /**
34
- * Invite-based onboarding gate + first-principal bootstrap (EC-324 / EC-150).
35
- * Fires on EVERY sign-in (not just account creation), right before the session
36
- * is persisted — throwing here rejects the sign-in. The ONE decision lives in
37
- * the pure `classifyInviteSignup` (shared with its tests):
38
- *
39
- * - **existing member** → normal re-sign-in; no-op (also recovers a member-less
40
- * account it created earlier and never lets the deployment lock itself out).
41
- * - **first user** (no members yet) → bootstrap as `admin` (was the only case).
42
- * - **invited** → grant the invited role + CONSUME the invite (delete the row).
43
- * - **uninvited** → REJECT. This closes the open-sign-up hole (EC-304): an
44
- * anonymous person can no longer self-register into the deployment. The
45
- * `ConvexError` message ("not-invited: …") survives to the client even in
46
- * production, so the sign-in screen tells the truth.
47
- *
48
- * Single-tenant: one `members` table per deployment. Invite mechanism: a plain
49
- * email allowlist (`invites` table) — the simplest server-enforced design, no
50
- * token infrastructure.
51
- */
162
+ /** EC-358: reject uninvited account creation BEFORE any row commits. */
163
+ async afterUserCreatedOrUpdated(ctx, { userId, profile }) {
164
+ await gateAccountCreation(ctx, { userId, profile })
165
+ },
166
+ /** EC-324: the every-sign-in gate — bootstrap / grant / consume / reject. */
52
167
  async beforeSessionCreation(ctx, { userId }) {
53
- const now = new Date().toISOString()
54
- // The convexAuth callback ctx is `AnyDataModel` (not schema-bound), so use
55
- // `.filter()` rather than `.withIndex()` here. The members/invites tables are
56
- // tiny (single-tenant), and this runs only at sign-in.
57
- const existingMember = await ctx.db
58
- .query('members')
59
- .filter((q) => q.eq(q.field('userId'), userId))
60
- .first()
61
- const anyMember = existingMember ?? (await ctx.db.query('members').first())
62
- const user = await ctx.db.get(userId)
63
- const rawEmail = (user as { email?: string } | null)?.email
64
- const email = rawEmail ? normalizeInviteEmail(rawEmail) : null
65
- const invite = email
66
- ? await ctx.db
67
- .query('invites')
68
- .filter((q) => q.eq(q.field('email'), email))
69
- .first()
70
- : null
71
-
72
- const decision = classifyInviteSignup({
73
- hasAnyMember: anyMember !== null,
74
- existingRole: existingMember?.role ?? null,
75
- invite: invite ? { role: invite.role } : null,
76
- })
77
-
78
- switch (decision.kind) {
79
- case 'existing-member':
80
- return
81
- case 'bootstrap-admin':
82
- await ctx.db.insert('members', { userId, role: 'admin', createdAt: now, updatedAt: now })
83
- return
84
- case 'grant-role':
85
- await ctx.db.insert('members', {
86
- userId,
87
- role: decision.role,
88
- createdAt: now,
89
- updatedAt: now,
90
- })
91
- if (invite) await ctx.db.delete(invite._id)
92
- return
93
- case 'reject':
94
- throw new ConvexError(decision.reason)
95
- }
168
+ await gateSessionCreation(ctx, { userId })
96
169
  },
97
170
  },
98
171
  })
package/convex/invites.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { v } from 'convex/values'
2
+ import { selectOrphanCredentialPurges } from '@elytracms/core/persistence'
2
3
  import { mutation, query } from './functions'
4
+ import type { MutationCtx } from './functions'
3
5
  import { requireCapability } from './guard'
4
6
 
5
7
  /**
@@ -17,6 +19,78 @@ import { requireCapability } from './guard'
17
19
 
18
20
  const normalizeEmail = (email: string): string => email.trim().toLowerCase()
19
21
 
22
+ /**
23
+ * EC-358 — an invite must never adopt pre-existing orphan credentials.
24
+ *
25
+ * Threat: an attacker pre-registers a password for someone's email; before
26
+ * EC-358's account-creation gate that left an orphan (member-less)
27
+ * `users` + `authAccounts` pair, and a later invite for that email would have
28
+ * silently promoted the ATTACKER's credentials at their next sign-in. Creating
29
+ * an invite is therefore the purge chokepoint: purging at gate-promotion time
30
+ * would be too late by construction — the promotion is *triggered by* whoever
31
+ * holds the password. Purged here, the invited person always sets their own.
32
+ *
33
+ * WHAT is purged, per member-less user matching the invited email (selection is
34
+ * the pure `selectOrphanCredentialPurges`, normalized-email matched): their
35
+ * `authSessions` (+ `authRefreshTokens`), `authAccounts`
36
+ * (+ `authVerificationCodes`), and the `users` row itself — so no stale session
37
+ * or reset code survives the credential purge. A user with a `members` row is
38
+ * NEVER touched, whatever their email. Runs in the invite-creation mutation, so
39
+ * purge + invite land in one atomic transaction.
40
+ *
41
+ * The `users` table is tiny (single-tenant studio principals), so the
42
+ * collect-and-match scan is fine — and required, because the auth provider
43
+ * stores emails as typed at sign-up (an index lookup on the normalized email
44
+ * would miss case-varied orphans the sign-in gate would still promote).
45
+ */
46
+ export async function purgeOrphanCredentials(
47
+ ctx: Pick<MutationCtx, 'db'>,
48
+ invitedEmail: string,
49
+ ): Promise<number> {
50
+ const members = await ctx.db.query('members').collect()
51
+ const memberIds = new Set(members.map((member) => member.userId))
52
+ const users = await ctx.db.query('users').collect()
53
+ const purgeIds = new Set(
54
+ selectOrphanCredentialPurges(
55
+ invitedEmail,
56
+ users.map((user) => ({
57
+ id: String(user._id),
58
+ email: user.email ?? null,
59
+ isMember: memberIds.has(String(user._id)),
60
+ })),
61
+ ),
62
+ )
63
+ const orphans = users.filter((user) => purgeIds.has(String(user._id)))
64
+ for (const orphan of orphans) {
65
+ const sessions = await ctx.db
66
+ .query('authSessions')
67
+ .withIndex('userId', (q) => q.eq('userId', orphan._id))
68
+ .collect()
69
+ for (const session of sessions) {
70
+ const tokens = await ctx.db
71
+ .query('authRefreshTokens')
72
+ .withIndex('sessionId', (q) => q.eq('sessionId', session._id))
73
+ .collect()
74
+ for (const token of tokens) await ctx.db.delete(token._id)
75
+ await ctx.db.delete(session._id)
76
+ }
77
+ const accounts = await ctx.db
78
+ .query('authAccounts')
79
+ .withIndex('userIdAndProvider', (q) => q.eq('userId', orphan._id))
80
+ .collect()
81
+ for (const account of accounts) {
82
+ const codes = await ctx.db
83
+ .query('authVerificationCodes')
84
+ .withIndex('accountId', (q) => q.eq('accountId', account._id))
85
+ .collect()
86
+ for (const code of codes) await ctx.db.delete(code._id)
87
+ await ctx.db.delete(account._id)
88
+ }
89
+ await ctx.db.delete(orphan._id)
90
+ }
91
+ return orphans.length
92
+ }
93
+
20
94
  export const list = query({
21
95
  args: {},
22
96
  handler: async (ctx) => {
@@ -37,6 +111,9 @@ export const create = mutation({
37
111
  handler: async (ctx, args) => {
38
112
  await requireCapability(ctx, 'project.administer')
39
113
  const email = normalizeEmail(args.email)
114
+ // EC-358: never adopt orphan credentials — purge member-less auth rows for
115
+ // this email atomically with the invite, so the invitee sets their own password.
116
+ await purgeOrphanCredentials(ctx, email)
40
117
  const existing = await ctx.db
41
118
  .query('invites')
42
119
  .withIndex('by_email', (q) => q.eq('email', email))
@@ -12,6 +12,56 @@ import type { SessionStore } from './local-auth-adapter';
12
12
  * fallback is the neutral "unavailable" branch (never "password too short").
13
13
  */
14
14
  export declare function isDeploymentMisconfigured(message: string): boolean;
15
+ /**
16
+ * Reduce a thrown Convex error to the text used for classification (EC-362).
17
+ *
18
+ * **Read `.data` FIRST, then `.message`** — because a PRODUCTION Convex
19
+ * deployment REDACTS a UDF's thrown-error `.message` to a generic
20
+ * `"[Request ID: …] Server Error"`, but a `ConvexError`'s `.data` payload
21
+ * survives to the client. Proven in the installed `convex@1.42.1` browser
22
+ * client: on an `"error"` response `ConvexHttpClient.action` throws
23
+ * `forwardErrorData(respJSON.errorData, new ConvexError(respJSON.errorMessage))`,
24
+ * and `forwardErrorData` sets `error.data = jsonToConvex(errorData)`
25
+ * (`dist/esm/browser/http_client.js`, ~L388-396 + L459-461). The server keeps
26
+ * `errorData` for a `ConvexError` in prod while redacting `errorMessage`. So the
27
+ * only classifiable text that survives prod is the `.data` of a `ConvexError` —
28
+ * e.g. our invite gate's `ConvexError(NOT_INVITED_REASON)`. On DEV deployments
29
+ * nothing is redacted, so `.message` also carries the token (the regex ladder
30
+ * still matches); non-Convex errors only have a `.message`.
31
+ *
32
+ * Duck-types the `data` property rather than `instanceof ConvexError`: two
33
+ * copies of the `convex` module can coexist under Vite (transformed vs
34
+ * pre-bundled), so `instanceof` can spuriously fail (EC-357). `.data` may be a
35
+ * string (our gates) or a structured object (future gates) — both are folded in.
36
+ */
37
+ export declare function convexErrorClassifierText(error: unknown): string;
38
+ type SignInFailure = Extract<SignInResult, {
39
+ ok: false;
40
+ }>;
41
+ /**
42
+ * Pure sign-in/sign-up error classifier (EC-257 / EC-324 / EC-362). Runs the
43
+ * regex ladder over {@link convexErrorClassifierText} output (data-first).
44
+ *
45
+ * Order matters: a DEPLOYMENT-misconfiguration (EC-257) and an uninvited
46
+ * sign-up (EC-324) are each checked BEFORE the credential regex so neither
47
+ * degrades to the lying "password too short". The final fallback is HONEST
48
+ * ("unexpected server error — check the deployment logs"), never "unavailable":
49
+ * on a prod deployment the credential/`InvalidAccountId`/env errors thrown by
50
+ * `@convex-dev/auth` are PLAIN `Error`s (not `ConvexError`s), so prod redacts
51
+ * them to `"Server Error"` with no `.data` and they legitimately land here.
52
+ */
53
+ export declare function classifySignInError(flow: 'signIn' | 'signUp', text: string): SignInFailure;
54
+ type ChangePasswordFailure = Extract<ChangePasswordResult, {
55
+ ok: false;
56
+ }>;
57
+ /**
58
+ * Pure password-change error classifier (EC-240 H-tail / EC-362). Same
59
+ * data-first text + honest fallback discipline as {@link classifySignInError};
60
+ * `deployment-misconfigured` collapses onto the `unavailable` reason
61
+ * (`ChangePasswordResult` has no dedicated reason for it) with a specific
62
+ * remediation message.
63
+ */
64
+ export declare function classifyChangePasswordError(text: string): ChangePasswordFailure;
15
65
  /**
16
66
  * Per-deployment token key (EC-179, vision AD-8). One project per backend, so a
17
67
  * session is scoped to its deployment: signing into Aurora's backend must not
@@ -64,3 +114,4 @@ export declare class ConvexAuthAdapter implements AuthAdapter {
64
114
  private toUser;
65
115
  private clearToken;
66
116
  }
117
+ export {};
@@ -35,6 +35,146 @@ const TOKEN_STORAGE_PREFIX = 'elytra.convex-auth.jwt';
35
35
  export function isDeploymentMisconfigured(message) {
36
36
  return /JWT_PRIVATE_KEY|JWKS|CONVEX_SITE_URL|Missing environment variable|PKCS8|private key/i.test(message);
37
37
  }
38
+ /**
39
+ * Reduce a thrown Convex error to the text used for classification (EC-362).
40
+ *
41
+ * **Read `.data` FIRST, then `.message`** — because a PRODUCTION Convex
42
+ * deployment REDACTS a UDF's thrown-error `.message` to a generic
43
+ * `"[Request ID: …] Server Error"`, but a `ConvexError`'s `.data` payload
44
+ * survives to the client. Proven in the installed `convex@1.42.1` browser
45
+ * client: on an `"error"` response `ConvexHttpClient.action` throws
46
+ * `forwardErrorData(respJSON.errorData, new ConvexError(respJSON.errorMessage))`,
47
+ * and `forwardErrorData` sets `error.data = jsonToConvex(errorData)`
48
+ * (`dist/esm/browser/http_client.js`, ~L388-396 + L459-461). The server keeps
49
+ * `errorData` for a `ConvexError` in prod while redacting `errorMessage`. So the
50
+ * only classifiable text that survives prod is the `.data` of a `ConvexError` —
51
+ * e.g. our invite gate's `ConvexError(NOT_INVITED_REASON)`. On DEV deployments
52
+ * nothing is redacted, so `.message` also carries the token (the regex ladder
53
+ * still matches); non-Convex errors only have a `.message`.
54
+ *
55
+ * Duck-types the `data` property rather than `instanceof ConvexError`: two
56
+ * copies of the `convex` module can coexist under Vite (transformed vs
57
+ * pre-bundled), so `instanceof` can spuriously fail (EC-357). `.data` may be a
58
+ * string (our gates) or a structured object (future gates) — both are folded in.
59
+ */
60
+ export function convexErrorClassifierText(error) {
61
+ const parts = [];
62
+ if (typeof error === 'object' && error !== null && 'data' in error) {
63
+ const data = error.data;
64
+ if (typeof data === 'string') {
65
+ parts.push(data);
66
+ }
67
+ else if (data !== null && data !== undefined) {
68
+ try {
69
+ parts.push(JSON.stringify(data));
70
+ }
71
+ catch {
72
+ // Non-serializable data (cycles) — ignore; fall back to the message.
73
+ }
74
+ }
75
+ }
76
+ if (error instanceof Error)
77
+ parts.push(error.message);
78
+ else if (typeof error === 'string')
79
+ parts.push(error);
80
+ if (parts.length === 0)
81
+ parts.push(String(error));
82
+ return parts.join('\n');
83
+ }
84
+ /**
85
+ * Pure sign-in/sign-up error classifier (EC-257 / EC-324 / EC-362). Runs the
86
+ * regex ladder over {@link convexErrorClassifierText} output (data-first).
87
+ *
88
+ * Order matters: a DEPLOYMENT-misconfiguration (EC-257) and an uninvited
89
+ * sign-up (EC-324) are each checked BEFORE the credential regex so neither
90
+ * degrades to the lying "password too short". The final fallback is HONEST
91
+ * ("unexpected server error — check the deployment logs"), never "unavailable":
92
+ * on a prod deployment the credential/`InvalidAccountId`/env errors thrown by
93
+ * `@convex-dev/auth` are PLAIN `Error`s (not `ConvexError`s), so prod redacts
94
+ * them to `"Server Error"` with no `.data` and they legitimately land here.
95
+ */
96
+ export function classifySignInError(flow, text) {
97
+ // EC-257: a DEPLOYMENT-misconfiguration — its auth signing key is unset — must be
98
+ // classified FIRST so it never falls through to the lying "password too short".
99
+ // Convex Auth signs the session JWT with the deployment's `JWT_PRIVATE_KEY`
100
+ // (+ `JWKS`/`CONVEX_SITE_URL`); when any is unset (`npx @convex-dev/auth` never
101
+ // run) the `requireEnv`/`importPKCS8` call throws AFTER the password is validated,
102
+ // so EVERY sign-in fails regardless of the password.
103
+ if (isDeploymentMisconfigured(text)) {
104
+ return {
105
+ ok: false,
106
+ reason: 'deployment-misconfigured',
107
+ message: 'This deployment can’t sign you in: its auth signing key is not set. Run `npx @convex-dev/auth` against this deployment, then retry. (The deployment-setup check reports the same.)',
108
+ };
109
+ }
110
+ // EC-324: an uninvited sign-up is rejected server-side (`gateAccountCreation`/
111
+ // `gateSessionCreation` throw `ConvexError(NOT_INVITED_REASON)`, whose `.data`
112
+ // survives to the client even in production). Checked BEFORE the credential regex.
113
+ if (/not-invited|invite-only|invite only/i.test(text)) {
114
+ return {
115
+ ok: false,
116
+ reason: 'not-invited',
117
+ message: 'This deployment is invite-only. Ask an admin to invite your email, then create your account.',
118
+ };
119
+ }
120
+ if (flow === 'signUp' && /already|exists|InvalidAccountId/i.test(text)) {
121
+ return {
122
+ ok: false,
123
+ reason: 'account-exists',
124
+ message: 'An account with this email already exists — sign in instead.',
125
+ };
126
+ }
127
+ if (/InvalidSecret|InvalidAccountId|credential|password/i.test(text)) {
128
+ return {
129
+ ok: false,
130
+ reason: 'invalid-credentials',
131
+ message: flow === 'signUp'
132
+ ? 'That email or password can’t be used (password too short?).'
133
+ : 'Email or password is incorrect.',
134
+ };
135
+ }
136
+ const verb = flow === 'signUp' ? 'Sign-up' : 'Sign-in';
137
+ return {
138
+ ok: false,
139
+ reason: 'unavailable',
140
+ message: `${verb} failed (unexpected server error) — check the deployment logs.`,
141
+ };
142
+ }
143
+ /**
144
+ * Pure password-change error classifier (EC-240 H-tail / EC-362). Same
145
+ * data-first text + honest fallback discipline as {@link classifySignInError};
146
+ * `deployment-misconfigured` collapses onto the `unavailable` reason
147
+ * (`ChangePasswordResult` has no dedicated reason for it) with a specific
148
+ * remediation message.
149
+ */
150
+ export function classifyChangePasswordError(text) {
151
+ if (isDeploymentMisconfigured(text)) {
152
+ return {
153
+ ok: false,
154
+ reason: 'unavailable',
155
+ message: 'This deployment can’t change your password: its auth signing key is not set. Run `npx @convex-dev/auth`.',
156
+ };
157
+ }
158
+ if (/InvalidSecret|InvalidAccountId|current password|incorrect/i.test(text)) {
159
+ return {
160
+ ok: false,
161
+ reason: 'invalid-credentials',
162
+ message: 'Your current password is incorrect.',
163
+ };
164
+ }
165
+ if (/at least|too short|8 characters|password requirement|weak/i.test(text)) {
166
+ return {
167
+ ok: false,
168
+ reason: 'weak-password',
169
+ message: 'Choose a stronger password (at least 8 characters).',
170
+ };
171
+ }
172
+ return {
173
+ ok: false,
174
+ reason: 'unavailable',
175
+ message: 'Password change failed (unexpected server error) — check the deployment logs.',
176
+ };
177
+ }
38
178
  /**
39
179
  * Per-deployment token key (EC-179, vision AD-8). One project per backend, so a
40
180
  * session is scoped to its deployment: signing into Aurora's backend must not
@@ -120,50 +260,10 @@ export class ConvexAuthAdapter {
120
260
  }
121
261
  catch (error) {
122
262
  // Convex Auth surfaces credential/registration problems as thrown errors.
123
- const message = error instanceof Error ? error.message : String(error);
124
- // EC-257: a DEPLOYMENT-misconfiguration — not a credential problem — must be
125
- // classified FIRST so it never falls through to the lying "password too short".
126
- // Convex Auth signs the session JWT with the deployment's `JWT_PRIVATE_KEY`
127
- // (+ `JWKS`/`CONVEX_SITE_URL`); when any is unset (`npx @convex-dev/auth`
128
- // never run) the `requireEnv`/`importPKCS8` call throws *after* the password
129
- // is validated, so EVERY sign-in fails regardless of the password. This is
130
- // the exact footgun that cost hours on the Konditorei proof (2026-06-22).
131
- if (isDeploymentMisconfigured(message)) {
132
- return {
133
- ok: false,
134
- reason: 'deployment-misconfigured',
135
- message: 'This deployment can’t sign you in: its auth signing key is not set. Run `npx @convex-dev/auth` against this deployment, then retry. (The deployment-setup check reports the same.)',
136
- };
137
- }
138
- // EC-324: an uninvited sign-up is rejected server-side (`beforeSessionCreation`
139
- // throws a `ConvexError('not-invited: …')`, whose data survives to the client
140
- // even in production). Classified BEFORE the credential regex so it never
141
- // degrades to "password too short".
142
- if (/not-invited|invite-only|invite only/i.test(message)) {
143
- return {
144
- ok: false,
145
- reason: 'not-invited',
146
- message: 'This deployment is invite-only. Ask an admin to invite your email, then create your account.',
147
- };
148
- }
149
- if (flow === 'signUp' && /already|exists|InvalidAccountId/i.test(message)) {
150
- return {
151
- ok: false,
152
- reason: 'account-exists',
153
- message: 'An account with this email already exists — sign in instead.',
154
- };
155
- }
156
- if (/InvalidSecret|InvalidAccountId|credential|password/i.test(message)) {
157
- return {
158
- ok: false,
159
- reason: 'invalid-credentials',
160
- message: flow === 'signUp'
161
- ? 'That email or password can’t be used (password too short?).'
162
- : 'Email or password is incorrect.',
163
- };
164
- }
165
- const verb = flow === 'signUp' ? 'Sign-up' : 'Sign-in';
166
- return { ok: false, reason: 'unavailable', message: `${verb} is currently unavailable.` };
263
+ // Classify from the DATA-FIRST text (a `ConvexError`'s `.data` survives prod
264
+ // redaction, the redacted `.message` does not — EC-362), then apply the pure
265
+ // regex ladder. See `convexErrorClassifierText` / `classifySignInError`.
266
+ return classifySignInError(flow, convexErrorClassifierText(error));
167
267
  }
168
268
  if (!result.tokens) {
169
269
  return {
@@ -239,29 +339,10 @@ export class ConvexAuthAdapter {
239
339
  return { ok: true };
240
340
  }
241
341
  catch (error) {
242
- const message = error instanceof Error ? error.message : String(error);
243
- if (isDeploymentMisconfigured(message)) {
244
- return {
245
- ok: false,
246
- reason: 'unavailable',
247
- message: 'This deployment can’t change your password: its auth signing key is not set. Run `npx @convex-dev/auth`.',
248
- };
249
- }
250
- if (/InvalidSecret|InvalidAccountId|current password|incorrect/i.test(message)) {
251
- return {
252
- ok: false,
253
- reason: 'invalid-credentials',
254
- message: 'Your current password is incorrect.',
255
- };
256
- }
257
- if (/at least|too short|8 characters|password requirement|weak/i.test(message)) {
258
- return {
259
- ok: false,
260
- reason: 'weak-password',
261
- message: 'Choose a stronger password (at least 8 characters).',
262
- };
263
- }
264
- return { ok: false, reason: 'unavailable', message: 'Password change is currently unavailable.' };
342
+ // Data-first classification (EC-362): the change-password ACTION runs on the
343
+ // same deployment, so a prod redaction erases the thrown `.message` the same
344
+ // way — read a surviving `ConvexError.data` first.
345
+ return classifyChangePasswordError(convexErrorClassifierText(error));
265
346
  }
266
347
  }
267
348
  toUser(me) {
@@ -1 +1 @@
1
- {"version":3,"file":"convex-auth-adapter.js","sourceRoot":"","sources":["../../../src/lib/auth/convex-auth-adapter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAA;AAa/E;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,oBAAoB,GAAG,wBAAwB,CAAA;AAErD;;;;;;;;;GASG;AACH,MAAM,UAAU,yBAAyB,CAAC,OAAe;IACvD,OAAO,sFAAsF,CAAC,IAAI,CAChG,OAAO,CACR,CAAA;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,aAAsB;IACpD,OAAO,aAAa,CAAC,CAAC,CAAC,GAAG,oBAAoB,KAAK,aAAa,EAAE,CAAC,CAAC,CAAC,oBAAoB,CAAA;AAC3F,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CACpC,aAAqB,EACrB,QAAsB,YAAY,EAAE;IAEpC,OAAO,KAAK,CAAC,OAAO,CAAC,eAAe,CAAC,aAAa,CAAC,CAAC,KAAK,IAAI,CAAA;AAC/D,CAAC;AAYD,SAAS,YAAY;IACnB,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,MAAM,CAAC,cAAc;QAAE,OAAO,MAAM,CAAC,cAAc,CAAA;IACxF,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAA;IACxC,OAAO;QACL,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI;QACzC,OAAO,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC;QACpD,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC;KAC7C,CAAA;AACH,CAAC;AAED,MAAM,OAAO,iBAAiB;IAIT;IACA;IAJF,QAAQ,CAAQ;IAEjC,YACmB,MAAwB,EACxB,QAAsB,YAAY,EAAE;IACrD,4EAA4E;IAC5E,aAAsB;QAHL,WAAM,GAAN,MAAM,CAAkB;QACxB,UAAK,GAAL,KAAK,CAA+B;QAIrD,IAAI,CAAC,QAAQ,GAAG,eAAe,CAAC,aAAa,CAAC,CAAA;IAChD,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC/C,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAA;QACvB,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;QAC1B,IAAI,CAAC;YACH,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC,CAAoB,CAAA;YACjF,IAAI,CAAC,EAAE,EAAE,CAAC;gBACR,IAAI,CAAC,UAAU,EAAE,CAAA;gBACjB,OAAO,IAAI,CAAA;YACb,CAAC;YACD,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAA;QAClC,CAAC;QAAC,MAAM,CAAC;YACP,oEAAoE;YACpE,IAAI,CAAC,UAAU,EAAE,CAAA;YACjB,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAA8B;QACzC,OAAO,IAAI,CAAC,YAAY,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAA;IACjD,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAA8B;QACzC,OAAO,IAAI,CAAC,YAAY,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAA;IACjD,CAAC;IAED;;;OAGG;IACK,KAAK,CAAC,YAAY,CACxB,IAAyB,EACzB,WAA8B;QAE9B,IAAI,MAA0B,CAAA;QAC9B,IAAI,CAAC;YACH,MAAM,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,aAAa,CAAC,EAAE;gBAC3D,QAAQ,EAAE,UAAU;gBACpB,MAAM,EAAE;oBACN,KAAK,EAAE,WAAW,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE;oBAC7C,QAAQ,EAAE,WAAW,CAAC,QAAQ;oBAC9B,IAAI;iBACL;aACF,CAAC,CAAuB,CAAA;QAC3B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,0EAA0E;YAC1E,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;YACtE,6EAA6E;YAC7E,gFAAgF;YAChF,4EAA4E;YAC5E,0EAA0E;YAC1E,6EAA6E;YAC7E,2EAA2E;YAC3E,0EAA0E;YAC1E,IAAI,yBAAyB,CAAC,OAAO,CAAC,EAAE,CAAC;gBACvC,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,0BAA0B;oBAClC,OAAO,EACL,oLAAoL;iBACvL,CAAA;YACH,CAAC;YACD,gFAAgF;YAChF,8EAA8E;YAC9E,0EAA0E;YAC1E,oCAAoC;YACpC,IAAI,sCAAsC,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;gBACzD,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,aAAa;oBACrB,OAAO,EACL,8FAA8F;iBACjG,CAAA;YACH,CAAC;YACD,IAAI,IAAI,KAAK,QAAQ,IAAI,kCAAkC,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC1E,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,gBAAgB;oBACxB,OAAO,EAAE,8DAA8D;iBACxE,CAAA;YACH,CAAC;YACD,IAAI,qDAAqD,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;gBACxE,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,qBAAqB;oBAC7B,OAAO,EACL,IAAI,KAAK,QAAQ;wBACf,CAAC,CAAC,6DAA6D;wBAC/D,CAAC,CAAC,iCAAiC;iBACxC,CAAA;YACH,CAAC;YACD,MAAM,IAAI,GAAG,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;YACtD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,GAAG,IAAI,4BAA4B,EAAE,CAAA;QAC3F,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO;gBACL,EAAE,EAAE,KAAK;gBACT,MAAM,EAAE,qBAAqB;gBAC7B,OAAO,EAAE,iCAAiC;aAC3C,CAAA;QACH,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QACtD,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QACxC,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC,CAAoB,CAAA;QACjF,IAAI,CAAC,EAAE,EAAE,CAAC;YACR,IAAI,CAAC,UAAU,EAAE,CAAA;YACjB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,mCAAmC,EAAE,CAAA;QAC3F,CAAC;QACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,CAAA;IACzD,CAAC;IAED,KAAK,CAAC,OAAO;QACX,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,EAAE,EAAE,CAAC,CAAA;QACzD,CAAC;QAAC,MAAM,CAAC;YACP,2EAA2E;QAC7E,CAAC;QACD,IAAI,CAAC,UAAU,EAAE,CAAA;IACnB,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,aAAa,CAAC,KAAyB;QAC3C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC/C,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,wBAAwB,EAAE,CAAA;QACtF,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;QAC1B,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,qBAAqB,CAAC,EAAE;gBAC7D,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACzD,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChE,CAAC,CAAA;YACF,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC,CAAoB,CAAA;YACjF,IAAI,CAAC,EAAE,EAAE,CAAC;gBACR,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,0CAA0C,EAAE,CAAA;YAClG,CAAC;YACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,CAAA;QACzD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,0CAA0C,EAAE,CAAA;QAClG,CAAC;IACH,CAAC;IAED;;;;;;;OAOG;IACH,KAAK,CAAC,cAAc,CAAC,KAA0B;QAC7C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC/C,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,wBAAwB,EAAE,CAAA;QACtF,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;QAC1B,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,sBAAsB,CAAC,EAAE;gBAC1D,eAAe,EAAE,KAAK,CAAC,eAAe;gBACtC,WAAW,EAAE,KAAK,CAAC,WAAW;aAC/B,CAAC,CAAA;YACF,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;QACrB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;YACtE,IAAI,yBAAyB,CAAC,OAAO,CAAC,EAAE,CAAC;gBACvC,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,aAAa;oBACrB,OAAO,EACL,0GAA0G;iBAC7G,CAAA;YACH,CAAC;YACD,IAAI,4DAA4D,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC/E,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,qBAAqB;oBAC7B,OAAO,EAAE,qCAAqC;iBAC/C,CAAA;YACH,CAAC;YACD,IAAI,4DAA4D,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC/E,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,eAAe;oBACvB,OAAO,EAAE,qDAAqD;iBAC/D,CAAA;YACH,CAAC;YACD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,2CAA2C,EAAE,CAAA;QACnG,CAAC;IACH,CAAC;IAEO,MAAM,CAAC,EAAY;QACzB,OAAO;YACL,EAAE,EAAE,EAAE,CAAC,EAAE;YACT,KAAK,EAAE,EAAE,CAAC,KAAK;YACf,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACrC,0EAA0E;YAC1E,uEAAuE;YACvE,yBAAyB;YACzB,WAAW,EAAE,EAAE;SAChB,CAAA;IACH,CAAC;IAEO,UAAU;QAChB,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QACpC,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,CAAA;IACzB,CAAC;CACF","sourcesContent":["import type { ConvexHttpClient } from 'convex/browser'\nimport { actionRef, mutationRef, queryRef } from '../persistence/convex-client'\nimport type {\n AuthAdapter,\n AuthSession,\n ChangePasswordInput,\n ChangePasswordResult,\n SignInCredentials,\n SignInResult,\n UpdateProfileInput,\n UpdateProfileResult,\n} from './auth-adapter'\nimport type { SessionStore } from './local-auth-adapter'\n\n/**\n * Convex Auth implementation of the project-owned {@link AuthAdapter}\n * (EC-150). Decision record (also in `convex/auth.ts`): **Convex Auth** over\n * Clerk — self-contained on the Convex stack, sessions in our own tables, no\n * extra vendor; reachable only through this adapter so a swap stays local.\n *\n * Flow (server side lives in `convex/auth.ts` + `convex/users.ts`):\n * 1. `signIn` calls the `auth:signIn` action with the Password provider\n * (`flow: 'signIn'`); on success Convex returns a JWT + refresh token.\n * 2. The JWT is set on the *shared* Convex client (`client.setAuth`), so every\n * subsequent persistence query/mutation runs authenticated and the\n * server-side `requireRole` guards see the caller.\n * 3. `currentSession` restores the JWT from storage and asks `users:me`.\n * 4. `signOut` calls `auth:signOut` and clears the JWT.\n *\n * v1 scope note: the stored JWT is short-lived and this adapter does not yet\n * run the refresh-token loop (the official `@convex-dev/auth/react` client\n * does); an expired session simply resolves to \"signed out\" and the user signs\n * in again. Live-deployment hardening is EC-156. No live deployment exists in\n * this repo — this file is typecheck-verified and exercised against the local\n * adapter's shared interface.\n */\n\nconst TOKEN_STORAGE_PREFIX = 'elytra.convex-auth.jwt'\n\n/**\n * Classify a thrown sign-in error as a DEPLOYMENT-setup failure rather than a\n * credential problem (EC-257). Matches the Convex Auth signing-path throws:\n * `requireEnv('JWT_PRIVATE_KEY'|'JWKS'|'CONVEX_SITE_URL')` (→ \"Missing environment\n * variable …\") and `importPKCS8` on a malformed key. Deliberately narrow — a genuine\n * wrong-password error carries none of these tokens — and checked BEFORE the credential\n * regex so it always wins. Note: a PRODUCTION deployment redacts non-`ConvexError`\n * messages to a generic \"Server Error\", which this can't match; there the honest\n * fallback is the neutral \"unavailable\" branch (never \"password too short\").\n */\nexport function isDeploymentMisconfigured(message: string): boolean {\n return /JWT_PRIVATE_KEY|JWKS|CONVEX_SITE_URL|Missing environment variable|PKCS8|private key/i.test(\n message,\n )\n}\n\n/**\n * Per-deployment token key (EC-179, vision AD-8). One project per backend, so a\n * session is scoped to its deployment: signing into Aurora's backend must not\n * carry a JWT to Northwind's. Keying the stored token by the deployment URL\n * makes each backend's session independent — switching to a project on a\n * not-yet-authenticated backend reads no token and the guard asks for sign-in.\n * (A future shared external IdP — WorkOS/Clerk — would issue one identity valid\n * on every deployment, collapsing this back to a single session.)\n */\nexport function tokenStorageKey(deploymentUrl?: string): string {\n return deploymentUrl ? `${TOKEN_STORAGE_PREFIX}::${deploymentUrl}` : TOKEN_STORAGE_PREFIX\n}\n\n/**\n * Best-effort \"is there a session for this deployment?\" — a synchronous token\n * presence check used to label per-backend projects in the switcher/launcher\n * (EC-179). It does not validate the token (an expired one still redirects to\n * sign-in on entry); it just answers \"has the user signed into this backend?\".\n */\nexport function hasStoredConvexSession(\n deploymentUrl: string,\n store: SessionStore = defaultStore(),\n): boolean {\n return store.getItem(tokenStorageKey(deploymentUrl)) !== null\n}\n\ninterface SignInActionResult {\n tokens?: { token: string; refreshToken: string } | null\n}\n\ninterface MeResult {\n id: string\n email: string\n name?: string\n}\n\nfunction defaultStore(): SessionStore {\n if (typeof window !== 'undefined' && window.sessionStorage) return window.sessionStorage\n const memory = new Map<string, string>()\n return {\n getItem: (key) => memory.get(key) ?? null,\n setItem: (key, value) => void memory.set(key, value),\n removeItem: (key) => void memory.delete(key),\n }\n}\n\nexport class ConvexAuthAdapter implements AuthAdapter {\n private readonly tokenKey: string\n\n constructor(\n private readonly client: ConvexHttpClient,\n private readonly store: SessionStore = defaultStore(),\n /** Deployment URL the token is scoped to (per-backend sessions, EC-179). */\n deploymentUrl?: string,\n ) {\n this.tokenKey = tokenStorageKey(deploymentUrl)\n }\n\n async currentSession(): Promise<AuthSession | null> {\n const token = this.store.getItem(this.tokenKey)\n if (!token) return null\n this.client.setAuth(token)\n try {\n const me = (await this.client.query(queryRef('users:me'), {})) as MeResult | null\n if (!me) {\n this.clearToken()\n return null\n }\n return { user: this.toUser(me) }\n } catch {\n // Expired/invalid JWT or unreachable backend → treat as signed out.\n this.clearToken()\n return null\n }\n }\n\n async signIn(credentials: SignInCredentials): Promise<SignInResult> {\n return this.authenticate('signIn', credentials)\n }\n\n async signUp(credentials: SignInCredentials): Promise<SignInResult> {\n return this.authenticate('signUp', credentials)\n }\n\n /**\n * Shared password flow (EC-150/EC-179). `signIn` and `signUp` differ only by\n * the Convex Auth `flow` param; both return a token + load `users:me`.\n */\n private async authenticate(\n flow: 'signIn' | 'signUp',\n credentials: SignInCredentials,\n ): Promise<SignInResult> {\n let result: SignInActionResult\n try {\n result = (await this.client.action(actionRef('auth:signIn'), {\n provider: 'password',\n params: {\n email: credentials.email.trim().toLowerCase(),\n password: credentials.password,\n flow,\n },\n })) as SignInActionResult\n } catch (error) {\n // Convex Auth surfaces credential/registration problems as thrown errors.\n const message = error instanceof Error ? error.message : String(error)\n // EC-257: a DEPLOYMENT-misconfiguration — not a credential problem — must be\n // classified FIRST so it never falls through to the lying \"password too short\".\n // Convex Auth signs the session JWT with the deployment's `JWT_PRIVATE_KEY`\n // (+ `JWKS`/`CONVEX_SITE_URL`); when any is unset (`npx @convex-dev/auth`\n // never run) the `requireEnv`/`importPKCS8` call throws *after* the password\n // is validated, so EVERY sign-in fails regardless of the password. This is\n // the exact footgun that cost hours on the Konditorei proof (2026-06-22).\n if (isDeploymentMisconfigured(message)) {\n return {\n ok: false,\n reason: 'deployment-misconfigured',\n message:\n 'This deployment can’t sign you in: its auth signing key is not set. Run `npx @convex-dev/auth` against this deployment, then retry. (The deployment-setup check reports the same.)',\n }\n }\n // EC-324: an uninvited sign-up is rejected server-side (`beforeSessionCreation`\n // throws a `ConvexError('not-invited: …')`, whose data survives to the client\n // even in production). Classified BEFORE the credential regex so it never\n // degrades to \"password too short\".\n if (/not-invited|invite-only|invite only/i.test(message)) {\n return {\n ok: false,\n reason: 'not-invited',\n message:\n 'This deployment is invite-only. Ask an admin to invite your email, then create your account.',\n }\n }\n if (flow === 'signUp' && /already|exists|InvalidAccountId/i.test(message)) {\n return {\n ok: false,\n reason: 'account-exists',\n message: 'An account with this email already exists — sign in instead.',\n }\n }\n if (/InvalidSecret|InvalidAccountId|credential|password/i.test(message)) {\n return {\n ok: false,\n reason: 'invalid-credentials',\n message:\n flow === 'signUp'\n ? 'That email or password can’t be used (password too short?).'\n : 'Email or password is incorrect.',\n }\n }\n const verb = flow === 'signUp' ? 'Sign-up' : 'Sign-in'\n return { ok: false, reason: 'unavailable', message: `${verb} is currently unavailable.` }\n }\n if (!result.tokens) {\n return {\n ok: false,\n reason: 'invalid-credentials',\n message: 'Email or password is incorrect.',\n }\n }\n this.store.setItem(this.tokenKey, result.tokens.token)\n this.client.setAuth(result.tokens.token)\n const me = (await this.client.query(queryRef('users:me'), {})) as MeResult | null\n if (!me) {\n this.clearToken()\n return { ok: false, reason: 'unavailable', message: 'Sign-in is currently unavailable.' }\n }\n return { ok: true, session: { user: this.toUser(me) } }\n }\n\n async signOut(): Promise<void> {\n try {\n await this.client.action(actionRef('auth:signOut'), {})\n } catch {\n // Already signed out / unreachable — clearing local state is what matters.\n }\n this.clearToken()\n }\n\n /**\n * Self-service identity edit (EC-240 H-tail item 2). Patches the caller's OWN\n * auth `users` row (name / avatar) via the self-scoped `users:updateProfile`\n * mutation, then re-reads `users:me` so the returned session carries the new\n * name. Auth-owned fields never flow through the members-rail `users.save`.\n */\n async updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult> {\n const token = this.store.getItem(this.tokenKey)\n if (!token) {\n return { ok: false, reason: 'not-authenticated', message: 'You are not signed in.' }\n }\n this.client.setAuth(token)\n try {\n await this.client.mutation(mutationRef('users:updateProfile'), {\n ...(input.name !== undefined ? { name: input.name } : {}),\n ...(input.avatar !== undefined ? { avatar: input.avatar } : {}),\n })\n const me = (await this.client.query(queryRef('users:me'), {})) as MeResult | null\n if (!me) {\n return { ok: false, reason: 'unavailable', message: 'Profile update is currently unavailable.' }\n }\n return { ok: true, session: { user: this.toUser(me) } }\n } catch {\n return { ok: false, reason: 'unavailable', message: 'Profile update is currently unavailable.' }\n }\n }\n\n /**\n * Self-service password change (EC-240 H-tail item 3). Delegates entirely to\n * the provider via the self-scoped `users:changePassword` ACTION (which calls\n * Convex Auth's `retrieveAccount` to verify the current password, then\n * `modifyAccountCredentials` to set the new one). **Seam only:** the raw\n * password values are forwarded to the provider and NEVER logged or inspected\n * here.\n */\n async changePassword(input: ChangePasswordInput): Promise<ChangePasswordResult> {\n const token = this.store.getItem(this.tokenKey)\n if (!token) {\n return { ok: false, reason: 'not-authenticated', message: 'You are not signed in.' }\n }\n this.client.setAuth(token)\n try {\n await this.client.action(actionRef('users:changePassword'), {\n currentPassword: input.currentPassword,\n newPassword: input.newPassword,\n })\n return { ok: true }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n if (isDeploymentMisconfigured(message)) {\n return {\n ok: false,\n reason: 'unavailable',\n message:\n 'This deployment can’t change your password: its auth signing key is not set. Run `npx @convex-dev/auth`.',\n }\n }\n if (/InvalidSecret|InvalidAccountId|current password|incorrect/i.test(message)) {\n return {\n ok: false,\n reason: 'invalid-credentials',\n message: 'Your current password is incorrect.',\n }\n }\n if (/at least|too short|8 characters|password requirement|weak/i.test(message)) {\n return {\n ok: false,\n reason: 'weak-password',\n message: 'Choose a stronger password (at least 8 characters).',\n }\n }\n return { ok: false, reason: 'unavailable', message: 'Password change is currently unavailable.' }\n }\n }\n\n private toUser(me: MeResult): AuthSession['user'] {\n return {\n id: me.id,\n email: me.email,\n ...(me.name ? { name: me.name } : {}),\n // No global flags: project creation is config/CLI-owned (AD-11), so there\n // is no in-studio `create-projects` capability. Per-project roles come\n // from `projectMembers`.\n globalFlags: [],\n }\n }\n\n private clearToken(): void {\n this.store.removeItem(this.tokenKey)\n this.client.clearAuth()\n }\n}\n"]}
1
+ {"version":3,"file":"convex-auth-adapter.js","sourceRoot":"","sources":["../../../src/lib/auth/convex-auth-adapter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAA;AAa/E;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,oBAAoB,GAAG,wBAAwB,CAAA;AAErD;;;;;;;;;GASG;AACH,MAAM,UAAU,yBAAyB,CAAC,OAAe;IACvD,OAAO,sFAAsF,CAAC,IAAI,CAChG,OAAO,CACR,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,yBAAyB,CAAC,KAAc;IACtD,MAAM,KAAK,GAAa,EAAE,CAAA;IAC1B,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,MAAM,IAAI,KAAK,EAAE,CAAC;QACnE,MAAM,IAAI,GAAI,KAA2B,CAAC,IAAI,CAAA;QAC9C,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC7B,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAClB,CAAC;aAAM,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC/C,IAAI,CAAC;gBACH,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAA;YAClC,CAAC;YAAC,MAAM,CAAC;gBACP,qEAAqE;YACvE,CAAC;QACH,CAAC;IACH,CAAC;IACD,IAAI,KAAK,YAAY,KAAK;QAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;SAChD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACrD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAA;IACjD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACzB,CAAC;AAID;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAyB,EAAE,IAAY;IACzE,kFAAkF;IAClF,gFAAgF;IAChF,4EAA4E;IAC5E,gFAAgF;IAChF,mFAAmF;IACnF,qDAAqD;IACrD,IAAI,yBAAyB,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,0BAA0B;YAClC,OAAO,EACL,oLAAoL;SACvL,CAAA;IACH,CAAC;IACD,+EAA+E;IAC/E,+EAA+E;IAC/E,mFAAmF;IACnF,IAAI,sCAAsC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACtD,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,aAAa;YACrB,OAAO,EACL,8FAA8F;SACjG,CAAA;IACH,CAAC;IACD,IAAI,IAAI,KAAK,QAAQ,IAAI,kCAAkC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACvE,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,gBAAgB;YACxB,OAAO,EAAE,8DAA8D;SACxE,CAAA;IACH,CAAC;IACD,IAAI,qDAAqD,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACrE,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,qBAAqB;YAC7B,OAAO,EACL,IAAI,KAAK,QAAQ;gBACf,CAAC,CAAC,6DAA6D;gBAC/D,CAAC,CAAC,iCAAiC;SACxC,CAAA;IACH,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;IACtD,OAAO;QACL,EAAE,EAAE,KAAK;QACT,MAAM,EAAE,aAAa;QACrB,OAAO,EAAE,GAAG,IAAI,gEAAgE;KACjF,CAAA;AACH,CAAC;AAID;;;;;;GAMG;AACH,MAAM,UAAU,2BAA2B,CAAC,IAAY;IACtD,IAAI,yBAAyB,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,aAAa;YACrB,OAAO,EACL,0GAA0G;SAC7G,CAAA;IACH,CAAC;IACD,IAAI,4DAA4D,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC5E,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,qBAAqB;YAC7B,OAAO,EAAE,qCAAqC;SAC/C,CAAA;IACH,CAAC;IACD,IAAI,4DAA4D,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC5E,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,eAAe;YACvB,OAAO,EAAE,qDAAqD;SAC/D,CAAA;IACH,CAAC;IACD,OAAO;QACL,EAAE,EAAE,KAAK;QACT,MAAM,EAAE,aAAa;QACrB,OAAO,EAAE,+EAA+E;KACzF,CAAA;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,aAAsB;IACpD,OAAO,aAAa,CAAC,CAAC,CAAC,GAAG,oBAAoB,KAAK,aAAa,EAAE,CAAC,CAAC,CAAC,oBAAoB,CAAA;AAC3F,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CACpC,aAAqB,EACrB,QAAsB,YAAY,EAAE;IAEpC,OAAO,KAAK,CAAC,OAAO,CAAC,eAAe,CAAC,aAAa,CAAC,CAAC,KAAK,IAAI,CAAA;AAC/D,CAAC;AAYD,SAAS,YAAY;IACnB,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,MAAM,CAAC,cAAc;QAAE,OAAO,MAAM,CAAC,cAAc,CAAA;IACxF,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAA;IACxC,OAAO;QACL,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI;QACzC,OAAO,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC;QACpD,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC;KAC7C,CAAA;AACH,CAAC;AAED,MAAM,OAAO,iBAAiB;IAIT;IACA;IAJF,QAAQ,CAAQ;IAEjC,YACmB,MAAwB,EACxB,QAAsB,YAAY,EAAE;IACrD,4EAA4E;IAC5E,aAAsB;QAHL,WAAM,GAAN,MAAM,CAAkB;QACxB,UAAK,GAAL,KAAK,CAA+B;QAIrD,IAAI,CAAC,QAAQ,GAAG,eAAe,CAAC,aAAa,CAAC,CAAA;IAChD,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC/C,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAA;QACvB,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;QAC1B,IAAI,CAAC;YACH,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC,CAAoB,CAAA;YACjF,IAAI,CAAC,EAAE,EAAE,CAAC;gBACR,IAAI,CAAC,UAAU,EAAE,CAAA;gBACjB,OAAO,IAAI,CAAA;YACb,CAAC;YACD,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAA;QAClC,CAAC;QAAC,MAAM,CAAC;YACP,oEAAoE;YACpE,IAAI,CAAC,UAAU,EAAE,CAAA;YACjB,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAA8B;QACzC,OAAO,IAAI,CAAC,YAAY,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAA;IACjD,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAA8B;QACzC,OAAO,IAAI,CAAC,YAAY,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAA;IACjD,CAAC;IAED;;;OAGG;IACK,KAAK,CAAC,YAAY,CACxB,IAAyB,EACzB,WAA8B;QAE9B,IAAI,MAA0B,CAAA;QAC9B,IAAI,CAAC;YACH,MAAM,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,aAAa,CAAC,EAAE;gBAC3D,QAAQ,EAAE,UAAU;gBACpB,MAAM,EAAE;oBACN,KAAK,EAAE,WAAW,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE;oBAC7C,QAAQ,EAAE,WAAW,CAAC,QAAQ;oBAC9B,IAAI;iBACL;aACF,CAAC,CAAuB,CAAA;QAC3B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,0EAA0E;YAC1E,6EAA6E;YAC7E,6EAA6E;YAC7E,yEAAyE;YACzE,OAAO,mBAAmB,CAAC,IAAI,EAAE,yBAAyB,CAAC,KAAK,CAAC,CAAC,CAAA;QACpE,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO;gBACL,EAAE,EAAE,KAAK;gBACT,MAAM,EAAE,qBAAqB;gBAC7B,OAAO,EAAE,iCAAiC;aAC3C,CAAA;QACH,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QACtD,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QACxC,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC,CAAoB,CAAA;QACjF,IAAI,CAAC,EAAE,EAAE,CAAC;YACR,IAAI,CAAC,UAAU,EAAE,CAAA;YACjB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,mCAAmC,EAAE,CAAA;QAC3F,CAAC;QACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,CAAA;IACzD,CAAC;IAED,KAAK,CAAC,OAAO;QACX,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,EAAE,EAAE,CAAC,CAAA;QACzD,CAAC;QAAC,MAAM,CAAC;YACP,2EAA2E;QAC7E,CAAC;QACD,IAAI,CAAC,UAAU,EAAE,CAAA;IACnB,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,aAAa,CAAC,KAAyB;QAC3C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC/C,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,wBAAwB,EAAE,CAAA;QACtF,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;QAC1B,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,qBAAqB,CAAC,EAAE;gBAC7D,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACzD,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChE,CAAC,CAAA;YACF,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC,CAAoB,CAAA;YACjF,IAAI,CAAC,EAAE,EAAE,CAAC;gBACR,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,0CAA0C,EAAE,CAAA;YAClG,CAAC;YACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,CAAA;QACzD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,0CAA0C,EAAE,CAAA;QAClG,CAAC;IACH,CAAC;IAED;;;;;;;OAOG;IACH,KAAK,CAAC,cAAc,CAAC,KAA0B;QAC7C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC/C,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,wBAAwB,EAAE,CAAA;QACtF,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;QAC1B,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,sBAAsB,CAAC,EAAE;gBAC1D,eAAe,EAAE,KAAK,CAAC,eAAe;gBACtC,WAAW,EAAE,KAAK,CAAC,WAAW;aAC/B,CAAC,CAAA;YACF,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;QACrB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,6EAA6E;YAC7E,6EAA6E;YAC7E,mDAAmD;YACnD,OAAO,2BAA2B,CAAC,yBAAyB,CAAC,KAAK,CAAC,CAAC,CAAA;QACtE,CAAC;IACH,CAAC;IAEO,MAAM,CAAC,EAAY;QACzB,OAAO;YACL,EAAE,EAAE,EAAE,CAAC,EAAE;YACT,KAAK,EAAE,EAAE,CAAC,KAAK;YACf,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACrC,0EAA0E;YAC1E,uEAAuE;YACvE,yBAAyB;YACzB,WAAW,EAAE,EAAE;SAChB,CAAA;IACH,CAAC;IAEO,UAAU;QAChB,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QACpC,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,CAAA;IACzB,CAAC;CACF","sourcesContent":["import type { ConvexHttpClient } from 'convex/browser'\nimport { actionRef, mutationRef, queryRef } from '../persistence/convex-client'\nimport type {\n AuthAdapter,\n AuthSession,\n ChangePasswordInput,\n ChangePasswordResult,\n SignInCredentials,\n SignInResult,\n UpdateProfileInput,\n UpdateProfileResult,\n} from './auth-adapter'\nimport type { SessionStore } from './local-auth-adapter'\n\n/**\n * Convex Auth implementation of the project-owned {@link AuthAdapter}\n * (EC-150). Decision record (also in `convex/auth.ts`): **Convex Auth** over\n * Clerk — self-contained on the Convex stack, sessions in our own tables, no\n * extra vendor; reachable only through this adapter so a swap stays local.\n *\n * Flow (server side lives in `convex/auth.ts` + `convex/users.ts`):\n * 1. `signIn` calls the `auth:signIn` action with the Password provider\n * (`flow: 'signIn'`); on success Convex returns a JWT + refresh token.\n * 2. The JWT is set on the *shared* Convex client (`client.setAuth`), so every\n * subsequent persistence query/mutation runs authenticated and the\n * server-side `requireRole` guards see the caller.\n * 3. `currentSession` restores the JWT from storage and asks `users:me`.\n * 4. `signOut` calls `auth:signOut` and clears the JWT.\n *\n * v1 scope note: the stored JWT is short-lived and this adapter does not yet\n * run the refresh-token loop (the official `@convex-dev/auth/react` client\n * does); an expired session simply resolves to \"signed out\" and the user signs\n * in again. Live-deployment hardening is EC-156. No live deployment exists in\n * this repo — this file is typecheck-verified and exercised against the local\n * adapter's shared interface.\n */\n\nconst TOKEN_STORAGE_PREFIX = 'elytra.convex-auth.jwt'\n\n/**\n * Classify a thrown sign-in error as a DEPLOYMENT-setup failure rather than a\n * credential problem (EC-257). Matches the Convex Auth signing-path throws:\n * `requireEnv('JWT_PRIVATE_KEY'|'JWKS'|'CONVEX_SITE_URL')` (→ \"Missing environment\n * variable …\") and `importPKCS8` on a malformed key. Deliberately narrow — a genuine\n * wrong-password error carries none of these tokens — and checked BEFORE the credential\n * regex so it always wins. Note: a PRODUCTION deployment redacts non-`ConvexError`\n * messages to a generic \"Server Error\", which this can't match; there the honest\n * fallback is the neutral \"unavailable\" branch (never \"password too short\").\n */\nexport function isDeploymentMisconfigured(message: string): boolean {\n return /JWT_PRIVATE_KEY|JWKS|CONVEX_SITE_URL|Missing environment variable|PKCS8|private key/i.test(\n message,\n )\n}\n\n/**\n * Reduce a thrown Convex error to the text used for classification (EC-362).\n *\n * **Read `.data` FIRST, then `.message`** — because a PRODUCTION Convex\n * deployment REDACTS a UDF's thrown-error `.message` to a generic\n * `\"[Request ID: …] Server Error\"`, but a `ConvexError`'s `.data` payload\n * survives to the client. Proven in the installed `convex@1.42.1` browser\n * client: on an `\"error\"` response `ConvexHttpClient.action` throws\n * `forwardErrorData(respJSON.errorData, new ConvexError(respJSON.errorMessage))`,\n * and `forwardErrorData` sets `error.data = jsonToConvex(errorData)`\n * (`dist/esm/browser/http_client.js`, ~L388-396 + L459-461). The server keeps\n * `errorData` for a `ConvexError` in prod while redacting `errorMessage`. So the\n * only classifiable text that survives prod is the `.data` of a `ConvexError` —\n * e.g. our invite gate's `ConvexError(NOT_INVITED_REASON)`. On DEV deployments\n * nothing is redacted, so `.message` also carries the token (the regex ladder\n * still matches); non-Convex errors only have a `.message`.\n *\n * Duck-types the `data` property rather than `instanceof ConvexError`: two\n * copies of the `convex` module can coexist under Vite (transformed vs\n * pre-bundled), so `instanceof` can spuriously fail (EC-357). `.data` may be a\n * string (our gates) or a structured object (future gates) — both are folded in.\n */\nexport function convexErrorClassifierText(error: unknown): string {\n const parts: string[] = []\n if (typeof error === 'object' && error !== null && 'data' in error) {\n const data = (error as { data: unknown }).data\n if (typeof data === 'string') {\n parts.push(data)\n } else if (data !== null && data !== undefined) {\n try {\n parts.push(JSON.stringify(data))\n } catch {\n // Non-serializable data (cycles) — ignore; fall back to the message.\n }\n }\n }\n if (error instanceof Error) parts.push(error.message)\n else if (typeof error === 'string') parts.push(error)\n if (parts.length === 0) parts.push(String(error))\n return parts.join('\\n')\n}\n\ntype SignInFailure = Extract<SignInResult, { ok: false }>\n\n/**\n * Pure sign-in/sign-up error classifier (EC-257 / EC-324 / EC-362). Runs the\n * regex ladder over {@link convexErrorClassifierText} output (data-first).\n *\n * Order matters: a DEPLOYMENT-misconfiguration (EC-257) and an uninvited\n * sign-up (EC-324) are each checked BEFORE the credential regex so neither\n * degrades to the lying \"password too short\". The final fallback is HONEST\n * (\"unexpected server error — check the deployment logs\"), never \"unavailable\":\n * on a prod deployment the credential/`InvalidAccountId`/env errors thrown by\n * `@convex-dev/auth` are PLAIN `Error`s (not `ConvexError`s), so prod redacts\n * them to `\"Server Error\"` with no `.data` and they legitimately land here.\n */\nexport function classifySignInError(flow: 'signIn' | 'signUp', text: string): SignInFailure {\n // EC-257: a DEPLOYMENT-misconfiguration — its auth signing key is unset — must be\n // classified FIRST so it never falls through to the lying \"password too short\".\n // Convex Auth signs the session JWT with the deployment's `JWT_PRIVATE_KEY`\n // (+ `JWKS`/`CONVEX_SITE_URL`); when any is unset (`npx @convex-dev/auth` never\n // run) the `requireEnv`/`importPKCS8` call throws AFTER the password is validated,\n // so EVERY sign-in fails regardless of the password.\n if (isDeploymentMisconfigured(text)) {\n return {\n ok: false,\n reason: 'deployment-misconfigured',\n message:\n 'This deployment can’t sign you in: its auth signing key is not set. Run `npx @convex-dev/auth` against this deployment, then retry. (The deployment-setup check reports the same.)',\n }\n }\n // EC-324: an uninvited sign-up is rejected server-side (`gateAccountCreation`/\n // `gateSessionCreation` throw `ConvexError(NOT_INVITED_REASON)`, whose `.data`\n // survives to the client even in production). Checked BEFORE the credential regex.\n if (/not-invited|invite-only|invite only/i.test(text)) {\n return {\n ok: false,\n reason: 'not-invited',\n message:\n 'This deployment is invite-only. Ask an admin to invite your email, then create your account.',\n }\n }\n if (flow === 'signUp' && /already|exists|InvalidAccountId/i.test(text)) {\n return {\n ok: false,\n reason: 'account-exists',\n message: 'An account with this email already exists — sign in instead.',\n }\n }\n if (/InvalidSecret|InvalidAccountId|credential|password/i.test(text)) {\n return {\n ok: false,\n reason: 'invalid-credentials',\n message:\n flow === 'signUp'\n ? 'That email or password can’t be used (password too short?).'\n : 'Email or password is incorrect.',\n }\n }\n const verb = flow === 'signUp' ? 'Sign-up' : 'Sign-in'\n return {\n ok: false,\n reason: 'unavailable',\n message: `${verb} failed (unexpected server error) — check the deployment logs.`,\n }\n}\n\ntype ChangePasswordFailure = Extract<ChangePasswordResult, { ok: false }>\n\n/**\n * Pure password-change error classifier (EC-240 H-tail / EC-362). Same\n * data-first text + honest fallback discipline as {@link classifySignInError};\n * `deployment-misconfigured` collapses onto the `unavailable` reason\n * (`ChangePasswordResult` has no dedicated reason for it) with a specific\n * remediation message.\n */\nexport function classifyChangePasswordError(text: string): ChangePasswordFailure {\n if (isDeploymentMisconfigured(text)) {\n return {\n ok: false,\n reason: 'unavailable',\n message:\n 'This deployment can’t change your password: its auth signing key is not set. Run `npx @convex-dev/auth`.',\n }\n }\n if (/InvalidSecret|InvalidAccountId|current password|incorrect/i.test(text)) {\n return {\n ok: false,\n reason: 'invalid-credentials',\n message: 'Your current password is incorrect.',\n }\n }\n if (/at least|too short|8 characters|password requirement|weak/i.test(text)) {\n return {\n ok: false,\n reason: 'weak-password',\n message: 'Choose a stronger password (at least 8 characters).',\n }\n }\n return {\n ok: false,\n reason: 'unavailable',\n message: 'Password change failed (unexpected server error) — check the deployment logs.',\n }\n}\n\n/**\n * Per-deployment token key (EC-179, vision AD-8). One project per backend, so a\n * session is scoped to its deployment: signing into Aurora's backend must not\n * carry a JWT to Northwind's. Keying the stored token by the deployment URL\n * makes each backend's session independent — switching to a project on a\n * not-yet-authenticated backend reads no token and the guard asks for sign-in.\n * (A future shared external IdP — WorkOS/Clerk — would issue one identity valid\n * on every deployment, collapsing this back to a single session.)\n */\nexport function tokenStorageKey(deploymentUrl?: string): string {\n return deploymentUrl ? `${TOKEN_STORAGE_PREFIX}::${deploymentUrl}` : TOKEN_STORAGE_PREFIX\n}\n\n/**\n * Best-effort \"is there a session for this deployment?\" — a synchronous token\n * presence check used to label per-backend projects in the switcher/launcher\n * (EC-179). It does not validate the token (an expired one still redirects to\n * sign-in on entry); it just answers \"has the user signed into this backend?\".\n */\nexport function hasStoredConvexSession(\n deploymentUrl: string,\n store: SessionStore = defaultStore(),\n): boolean {\n return store.getItem(tokenStorageKey(deploymentUrl)) !== null\n}\n\ninterface SignInActionResult {\n tokens?: { token: string; refreshToken: string } | null\n}\n\ninterface MeResult {\n id: string\n email: string\n name?: string\n}\n\nfunction defaultStore(): SessionStore {\n if (typeof window !== 'undefined' && window.sessionStorage) return window.sessionStorage\n const memory = new Map<string, string>()\n return {\n getItem: (key) => memory.get(key) ?? null,\n setItem: (key, value) => void memory.set(key, value),\n removeItem: (key) => void memory.delete(key),\n }\n}\n\nexport class ConvexAuthAdapter implements AuthAdapter {\n private readonly tokenKey: string\n\n constructor(\n private readonly client: ConvexHttpClient,\n private readonly store: SessionStore = defaultStore(),\n /** Deployment URL the token is scoped to (per-backend sessions, EC-179). */\n deploymentUrl?: string,\n ) {\n this.tokenKey = tokenStorageKey(deploymentUrl)\n }\n\n async currentSession(): Promise<AuthSession | null> {\n const token = this.store.getItem(this.tokenKey)\n if (!token) return null\n this.client.setAuth(token)\n try {\n const me = (await this.client.query(queryRef('users:me'), {})) as MeResult | null\n if (!me) {\n this.clearToken()\n return null\n }\n return { user: this.toUser(me) }\n } catch {\n // Expired/invalid JWT or unreachable backend → treat as signed out.\n this.clearToken()\n return null\n }\n }\n\n async signIn(credentials: SignInCredentials): Promise<SignInResult> {\n return this.authenticate('signIn', credentials)\n }\n\n async signUp(credentials: SignInCredentials): Promise<SignInResult> {\n return this.authenticate('signUp', credentials)\n }\n\n /**\n * Shared password flow (EC-150/EC-179). `signIn` and `signUp` differ only by\n * the Convex Auth `flow` param; both return a token + load `users:me`.\n */\n private async authenticate(\n flow: 'signIn' | 'signUp',\n credentials: SignInCredentials,\n ): Promise<SignInResult> {\n let result: SignInActionResult\n try {\n result = (await this.client.action(actionRef('auth:signIn'), {\n provider: 'password',\n params: {\n email: credentials.email.trim().toLowerCase(),\n password: credentials.password,\n flow,\n },\n })) as SignInActionResult\n } catch (error) {\n // Convex Auth surfaces credential/registration problems as thrown errors.\n // Classify from the DATA-FIRST text (a `ConvexError`'s `.data` survives prod\n // redaction, the redacted `.message` does not — EC-362), then apply the pure\n // regex ladder. See `convexErrorClassifierText` / `classifySignInError`.\n return classifySignInError(flow, convexErrorClassifierText(error))\n }\n if (!result.tokens) {\n return {\n ok: false,\n reason: 'invalid-credentials',\n message: 'Email or password is incorrect.',\n }\n }\n this.store.setItem(this.tokenKey, result.tokens.token)\n this.client.setAuth(result.tokens.token)\n const me = (await this.client.query(queryRef('users:me'), {})) as MeResult | null\n if (!me) {\n this.clearToken()\n return { ok: false, reason: 'unavailable', message: 'Sign-in is currently unavailable.' }\n }\n return { ok: true, session: { user: this.toUser(me) } }\n }\n\n async signOut(): Promise<void> {\n try {\n await this.client.action(actionRef('auth:signOut'), {})\n } catch {\n // Already signed out / unreachable — clearing local state is what matters.\n }\n this.clearToken()\n }\n\n /**\n * Self-service identity edit (EC-240 H-tail item 2). Patches the caller's OWN\n * auth `users` row (name / avatar) via the self-scoped `users:updateProfile`\n * mutation, then re-reads `users:me` so the returned session carries the new\n * name. Auth-owned fields never flow through the members-rail `users.save`.\n */\n async updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult> {\n const token = this.store.getItem(this.tokenKey)\n if (!token) {\n return { ok: false, reason: 'not-authenticated', message: 'You are not signed in.' }\n }\n this.client.setAuth(token)\n try {\n await this.client.mutation(mutationRef('users:updateProfile'), {\n ...(input.name !== undefined ? { name: input.name } : {}),\n ...(input.avatar !== undefined ? { avatar: input.avatar } : {}),\n })\n const me = (await this.client.query(queryRef('users:me'), {})) as MeResult | null\n if (!me) {\n return { ok: false, reason: 'unavailable', message: 'Profile update is currently unavailable.' }\n }\n return { ok: true, session: { user: this.toUser(me) } }\n } catch {\n return { ok: false, reason: 'unavailable', message: 'Profile update is currently unavailable.' }\n }\n }\n\n /**\n * Self-service password change (EC-240 H-tail item 3). Delegates entirely to\n * the provider via the self-scoped `users:changePassword` ACTION (which calls\n * Convex Auth's `retrieveAccount` to verify the current password, then\n * `modifyAccountCredentials` to set the new one). **Seam only:** the raw\n * password values are forwarded to the provider and NEVER logged or inspected\n * here.\n */\n async changePassword(input: ChangePasswordInput): Promise<ChangePasswordResult> {\n const token = this.store.getItem(this.tokenKey)\n if (!token) {\n return { ok: false, reason: 'not-authenticated', message: 'You are not signed in.' }\n }\n this.client.setAuth(token)\n try {\n await this.client.action(actionRef('users:changePassword'), {\n currentPassword: input.currentPassword,\n newPassword: input.newPassword,\n })\n return { ok: true }\n } catch (error) {\n // Data-first classification (EC-362): the change-password ACTION runs on the\n // same deployment, so a prod redaction erases the thrown `.message` the same\n // way — read a surviving `ConvexError.data` first.\n return classifyChangePasswordError(convexErrorClassifierText(error))\n }\n }\n\n private toUser(me: MeResult): AuthSession['user'] {\n return {\n id: me.id,\n email: me.email,\n ...(me.name ? { name: me.name } : {}),\n // No global flags: project creation is config/CLI-owned (AD-11), so there\n // is no in-studio `create-projects` capability. Per-project roles come\n // from `projectMembers`.\n globalFlags: [],\n }\n }\n\n private clearToken(): void {\n this.store.removeItem(this.tokenKey)\n this.client.clearAuth()\n }\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/studio",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.51",
4
+ "version": "0.0.52",
5
5
  "private": false,
6
6
  "description": "The Elytra studio engine (UI + lib + routes + data-only Convex schema) as an importable package. Extraction staged in EXTRACTION.md (EC-223); Stages 1-2 = UI primitives + router-free lib leaves.",
7
7
  "license": "MIT",
@@ -136,7 +136,7 @@
136
136
  "lucide-react": "^1.23.0",
137
137
  "tailwind-merge": "^3.6.0",
138
138
  "zod": "^4.4.3",
139
- "@elytracms/core": "^0.0.51"
139
+ "@elytracms/core": "^0.0.52"
140
140
  },
141
141
  "peerDependencies": {
142
142
  "@tanstack/react-router": "^1.170.0",