{"version":3,"file":"verify.mjs","names":[],"sources":["../../../../../../../src/astro/routes/api/auth/passkey/register/verify.ts"],"sourcesContent":["/**\n * POST /_emdash/api/auth/passkey/register/verify\n *\n * Verify and store a new passkey credential (authenticated user)\n */\n\nimport type { APIRoute } from \"astro\";\n\nexport const prerender = false;\n\nimport { createKyselyAdapter } from \"@premium-cms/auth/adapters/kysely\";\nimport { verifyRegistrationResponse, registerPasskey } from \"@premium-cms/auth/passkey\";\n\nimport { apiError, apiSuccess } from \"#api/error.js\";\nimport { isParseError, parseBody } from \"#api/parse.js\";\nimport { getPublicOrigin } from \"#api/public-url.js\";\nimport { passkeyRegisterVerifyBody } from \"#api/schemas.js\";\nimport { getConfiguredAllowedOrigins, validateAllowedOrigins } from \"#auth/allowed-origins.js\";\nimport { createChallengeStore } from \"#auth/challenge-store.js\";\nimport { getPasskeyConfig } from \"#auth/passkey-config.js\";\nimport { OptionsRepository } from \"#db/repositories/options.js\";\n\nconst MAX_PASSKEYS = 10;\n\ninterface PasskeyResponse {\n\tid: string;\n\tname: string | null;\n\tdeviceType: \"singleDevice\" | \"multiDevice\";\n\tbackedUp: boolean;\n\tcreatedAt: string;\n\tlastUsedAt: string;\n}\n\nexport const POST: APIRoute = async ({ request, locals }) => {\n\tconst { emdash, user } = locals;\n\n\tif (!emdash?.db) {\n\t\treturn apiError(\"NOT_CONFIGURED\", \"EmDash is not initialized\", 500);\n\t}\n\n\t// Require authentication\n\tif (!user) {\n\t\treturn apiError(\"NOT_AUTHENTICATED\", \"Not authenticated\", 401);\n\t}\n\n\ttry {\n\t\tconst adapter = createKyselyAdapter(emdash.db);\n\n\t\t// Check passkey limit again (in case of concurrent requests)\n\t\tconst count = await adapter.countCredentialsByUserId(user.id);\n\t\tif (count >= MAX_PASSKEYS) {\n\t\t\treturn apiError(\"PASSKEY_LIMIT\", `Maximum of ${MAX_PASSKEYS} passkeys allowed`, 400);\n\t\t}\n\n\t\t// Parse request body\n\t\tconst body = await parseBody(request, passkeyRegisterVerifyBody);\n\t\tif (isParseError(body)) return body;\n\n\t\t// Get passkey config\n\t\tconst url = new URL(request.url);\n\t\tconst optionsRepo = new OptionsRepository(emdash.db);\n\t\tconst siteName = (await optionsRepo.get<string>(\"emdash:site_title\")) ?? undefined;\n\t\tconst siteUrl = getPublicOrigin(url, emdash?.config);\n\t\tconst allowedOrigins = validateAllowedOrigins(\n\t\t\tsiteUrl,\n\t\t\tgetConfiguredAllowedOrigins(emdash?.config),\n\t\t);\n\t\tconst passkeyConfig = getPasskeyConfig(url, siteName, siteUrl, allowedOrigins);\n\n\t\t// Verify the registration response\n\t\tconst challengeStore = createChallengeStore(emdash.db);\n\t\tconst verified = await verifyRegistrationResponse(\n\t\t\tpasskeyConfig,\n\t\t\tbody.credential,\n\t\t\tchallengeStore,\n\t\t);\n\n\t\t// Get passkey name - prefer body.name, then check stored pending name\n\t\tlet passKeyName: string | undefined = body.name ?? undefined;\n\t\tif (!passKeyName) {\n\t\t\tconst pending = await optionsRepo.get<{ name?: string }>(`emdash:passkey_pending:${user.id}`);\n\t\t\tif (pending?.name) {\n\t\t\t\tpassKeyName = pending.name;\n\t\t\t}\n\t\t}\n\n\t\t// Clean up pending state\n\t\tawait optionsRepo.delete(`emdash:passkey_pending:${user.id}`);\n\n\t\t// Register the passkey\n\t\tconst credential = await registerPasskey(adapter, user.id, verified, passKeyName);\n\n\t\t// Return the new passkey info\n\t\tconst passkey: PasskeyResponse = {\n\t\t\tid: credential.id,\n\t\t\tname: credential.name,\n\t\t\tdeviceType: credential.deviceType,\n\t\t\tbackedUp: credential.backedUp,\n\t\t\tcreatedAt: credential.createdAt.toISOString(),\n\t\t\tlastUsedAt: credential.lastUsedAt.toISOString(),\n\t\t};\n\n\t\treturn apiSuccess({ passkey });\n\t} catch (error) {\n\t\tconsole.error(\"Passkey registration verify error:\", error);\n\n\t\t// Handle specific errors\n\t\tconst message = error instanceof Error ? error.message : \"\";\n\n\t\t// Check for duplicate credential error\n\t\tif (message.includes(\"credential_exists\") || message.includes(\"already\")) {\n\t\t\treturn apiError(\"CREDENTIAL_EXISTS\", \"This passkey is already registered\", 400);\n\t\t}\n\n\t\t// Check for challenge errors\n\t\tif (message.includes(\"challenge\") || message.includes(\"expired\")) {\n\t\t\treturn apiError(\"CHALLENGE_EXPIRED\", \"Registration expired. Please try again.\", 400);\n\t\t}\n\n\t\treturn apiError(\"PASSKEY_REGISTER_ERROR\", \"Registration failed\", 500);\n\t}\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAQA,MAAa,YAAY;AAczB,MAAM,eAAe;AAWrB,MAAa,OAAiB,OAAO,EAAE,SAAS,aAAa;CAC5D,MAAM,EAAE,QAAQ,SAAS;AAEzB,KAAI,CAAC,QAAQ,GACZ,QAAO,SAAS,kBAAkB,6BAA6B,IAAI;AAIpE,KAAI,CAAC,KACJ,QAAO,SAAS,qBAAqB,qBAAqB,IAAI;AAG/D,KAAI;EACH,MAAM,UAAU,oBAAoB,OAAO,GAAG;AAI9C,MADc,MAAM,QAAQ,yBAAyB,KAAK,GAAG,IAChD,aACZ,QAAO,SAAS,iBAAiB,cAAc,aAAa,oBAAoB,IAAI;EAIrF,MAAM,OAAO,MAAM,UAAU,SAAS,0BAA0B;AAChE,MAAI,aAAa,KAAK,CAAE,QAAO;EAG/B,MAAM,MAAM,IAAI,IAAI,QAAQ,IAAI;EAChC,MAAM,cAAc,IAAI,kBAAkB,OAAO,GAAG;EACpD,MAAM,WAAY,MAAM,YAAY,IAAY,oBAAoB,IAAK;EACzE,MAAM,UAAU,gBAAgB,KAAK,QAAQ,OAAO;EAKpD,MAAM,gBAAgB,iBAAiB,KAAK,UAAU,SAJ/B,uBACtB,SACA,4BAA4B,QAAQ,OAAO,CAC3C,CAC6E;EAG9E,MAAM,iBAAiB,qBAAqB,OAAO,GAAG;EACtD,MAAM,WAAW,MAAM,2BACtB,eACA,KAAK,YACL,eACA;EAGD,IAAI,cAAkC,KAAK,QAAQ;AACnD,MAAI,CAAC,aAAa;GACjB,MAAM,UAAU,MAAM,YAAY,IAAuB,0BAA0B,KAAK,KAAK;AAC7F,OAAI,SAAS,KACZ,eAAc,QAAQ;;AAKxB,QAAM,YAAY,OAAO,0BAA0B,KAAK,KAAK;EAG7D,MAAM,aAAa,MAAM,gBAAgB,SAAS,KAAK,IAAI,UAAU,YAAY;AAYjF,SAAO,WAAW,EAAE,SATa;GAChC,IAAI,WAAW;GACf,MAAM,WAAW;GACjB,YAAY,WAAW;GACvB,UAAU,WAAW;GACrB,WAAW,WAAW,UAAU,aAAa;GAC7C,YAAY,WAAW,WAAW,aAAa;GAC/C,EAE4B,CAAC;UACtB,OAAO;AACf,UAAQ,MAAM,sCAAsC,MAAM;EAG1D,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AAGzD,MAAI,QAAQ,SAAS,oBAAoB,IAAI,QAAQ,SAAS,UAAU,CACvE,QAAO,SAAS,qBAAqB,sCAAsC,IAAI;AAIhF,MAAI,QAAQ,SAAS,YAAY,IAAI,QAAQ,SAAS,UAAU,CAC/D,QAAO,SAAS,qBAAqB,2CAA2C,IAAI;AAGrF,SAAO,SAAS,0BAA0B,uBAAuB,IAAI"}