diff --git a/sdk/dotnet/web-api/Corti.Sdk.Examples.Api.csproj b/sdk/dotnet/web-api/Corti.Sdk.Examples.Api.csproj index d900257..d13fa83 100644 --- a/sdk/dotnet/web-api/Corti.Sdk.Examples.Api.csproj +++ b/sdk/dotnet/web-api/Corti.Sdk.Examples.Api.csproj @@ -14,7 +14,7 @@ - + diff --git a/sdk/dotnet/web-api/Endpoints/AmbientAsyncEndToEndEndpoint.cs b/sdk/dotnet/web-api/Endpoints/AmbientAsyncEndToEndEndpoint.cs new file mode 100644 index 0000000..59f0992 --- /dev/null +++ b/sdk/dotnet/web-api/Endpoints/AmbientAsyncEndToEndEndpoint.cs @@ -0,0 +1,102 @@ +using Corti; +using CortiApiExamples; + +namespace CortiApiExamples.Endpoints; + +public static class AmbientAsyncEndToEndEndpoint +{ + public static void MapAmbientAsyncEndToEndEndpoint(this WebApplication app) + { + app.MapGet("/ambient-async-end-to-end", Handle); + } + + private static async Task Handle( + IConfiguration config, + IWebHostEnvironment env) + { + if (!CortiHelpers.TryCreateCortiClient(config, out var client, out var credentialError)) + { + return credentialError; + } + + try + { + var samplePath = CortiHelpers.ResolveSampleFilePath(env.ContentRootPath, "trouble-breathing.mp3"); + if (samplePath is null) + { + return Results.BadRequest(new + { + error = + "Sample file not found. Copy trouble-breathing.mp3 to sample/ or use typescript/next/public/trouble-breathing.mp3.", + }); + } + + var interactionId = Guid.NewGuid().ToString(); + var interaction = await client!.Interactions.CreateAsync( + new InteractionsCreateRequest + { + Encounter = new InteractionsEncounterCreateRequest + { + Identifier = interactionId, + Status = InteractionsEncounterStatusEnum.Planned, + Type = InteractionsEncounterTypeEnum.FirstConsultation, + }, + } + ); + + await using var audioStream = File.OpenRead(samplePath); + var recording = await client.Recordings.UploadAsync(interaction.InteractionId, audioStream); + + var transcript = await client.Transcripts.CreateAsync( + interaction.InteractionId, + new TranscriptsCreateRequest + { + RecordingId = recording.RecordingId, + PrimaryLanguage = "en", + } + ); + + var document = await client.Documents.CreateAsync( + interaction.InteractionId, + DocumentsCreateRequest.FromDocumentsCreateRequestWithTemplateKey( + new DocumentsCreateRequestWithTemplateKey + { + Context = (transcript.Transcripts ?? Enumerable.Empty()) + .Select(t => DocumentsContext.FromDocumentsContextWithTranscript( + new DocumentsContextWithTranscript + { + Type = DocumentsContextWithTranscriptType.Transcript, + Data = new CommonTranscriptRequest + { + Channel = t.Channel, + Participant = t.Participant, + SpeakerId = t.SpeakerId, + Text = t.Text, + Start = t.Start, + End = t.End, + }, + } + )) + .ToArray(), + TemplateKey = "soap", + OutputLanguage = "en", + } + ) + ); + + return Results.Ok(new + { + interactionId = interaction.InteractionId, + recordingId = recording.RecordingId, + transcript, + document, + documentName = document.Name, + }); + } + catch (CortiClientApiException ex) + { + return CortiHelpers.CortiApiErrorResult(ex); + } + } +} + diff --git a/sdk/dotnet/web-api/Endpoints/AmbientAsyncFactsEndpoint.cs b/sdk/dotnet/web-api/Endpoints/AmbientAsyncFactsEndpoint.cs new file mode 100644 index 0000000..68655a0 --- /dev/null +++ b/sdk/dotnet/web-api/Endpoints/AmbientAsyncFactsEndpoint.cs @@ -0,0 +1,90 @@ +using Corti; +using CortiApiExamples; + +namespace CortiApiExamples.Endpoints; + +public static class AmbientAsyncFactsEndpoint +{ + public static void MapAmbientAsyncFactsEndpoint(this WebApplication app) + { + app.MapGet("/ambient-async-facts", Handle); + } + + private static async Task Handle( + IConfiguration config, + IWebHostEnvironment env) + { + if (!CortiHelpers.TryCreateCortiClient(config, out var client, out var credentialError)) + { + return credentialError; + } + + try + { + var samplePath = CortiHelpers.ResolveSampleFilePath(env.ContentRootPath, "trouble-breathing.mp3"); + if (samplePath is null) + { + return Results.BadRequest(new + { + error = + "Sample file not found. Copy trouble-breathing.mp3 to sample/ or use typescript/next/public/trouble-breathing.mp3.", + }); + } + + var interaction = await client!.Interactions.CreateAsync( + new InteractionsCreateRequest + { + Encounter = new InteractionsEncounterCreateRequest + { + Identifier = Guid.NewGuid().ToString(), + Status = InteractionsEncounterStatusEnum.Planned, + Type = InteractionsEncounterTypeEnum.FirstConsultation, + }, + } + ); + + await using var audioStream = File.OpenRead(samplePath); + var recording = await client.Recordings.UploadAsync(interaction.InteractionId, audioStream); + + var transcript = await client.Transcripts.CreateAsync( + interaction.InteractionId, + new TranscriptsCreateRequest + { + RecordingId = recording.RecordingId, + PrimaryLanguage = "en", + Diarize = true, + IsMultichannel = false, + } + ); + + var context = new[] + { + new CommonTextContext + { + Type = CommonTextContextType.Text, + Text = string.Join(" ", (transcript.Transcripts ?? Enumerable.Empty()).Select(t => t.Text)), + }, + }; + + var factsResponse = await client.Facts.ExtractAsync(new FactsExtractRequest + { + Context = context, + OutputLanguage = "en", + }); + + return Results.Ok(new + { + interactionId = interaction.InteractionId, + recordingId = recording.RecordingId, + transcript, + facts = factsResponse.Facts, + factCount = factsResponse.Facts.Count(), + message = "Ambient async facts (SDK): upload recording, transcribe, extract facts.", + }); + } + catch (CortiClientApiException ex) + { + return CortiHelpers.CortiApiErrorResult(ex); + } + } +} diff --git a/sdk/dotnet/web-api/Endpoints/AmbientRtStreamsEndpoint.cs b/sdk/dotnet/web-api/Endpoints/AmbientRtStreamsEndpoint.cs new file mode 100644 index 0000000..9edf9e1 --- /dev/null +++ b/sdk/dotnet/web-api/Endpoints/AmbientRtStreamsEndpoint.cs @@ -0,0 +1,142 @@ +using Corti; +using CortiApiExamples; + +namespace CortiApiExamples.Endpoints; + +public static class AmbientRtStreamsEndpoint +{ + private const int ChunkSize = 32_000; + + public static void MapAmbientRtStreamsEndpoint(this WebApplication app) + { + app.MapGet("/ambient-rt-streams", Handle); + } + + private static async Task Handle( + IConfiguration config, + IWebHostEnvironment env) + { + if (!CortiHelpers.TryGetCortiConfig(config, out var cc, out var credentialError)) + { + return credentialError; + } + + var client = new CortiClient( + cc!.TenantName, + cc.Environment, + new CortiClientAuth.ClientCredentials(cc.ClientId, cc.ClientSecret) + ); + + var samplePath = CortiHelpers.ResolveSampleFilePath(env.ContentRootPath, "trouble-breathing.mp3"); + if (samplePath is null) + { + return Results.BadRequest(new + { + error = "Sample file not found. Copy typescript/next/public/trouble-breathing.mp3 to csharp/api/sample/.", + }); + } + + try + { + var now = DateTime.UtcNow; + var interaction = await client.Interactions.CreateAsync( + new InteractionsCreateRequest + { + AssignedUserId = Guid.NewGuid().ToString(), + Encounter = new InteractionsEncounterCreateRequest + { + Identifier = Guid.NewGuid().ToString(), + Status = InteractionsEncounterStatusEnum.Planned, + Type = InteractionsEncounterTypeEnum.FirstConsultation, + Period = new InteractionsEncounterPeriod + { + StartedAt = now, + EndedAt = now, + }, + Title = "Consultation", + }, + } + ); + + await using var stream = await client.CreateStreamApiAsync(interaction.InteractionId); + + var messages = new List(); + var endedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void AddMessage(object msg) + { + lock (messages) + { + messages.Add(msg); + } + } + + stream.StreamConfigStatusMessage.Subscribe(AddMessage); + stream.StreamTranscriptMessage.Subscribe(AddMessage); + stream.StreamFactsMessage.Subscribe(AddMessage); + stream.StreamUsageMessage.Subscribe(AddMessage); + stream.StreamErrorMessage.Subscribe(AddMessage); + stream.StreamEndedMessage.Subscribe(msg => + { + AddMessage(msg); + endedTcs.TrySetResult(); + }); + + await stream.ConnectAsync(new StreamConfig + { + Transcription = new StreamConfigTranscription + { + PrimaryLanguage = "en", + IsDiarization = false, + IsMultichannel = false, + Participants = new List + { + new() { Channel = 0, Role = StreamConfigParticipantRole.Multiple }, + }, + }, + Mode = new StreamConfigMode + { + Type = StreamConfigModeType.Facts, + OutputLocale = "en", + }, + }); + + await using (var audioStream = File.OpenRead(samplePath)) + { + var buffer = new byte[ChunkSize]; + int read; + while ((read = await audioStream.ReadAsync(buffer, CancellationToken.None)) > 0) + { + if (read == buffer.Length) + { + await stream.Send(buffer); + } + else + { + var chunk = new byte[read]; + Buffer.BlockCopy(buffer, 0, chunk, 0, read); + await stream.Send(chunk); + } + } + } + + await stream.Send(new StreamEndMessage()); + await endedTcs.Task; + + await stream.CloseAsync(); + + return Results.Ok(new + { + interactionId = interaction.InteractionId, + messageCount = messages.Count, + messages, + message = "Ambient RT streams (SDK): connect with facts config, stream audio, end, await ENDED.", + }); + } + catch (CortiClientApiException ex) + { + return CortiHelpers.CortiApiErrorResult(ex); + } + } +} + diff --git a/sdk/dotnet/web-api/Endpoints/CodesEndpoint.cs b/sdk/dotnet/web-api/Endpoints/CodesEndpoint.cs index 59c4f03..d6fe2f2 100644 --- a/sdk/dotnet/web-api/Endpoints/CodesEndpoint.cs +++ b/sdk/dotnet/web-api/Endpoints/CodesEndpoint.cs @@ -21,7 +21,7 @@ private static async Task Handle(IConfiguration config) { var predictResponse = await client!.Codes.PredictAsync(new CodesGeneralPredictRequest { - System = [CommonCodingSystemEnum.Icd10Cm, CommonCodingSystemEnum.Cpt], + System = [CommonCodingSystemEnum.Icd10CmOutpatient, CommonCodingSystemEnum.Cpt], Context = [ new CommonTextContext @@ -30,7 +30,6 @@ private static async Task Handle(IConfiguration config) Text = "Short arm splint applied in ED for pain control.", }, ], - MaxCandidates = 5, }); return Results.Ok(new diff --git a/sdk/dotnet/web-api/Endpoints/StreamEndpoint.cs b/sdk/dotnet/web-api/Endpoints/StreamEndpoint.cs index cbb881a..7f0215d 100644 --- a/sdk/dotnet/web-api/Endpoints/StreamEndpoint.cs +++ b/sdk/dotnet/web-api/Endpoints/StreamEndpoint.cs @@ -17,16 +17,18 @@ private static async Task Handle( IWebHostEnvironment env, string? interactionId) { - if (!CortiHelpers.TryCreateCortiClient(config, out var client, out var credentialError)) + if (!CortiHelpers.TryGetCortiConfig(config, out var cc, out var credentialError)) { return credentialError; } + var client = new CortiClient(cc!.TenantName, cc.Environment, new CortiClientAuth.ClientCredentials(cc.ClientId, cc.ClientSecret)); + var interactionIdToUse = interactionId?.Trim(); if (string.IsNullOrEmpty(interactionIdToUse)) { var id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); - var created = await client!.Interactions.CreateAsync(new InteractionsCreateRequest + var created = await client.Interactions.CreateAsync(new InteractionsCreateRequest { Encounter = new InteractionsEncounterCreateRequest { @@ -54,10 +56,9 @@ private static async Task Handle( try { - var streamApi = await client!.CreateStreamApiAsync(interactionIdToUse!); + var streamApi = await client.CreateStreamApiAsync(interactionIdToUse!); var messages = new List(); - var configAcceptedTcs = new TaskCompletionSource(); var flushedTcs = new TaskCompletionSource(); void AddMessage(object msg) @@ -68,6 +69,8 @@ void AddMessage(object msg) } } + var configAcceptedTcs = new TaskCompletionSource(); + streamApi.StreamConfigStatusMessage.Subscribe((StreamConfigStatusMessage msg) => { AddMessage(msg); @@ -99,7 +102,6 @@ void AddMessage(object msg) await streamApi.Send(new StreamConfigMessage { - Type = StreamConfigMessageType.Config, Configuration = new StreamConfig { Transcription = new StreamConfigTranscription @@ -126,7 +128,7 @@ await streamApi.Send(new StreamConfigMessage } } - await streamApi.Send(new StreamFlushMessage { Type = StreamFlushMessageType.Flush }); + await streamApi.Send(new StreamFlushMessage()); await flushedTcs.Task; await streamApi.CloseAsync(); diff --git a/sdk/dotnet/web-api/Endpoints/StreamWithConfigEndpoint.cs b/sdk/dotnet/web-api/Endpoints/StreamWithConfigEndpoint.cs new file mode 100644 index 0000000..6a3c71e --- /dev/null +++ b/sdk/dotnet/web-api/Endpoints/StreamWithConfigEndpoint.cs @@ -0,0 +1,129 @@ +using Corti; +using CortiApiExamples; + +namespace CortiApiExamples.Endpoints; + +public static class StreamWithConfigEndpoint +{ + private const int ChunkSize = 4_096; + + public static void MapStreamWithConfigEndpoint(this WebApplication app) + { + app.MapGet("/stream-with-config", Handle); + } + + private static async Task Handle( + IConfiguration config, + IWebHostEnvironment env, + string? interactionId) + { + if (!CortiHelpers.TryGetCortiConfig(config, out var cc, out var credentialError)) + { + return credentialError; + } + + var client = new CortiClient(cc!.TenantName, cc.Environment, new CortiClientAuth.ClientCredentials(cc.ClientId, cc.ClientSecret)); + + var interactionIdToUse = interactionId?.Trim(); + if (string.IsNullOrEmpty(interactionIdToUse)) + { + var id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + var created = await client.Interactions.CreateAsync(new InteractionsCreateRequest + { + Encounter = new InteractionsEncounterCreateRequest + { + Identifier = id, + Status = InteractionsEncounterStatusEnum.Planned, + Type = InteractionsEncounterTypeEnum.FirstConsultation, + }, + Patient = new InteractionsPatient + { + Identifier = id, + Gender = InteractionsGenderEnum.Unknown, + }, + }); + interactionIdToUse = created.InteractionId; + } + + var samplePath = CortiHelpers.ResolveSampleFilePath(env.ContentRootPath, "trouble-breathing.mp3"); + if (samplePath is null) + { + return Results.BadRequest(new + { + error = "Sample file not found. Copy typescript/next/public/trouble-breathing.mp3 to csharp/api/sample/.", + }); + } + + try + { + var streamApi = await client.CreateStreamApiAsync(interactionIdToUse!); + + var messages = new List(); + var flushedTcs = new TaskCompletionSource(); + + void AddMessage(object msg) + { + lock (messages) + { + messages.Add(msg); + } + } + + streamApi.StreamConfigStatusMessage.Subscribe(AddMessage); + streamApi.StreamFlushedMessage.Subscribe((StreamFlushedMessage msg) => + { + AddMessage(msg); + flushedTcs.TrySetResult(); + }); + streamApi.StreamTranscriptMessage.Subscribe(AddMessage); + streamApi.StreamFactsMessage.Subscribe(AddMessage); + streamApi.StreamEndedMessage.Subscribe(AddMessage); + streamApi.StreamUsageMessage.Subscribe(AddMessage); + streamApi.StreamErrorMessage.Subscribe(AddMessage); + + // ConnectAsync sends configuration and resolves only after CONFIG_ACCEPTED. + // It throws on CONFIG_DENIED / CONFIG_MISSING / CONFIG_TIMEOUT / CONFIG_NOT_PROVIDED. + await streamApi.ConnectAsync(new StreamConfig + { + Transcription = new StreamConfigTranscription + { + PrimaryLanguage = "en", + Participants = new List(), + }, + Mode = new StreamConfigMode { Type = StreamConfigModeType.Transcription }, + }); + + var chunkCount = 0; + await using (var sampleStream = File.OpenRead(samplePath)) + { + var audioBuffer = new byte[ChunkSize]; + int read; + while ((read = await sampleStream.ReadAsync(audioBuffer, CancellationToken.None)) > 0) + { + var chunk = new byte[read]; + Array.Copy(audioBuffer, chunk, read); + await streamApi.Send(chunk); + chunkCount++; + } + } + + await streamApi.Send(new StreamFlushMessage()); + await flushedTcs.Task; + + await streamApi.CloseAsync(); + await streamApi.DisposeAsync(); + + return Results.Ok(new + { + interactionId = interactionIdToUse, + messageCount = messages.Count, + messages, + message = "Stream WebSocket (SDK, with config): configuration passed to ConnectAsync(), audio sent by chunks, flush sent, flushed received.", + }); + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 500); + } + } +} diff --git a/sdk/dotnet/web-api/Endpoints/TokenEndpoint.cs b/sdk/dotnet/web-api/Endpoints/TokenEndpoint.cs index 546aada..0a4e0df 100644 --- a/sdk/dotnet/web-api/Endpoints/TokenEndpoint.cs +++ b/sdk/dotnet/web-api/Endpoints/TokenEndpoint.cs @@ -98,9 +98,8 @@ private static async Task HandleBearer(IConfiguration config) var tokenResponse = await auth.GetTokenAsync(new OAuthTokenRequest { ClientId = cortiConfig.ClientId, ClientSecret = cortiConfig.ClientSecret }); var client = new CortiClient( - cortiConfig.TenantName, - cortiConfig.Environment, - new CortiClientAuth.Bearer(tokenResponse.AccessToken ?? string.Empty)); + new CortiClientAuth.Bearer(tokenResponse.AccessToken ?? string.Empty) + ); var factGroups = await client.Facts.FactGroupsListAsync(); return Results.Ok(new { message = "Corti client (Bearer token from CC) called Facts.FactGroupsListAsync successfully." }); diff --git a/sdk/dotnet/web-api/Endpoints/TranscribeEndpoint.cs b/sdk/dotnet/web-api/Endpoints/TranscribeEndpoint.cs index d350d67..f49a840 100644 --- a/sdk/dotnet/web-api/Endpoints/TranscribeEndpoint.cs +++ b/sdk/dotnet/web-api/Endpoints/TranscribeEndpoint.cs @@ -14,11 +14,13 @@ private static async Task Handle( IConfiguration config, IWebHostEnvironment env) { - if (!CortiHelpers.TryCreateCortiClient(config, out var client, out var credentialError)) + if (!CortiHelpers.TryGetCortiConfig(config, out var cc, out var credentialError)) { return credentialError; } + var client = new CortiClient(cc!.TenantName, cc.Environment, new CortiClientAuth.ClientCredentials(cc.ClientId, cc.ClientSecret)); + var samplePath = CortiHelpers.ResolveSampleFilePath(env.ContentRootPath, "trouble-breathing.mp3"); if (samplePath is null) { @@ -30,10 +32,9 @@ private static async Task Handle( try { - var transcribeApi = await client!.CreateTranscribeApiAsync(); + var transcribeApi = await client.CreateTranscribeApiAsync(); var messages = new List(); - var configAcceptedTcs = new TaskCompletionSource(); var flushedTcs = new TaskCompletionSource(); void AddMessage(object msg) @@ -44,6 +45,8 @@ void AddMessage(object msg) } } + var configAcceptedTcs = new TaskCompletionSource(); + transcribeApi.TranscribeConfigStatusMessage.Subscribe((TranscribeConfigStatusMessage msg) => { AddMessage(msg); @@ -62,17 +65,16 @@ void AddMessage(object msg) AddMessage(msg); flushedTcs.TrySetResult(); }); - transcribeApi.TranscribeUsageMessage.Subscribe(msg => AddMessage(msg)); - transcribeApi.TranscribeTranscriptMessage.Subscribe(msg => AddMessage(msg)); - transcribeApi.TranscribeErrorMessage.Subscribe(msg => AddMessage(msg)); - transcribeApi.TranscribeCommandMessage.Subscribe(msg => AddMessage(msg)); - transcribeApi.TranscribeEndedMessage.Subscribe(msg => AddMessage(msg)); + transcribeApi.TranscribeUsageMessage.Subscribe(AddMessage); + transcribeApi.TranscribeTranscriptMessage.Subscribe(AddMessage); + transcribeApi.TranscribeErrorMessage.Subscribe(AddMessage); + transcribeApi.TranscribeCommandMessage.Subscribe(AddMessage); + transcribeApi.TranscribeEndedMessage.Subscribe(AddMessage); await transcribeApi.ConnectAsync(); await transcribeApi.Send(new TranscribeConfigMessage { - Type = TranscribeConfigMessageType.Config, Configuration = new TranscribeConfig { PrimaryLanguage = "en" }, }); await configAcceptedTcs.Task; @@ -92,7 +94,7 @@ await transcribeApi.Send(new TranscribeConfigMessage } } - await transcribeApi.Send(new TranscribeFlushMessage { Type = TranscribeFlushMessageType.Flush }); + await transcribeApi.Send(new TranscribeFlushMessage()); await flushedTcs.Task; await transcribeApi.CloseAsync(); diff --git a/sdk/dotnet/web-api/Endpoints/TranscribeWithConfigEndpoint.cs b/sdk/dotnet/web-api/Endpoints/TranscribeWithConfigEndpoint.cs new file mode 100644 index 0000000..528b109 --- /dev/null +++ b/sdk/dotnet/web-api/Endpoints/TranscribeWithConfigEndpoint.cs @@ -0,0 +1,97 @@ +using Corti; +using CortiApiExamples; + +namespace CortiApiExamples.Endpoints; + +public static class TranscribeWithConfigEndpoint +{ + public static void MapTranscribeWithConfigEndpoint(this WebApplication app) + { + app.MapGet("/transcribe-with-config", Handle); + } + + private static async Task Handle( + IConfiguration config, + IWebHostEnvironment env) + { + if (!CortiHelpers.TryGetCortiConfig(config, out var cc, out var credentialError)) + { + return credentialError; + } + + var client = new CortiClient(cc!.TenantName, cc.Environment, new CortiClientAuth.ClientCredentials(cc.ClientId, cc.ClientSecret)); + + var samplePath = CortiHelpers.ResolveSampleFilePath(env.ContentRootPath, "trouble-breathing.mp3"); + if (samplePath is null) + { + return Results.BadRequest(new + { + error = "Sample file not found. Copy typescript/next/public/trouble-breathing.mp3 to csharp/api/sample/.", + }); + } + + try + { + var transcribeApi = await client.CreateTranscribeApiAsync(); + + var messages = new List(); + var flushedTcs = new TaskCompletionSource(); + + void AddMessage(object msg) + { + lock (messages) + { + messages.Add(msg); + } + } + + transcribeApi.TranscribeConfigStatusMessage.Subscribe(AddMessage); + transcribeApi.TranscribeFlushedMessage.Subscribe((TranscribeFlushedMessage msg) => + { + AddMessage(msg); + flushedTcs.TrySetResult(); + }); + transcribeApi.TranscribeUsageMessage.Subscribe(AddMessage); + transcribeApi.TranscribeTranscriptMessage.Subscribe(AddMessage); + transcribeApi.TranscribeErrorMessage.Subscribe(AddMessage); + transcribeApi.TranscribeCommandMessage.Subscribe(AddMessage); + transcribeApi.TranscribeEndedMessage.Subscribe(AddMessage); + + // ConnectAsync sends configuration and resolves only after CONFIG_ACCEPTED. + // It throws on CONFIG_DENIED / CONFIG_TIMEOUT. + await transcribeApi.ConnectAsync(new TranscribeConfig { PrimaryLanguage = "en" }); + + const int chunkSize = 4_096; + var chunkCount = 0; + await using (var sampleStream = File.OpenRead(samplePath)) + { + var audioBuffer = new byte[chunkSize]; + int read; + while ((read = await sampleStream.ReadAsync(audioBuffer, CancellationToken.None)) > 0) + { + var chunk = new byte[read]; + Array.Copy(audioBuffer, chunk, read); + await transcribeApi.Send(chunk); + chunkCount++; + } + } + + await transcribeApi.Send(new TranscribeFlushMessage()); + await flushedTcs.Task; + + await transcribeApi.CloseAsync(); + await transcribeApi.DisposeAsync(); + + return Results.Ok(new + { + messageCount = messages.Count, + messages, + message = "Transcribe WebSocket (SDK, with config): configuration passed to ConnectAsync(), audio sent by chunks, flush sent, flushed received.", + }); + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 500); + } + } +} diff --git a/sdk/dotnet/web-api/Program.cs b/sdk/dotnet/web-api/Program.cs index c5c34c7..9ae20e1 100644 --- a/sdk/dotnet/web-api/Program.cs +++ b/sdk/dotnet/web-api/Program.cs @@ -29,6 +29,11 @@ app.MapAgentsEndpoint(); app.MapDocumentsEndpoint(); app.MapTranscribeEndpoint(); +app.MapTranscribeWithConfigEndpoint(); app.MapStreamEndpoint(); +app.MapStreamWithConfigEndpoint(); +app.MapAmbientAsyncEndToEndEndpoint(); +app.MapAmbientAsyncFactsEndpoint(); +app.MapAmbientRtStreamsEndpoint(); app.Run(); diff --git a/sdk/postman/WebApi.postman_collection.json b/sdk/postman/WebApi.postman_collection.json index 983f261..874557b 100644 --- a/sdk/postman/WebApi.postman_collection.json +++ b/sdk/postman/WebApi.postman_collection.json @@ -91,6 +91,60 @@ }, "response": [] }, + { + "name": "Ambient async (end-to-end)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{host}}/ambient-async-end-to-end", + "host": [ + "{{host}}" + ], + "path": [ + "ambient-async-end-to-end" + ], + "query": [] + } + }, + "response": [] + }, + { + "name": "Ambient async facts (SDK)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{host}}/ambient-async-facts", + "host": [ + "{{host}}" + ], + "path": [ + "ambient-async-facts" + ], + "query": [] + } + }, + "response": [] + }, + { + "name": "Ambient RT streams (SDK)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{host}}/ambient-rt-streams", + "host": [ + "{{host}}" + ], + "path": [ + "ambient-rt-streams" + ], + "query": [] + } + }, + "response": [] + }, { "name": "Facts", "request": { @@ -231,6 +285,24 @@ }, "response": [] }, + { + "name": "Transcribe with config", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{host}}/transcribe-with-config", + "host": [ + "{{host}}" + ], + "path": [ + "transcribe-with-config" + ], + "query": [] + } + }, + "response": [] + }, { "name": "Stream", "request": { @@ -255,6 +327,30 @@ }, "response": [] }, + { + "name": "Stream with config", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{host}}/stream-with-config", + "host": [ + "{{host}}" + ], + "path": [ + "stream-with-config" + ], + "query": [ + { + "key": "interactionId", + "value": "", + "disabled": true + } + ] + } + }, + "response": [] + }, { "name": "Token – client credentials (call API)", "request": { diff --git a/sdk/typescript/express-web-api/package-lock.json b/sdk/typescript/express-web-api/package-lock.json index 65ceeb7..4b903f1 100644 --- a/sdk/typescript/express-web-api/package-lock.json +++ b/sdk/typescript/express-web-api/package-lock.json @@ -184,9 +184,9 @@ } }, "node_modules/@corti/sdk": { - "version": "1.0.0-alpha", - "resolved": "https://registry.npmjs.org/@corti/sdk/-/sdk-1.0.0-alpha.tgz", - "integrity": "sha512-aM2fsEGhlwo/BlT5ogmWzYJdp8TTfjmOPviAsFRBQpTnqS6llrdso4gh8KxivH/HZjxnSVcooEzG006YKnnKzQ==", + "version": "1.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/@corti/sdk/-/sdk-1.0.0-alpha.1.tgz", + "integrity": "sha512-xyJc+FzTuxxUttkpMCJ6ZyEiC8WrsMe3eD4Q/RouhCQqGavtD/wzHnG9GZ5o0ZL8OzZj1XKkrO7YZoCScKTkkg==", "license": "MIT", "dependencies": { "ws": "^8.16.0" @@ -1746,9 +1746,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/sdk/typescript/express-web-api/package.json b/sdk/typescript/express-web-api/package.json index a0ddaf1..2500bd5 100644 --- a/sdk/typescript/express-web-api/package.json +++ b/sdk/typescript/express-web-api/package.json @@ -12,7 +12,7 @@ "format": "biome format --write src/" }, "dependencies": { - "@corti/sdk": "alpha", + "@corti/sdk": "rc", "dotenv": "^16.4.5", "express": "^4.21.0" }, diff --git a/sdk/typescript/express-web-api/src/routes/ambientAsyncEndToEnd.ts b/sdk/typescript/express-web-api/src/routes/ambientAsyncEndToEnd.ts new file mode 100644 index 0000000..e4b7d93 --- /dev/null +++ b/sdk/typescript/express-web-api/src/routes/ambientAsyncEndToEnd.ts @@ -0,0 +1,86 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import type { Application, Request, Response } from "express"; +import { asyncHandler } from "../lib/asyncHandler.js"; +import { cortiErrorResponse, createCortiClient, sendCortiConfigError } from "../lib/corti.js"; +import { resolveSampleFilePath } from "../lib/sample.js"; + +export function registerAmbientAsyncEndToEnd(app: Application): void { + app.get("/ambient-async-end-to-end", asyncHandler(handle)); +} + +async function handle(_req: Request, res: Response): Promise { + if (sendCortiConfigError(res)) { + return; + } + + const { client } = createCortiClient(); + + if (!client) { + res.status(500).json({ error: "Missing client" }); + + return; + } + + try { + const samplePath = resolveSampleFilePath(); + + if (!samplePath) { + res.status(400).json({ + error: + "Sample file not found. Copy trouble-breathing.mp3 to sample/ or use typescript/next/public/trouble-breathing.mp3.", + }); + + return; + } + + const identifier = randomUUID(); + + const { interactionId } = await client.interactions.create({ + encounter: { + identifier, + status: "planned", + type: "first_consultation", + }, + }); + + if (!interactionId) { + throw new Error("Missing interactionId"); + } + + const { recordingId } = await client.recordings.upload( + fs.createReadStream(samplePath, { autoClose: true }), + interactionId, + ); + + if (!recordingId) { + throw new Error("Missing recordingId"); + } + + const transcript = await client.transcripts.create(interactionId, { + recordingId, + primaryLanguage: "en", + }); + + const context = (transcript.transcripts ?? []).map((t) => ({ + type: "transcript" as const, + data: t, + })); + + const document = await client.documents.create(interactionId, { + context, + templateKey: "soap", + outputLanguage: "en", + }); + + res.json({ + interactionId, + recordingId, + transcript, + document, + documentName: document.name, + }); + } catch (e) { + cortiErrorResponse(e, res); + } +} diff --git a/sdk/typescript/express-web-api/src/routes/ambientAsyncFacts.ts b/sdk/typescript/express-web-api/src/routes/ambientAsyncFacts.ts new file mode 100644 index 0000000..c9bf957 --- /dev/null +++ b/sdk/typescript/express-web-api/src/routes/ambientAsyncFacts.ts @@ -0,0 +1,86 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import type { Application, Request, Response } from "express"; +import { asyncHandler } from "../lib/asyncHandler.js"; +import { cortiErrorResponse, createCortiClient, sendCortiConfigError } from "../lib/corti.js"; +import { resolveSampleFilePath } from "../lib/sample.js"; + +export function registerAmbientAsyncFacts(app: Application): void { + app.get("/ambient-async-facts", asyncHandler(handle)); +} + +async function handle(_req: Request, res: Response): Promise { + if (sendCortiConfigError(res)) { + return; + } + + const { client } = createCortiClient(); + + if (!client) { + res.status(500).json({ error: "Missing client" }); + return; + } + + try { + const samplePath = resolveSampleFilePath(); + + if (!samplePath) { + res.status(400).json({ + error: + "Sample file not found. Copy trouble-breathing.mp3 to sample/ or use typescript/next/public/trouble-breathing.mp3.", + }); + return; + } + + const { interactionId } = await client.interactions.create({ + encounter: { + identifier: randomUUID(), + status: "planned", + type: "first_consultation", + }, + }); + + if (!interactionId) { + throw new Error("Missing interactionId"); + } + + const { recordingId } = await client.recordings.upload( + fs.createReadStream(samplePath, { autoClose: true }), + interactionId, + ); + + if (!recordingId) { + throw new Error("Missing recordingId"); + } + + const transcript = await client.transcripts.create(interactionId, { + recordingId, + primaryLanguage: "en", + diarize: true, + isMultichannel: false, + }); + + const context = [ + { + type: "text" as const, + text: (transcript.transcripts ?? []).map((t) => t.text).join(" "), + }, + ]; + + const { facts } = await client.facts.extract({ + context, + outputLanguage: "en", + }); + + res.json({ + interactionId, + recordingId, + transcript, + facts, + factCount: facts.length, + message: "Ambient async facts (SDK): upload recording, transcribe, extract facts.", + }); + } catch (e) { + cortiErrorResponse(e, res); + } +} diff --git a/sdk/typescript/express-web-api/src/routes/ambientRtStreams.ts b/sdk/typescript/express-web-api/src/routes/ambientRtStreams.ts new file mode 100644 index 0000000..97c2c91 --- /dev/null +++ b/sdk/typescript/express-web-api/src/routes/ambientRtStreams.ts @@ -0,0 +1,98 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import { Corti } from "@corti/sdk"; +import type { Application, Request, Response } from "express"; +import { asyncHandler } from "../lib/asyncHandler.js"; +import { cortiErrorResponse, createCortiClient, sendCortiConfigError } from "../lib/corti.js"; +import { resolveSampleFilePath } from "../lib/sample.js"; + +export function registerAmbientRtStreams(app: Application): void { + app.get("/ambient-rt-streams", asyncHandler(handle)); +} + +async function handle(_req: Request, res: Response): Promise { + if (sendCortiConfigError(res)) { + return; + } + + const { client } = createCortiClient(); + + if (!client) { + res.status(500).json({ error: "Missing client" }); + return; + } + + const samplePath = resolveSampleFilePath(); + if (!samplePath) { + res.status(400).json({ + error: + "Sample file not found. Copy trouble-breathing.mp3 to sample/ or use typescript/next/public/trouble-breathing.mp3.", + }); + return; + } + + try { + const now = new Date(); + const created = await client.interactions.create({ + assignedUserId: randomUUID(), + encounter: { + identifier: randomUUID(), + status: Corti.InteractionsEncounterStatusEnum.Planned, + type: Corti.InteractionsEncounterTypeEnum.FirstConsultation, + period: { startedAt: now, endedAt: now }, + title: "Consultation", + }, + }); + + const interactionId = created.interactionId ?? null; + if (!interactionId) { + throw new Error("Missing interactionId"); + } + + const socket = await client.stream.connect({ + id: interactionId, + configuration: { + transcription: { + primaryLanguage: "en", + isDiarization: false, + isMultichannel: false, + participants: [{ channel: 0, role: "multiple" }], + }, + mode: { type: "facts", outputLocale: "en" }, + }, + }); + + const messages: unknown[] = []; + let endedResolve: () => void; + const endedPromise = new Promise((resolve) => { + endedResolve = resolve; + }); + + socket.on("message", (msg: unknown) => { + messages.push(msg); + const m = msg as { type?: string }; + if (m.type === "ENDED") { + endedResolve(); + } + }); + + const stream = fs.createReadStream(samplePath, { highWaterMark: 32000, autoClose: true }); + for await (const chunk of stream) { + socket.sendAudio(chunk); + } + socket.sendEnd({ type: "end" }); + + await endedPromise; + socket.close(); + + res.json({ + interactionId, + messageCount: messages.length, + messages, + message: + "Ambient RT streams (SDK): connect with facts config, stream audio, end, await ENDED.", + }); + } catch (e) { + cortiErrorResponse(e, res); + } +} diff --git a/sdk/typescript/express-web-api/src/routes/codes.ts b/sdk/typescript/express-web-api/src/routes/codes.ts index f7bae01..f9b3cd9 100644 --- a/sdk/typescript/express-web-api/src/routes/codes.ts +++ b/sdk/typescript/express-web-api/src/routes/codes.ts @@ -21,14 +21,13 @@ async function handle(_req: Request, res: Response): Promise { } try { const predictResponse = await client.codes.predict({ - system: [Corti.CommonCodingSystemEnum.Icd10Cm, Corti.CommonCodingSystemEnum.Cpt], + system: [Corti.CommonCodingSystemEnum.Icd10CmOutpatient, Corti.CommonCodingSystemEnum.Cpt], context: [ { type: "text", text: "Short arm splint applied in ED for pain control.", }, ], - maxCandidates: 5, }); res.json({ diff --git a/sdk/typescript/express-web-api/src/routes/index.ts b/sdk/typescript/express-web-api/src/routes/index.ts index 3a3399a..e7c0371 100644 --- a/sdk/typescript/express-web-api/src/routes/index.ts +++ b/sdk/typescript/express-web-api/src/routes/index.ts @@ -1,5 +1,8 @@ import type { Application } from "express"; import { registerAgents } from "./agents.js"; +import { registerAmbientAsyncEndToEnd } from "./ambientAsyncEndToEnd.js"; +import { registerAmbientAsyncFacts } from "./ambientAsyncFacts.js"; +import { registerAmbientRtStreams } from "./ambientRtStreams.js"; import { registerClientVariants } from "./clientVariants.js"; import { registerCodes } from "./codes.js"; import { registerDocuments } from "./documents.js"; @@ -7,9 +10,11 @@ import { registerFacts } from "./facts.js"; import { registerInteractions } from "./interactions.js"; import { registerRecordings } from "./recordings.js"; import { registerStream } from "./stream.js"; +import { registerStreamWithConfig } from "./streamWithConfig.js"; import { registerTemplates } from "./templates.js"; import { registerToken } from "./token.js"; import { registerTranscribe } from "./transcribe.js"; +import { registerTranscribeWithConfig } from "./transcribeWithConfig.js"; import { registerTranscripts } from "./transcripts.js"; export function registerRoutes(app: Application): void { @@ -24,5 +29,10 @@ export function registerRoutes(app: Application): void { registerAgents(app); registerDocuments(app); registerStream(app); + registerStreamWithConfig(app); registerTranscribe(app); + registerTranscribeWithConfig(app); + registerAmbientAsyncEndToEnd(app); + registerAmbientAsyncFacts(app); + registerAmbientRtStreams(app); } diff --git a/sdk/typescript/express-web-api/src/routes/streamWithConfig.ts b/sdk/typescript/express-web-api/src/routes/streamWithConfig.ts new file mode 100644 index 0000000..849dacb --- /dev/null +++ b/sdk/typescript/express-web-api/src/routes/streamWithConfig.ts @@ -0,0 +1,125 @@ +import * as fs from "node:fs"; +import { Corti } from "@corti/sdk"; +import type { Application, Request, Response } from "express"; +import { asyncHandler } from "../lib/asyncHandler.js"; +import { cortiErrorResponse, createCortiClient, sendCortiConfigError } from "../lib/corti.js"; +import { resolveSampleFilePath } from "../lib/sample.js"; + +const CHUNK_SIZE = 4096; + +export function registerStreamWithConfig(app: Application): void { + app.get("/stream-with-config", asyncHandler(handle)); +} + +async function handle(req: Request, res: Response): Promise { + if (sendCortiConfigError(res)) { + return; + } + + const { client } = createCortiClient(); + + if (!client) { + res.status(500).json({ error: "Missing client" }); + + return; + } + + let interactionId = + typeof req.query.interactionId === "string" ? req.query.interactionId.trim() : null; + + if (!interactionId) { + const id = String(Date.now()); + const created = await client.interactions.create({ + encounter: { + identifier: id, + status: Corti.InteractionsEncounterStatusEnum.Planned, + type: Corti.InteractionsEncounterTypeEnum.FirstConsultation, + }, + patient: { + identifier: id, + gender: "unknown", + }, + }); + + interactionId = created.interactionId ?? null; + } + + if (!interactionId) { + res.status(400).json({ error: "Missing interactionId." }); + + return; + } + + const samplePath = resolveSampleFilePath(); + + if (!samplePath) { + res.status(400).json({ + error: + "Sample file not found. Copy trouble-breathing.mp3 to sample/ or use typescript/next/public/trouble-breathing.mp3.", + }); + + return; + } + + try { + // connect() sends configuration and resolves only after CONFIG_ACCEPTED. + // It rejects on CONFIG_DENIED / CONFIG_MISSING / CONFIG_TIMEOUT / CONFIG_NOT_PROVIDED. + const socket = await client.stream.connect({ + id: interactionId, + configuration: { + transcription: { + primaryLanguage: "en", + participants: [], + }, + mode: { type: Corti.StreamConfigModeType.Transcription }, + }, + }); + + const messages: unknown[] = []; + let flushedResolve: () => void; + const flushedPromise = new Promise((resolve) => { + flushedResolve = resolve; + }); + + socket.on("message", (msg: unknown) => { + messages.push(msg); + + const m = msg as { type?: string }; + if (m.type === "flushed") { + flushedResolve(); + } + }); + + const buffer = Buffer.alloc(CHUNK_SIZE); + const fd = fs.openSync(samplePath, "r"); + + try { + let read: number; + + do { + read = fs.readSync(fd, buffer, 0, CHUNK_SIZE, null); + if (read > 0) { + const chunk = read < CHUNK_SIZE ? buffer.subarray(0, read) : buffer; + socket.sendAudio(chunk); + } + } while (read > 0); + } finally { + fs.closeSync(fd); + } + + socket.sendFlush({ type: "flush" }); + await flushedPromise; + + socket.close(); + + res.json({ + interactionId, + messageCount: messages.length, + messages, + message: + "Stream WebSocket (SDK, with config): configuration passed to connect(), audio sent by chunks, flush sent, flushed received.", + }); + } catch (e) { + cortiErrorResponse(e, res); + } +} diff --git a/sdk/typescript/express-web-api/src/routes/token.ts b/sdk/typescript/express-web-api/src/routes/token.ts index 497daac..5aed103 100644 --- a/sdk/typescript/express-web-api/src/routes/token.ts +++ b/sdk/typescript/express-web-api/src/routes/token.ts @@ -309,7 +309,7 @@ async function tokenAuthCodeAuthorize(req: Request, res: Response): Promise { environment: config.environment, }); - const url = await cortiAuth.authorizeUrl( + const url = await cortiAuth.authorizeURL( { clientId: config.clientId, redirectUri, codeChallenge }, { skipRedirect: true }, ); diff --git a/sdk/typescript/express-web-api/src/routes/transcribeWithConfig.ts b/sdk/typescript/express-web-api/src/routes/transcribeWithConfig.ts new file mode 100644 index 0000000..2f9d82c --- /dev/null +++ b/sdk/typescript/express-web-api/src/routes/transcribeWithConfig.ts @@ -0,0 +1,90 @@ +import * as fs from "node:fs"; +import type { Application, Request, Response } from "express"; +import { asyncHandler } from "../lib/asyncHandler.js"; +import { cortiErrorResponse, createCortiClient, sendCortiConfigError } from "../lib/corti.js"; +import { resolveSampleFilePath } from "../lib/sample.js"; + +const CHUNK_SIZE = 4096; + +export function registerTranscribeWithConfig(app: Application): void { + app.get("/transcribe-with-config", asyncHandler(handle)); +} + +async function handle(_req: Request, res: Response): Promise { + if (sendCortiConfigError(res)) { + return; + } + + const { client } = createCortiClient(); + + if (!client) { + res.status(500).json({ error: "Missing client" }); + + return; + } + + const samplePath = resolveSampleFilePath(); + + if (!samplePath) { + res.status(400).json({ + error: + "Sample file not found. Copy trouble-breathing.mp3 to sample/ or use typescript/next/public/trouble-breathing.mp3.", + }); + + return; + } + + try { + // connect() sends configuration and resolves only after CONFIG_ACCEPTED. + // It rejects on CONFIG_DENIED / CONFIG_TIMEOUT. + const socket = await client.transcribe.connect({ + configuration: { primaryLanguage: "en" }, + }); + + const messages: unknown[] = []; + let flushedResolve: () => void; + const flushedPromise = new Promise((resolve) => { + flushedResolve = resolve; + }); + + socket.on("message", (msg: unknown) => { + messages.push(msg); + + const m = msg as { type?: string }; + if (m.type === "flushed") { + flushedResolve(); + } + }); + + const buffer = Buffer.alloc(CHUNK_SIZE); + const fd = fs.openSync(samplePath, "r"); + + try { + let read: number; + + do { + read = fs.readSync(fd, buffer, 0, CHUNK_SIZE, null); + if (read > 0) { + const chunk = read < CHUNK_SIZE ? buffer.subarray(0, read) : buffer; + socket.sendAudio(chunk); + } + } while (read > 0); + } finally { + fs.closeSync(fd); + } + + socket.sendFlush({ type: "flush" }); + await flushedPromise; + + socket.close(); + + res.json({ + messageCount: messages.length, + messages, + message: + "Transcribe WebSocket (SDK, with config): configuration passed to connect(), audio sent by chunks, flush sent, flushed received.", + }); + } catch (e) { + cortiErrorResponse(e, res); + } +} diff --git a/sdk/typescript/next-auth-examples/app/api/auth/token/pkce/route.ts b/sdk/typescript/next-auth-examples/app/api/auth/token/pkce/route.ts deleted file mode 100644 index c8baad8..0000000 --- a/sdk/typescript/next-auth-examples/app/api/auth/token/pkce/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CortiAuth } from "@corti/sdk"; -import { NextResponse } from "next/server"; -import { getErrorStatus, isNonEmptyString, parseJsonBody } from "@/app/lib/utils"; - -export async function POST(request: Request) { - const body = await parseJsonBody(request); - if (body === null || typeof body !== "object") { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const clientId = isNonEmptyString(body.clientId) ? body.clientId.trim() : null; - const environment = isNonEmptyString(body.environment) ? body.environment.trim() : null; - const tenant = isNonEmptyString(body.tenant) ? body.tenant.trim() : null; - const code = isNonEmptyString(body.code) ? body.code.trim() : null; - const redirectUri = isNonEmptyString(body.redirectUri) ? body.redirectUri.trim() : null; - const codeVerifier = isNonEmptyString(body.codeVerifier) ? body.codeVerifier.trim() : null; - - if (!clientId || !environment || !tenant || !code || !redirectUri || !codeVerifier) { - return NextResponse.json( - { - error: - "Missing or empty required fields: clientId, environment, tenant, code, redirectUri, codeVerifier", - }, - { status: 400 }, - ); - } - - try { - const cortiAuth = new CortiAuth({ - tenantName: tenant, - environment, - }); - const tokenResponse = await cortiAuth.getPkceFlowToken({ - clientId, - code, - redirectUri, - codeVerifier, - }); - return NextResponse.json(tokenResponse); - } catch (e) { - const { message, status } = getErrorStatus(e, "Token exchange failed"); - return NextResponse.json({ error: message }, { status }); - } -} diff --git a/sdk/typescript/next-auth-examples/app/components/AuthCodeCredentialsForm.tsx b/sdk/typescript/next-auth-examples/app/components/AuthCodeCredentialsForm.tsx index 877e1b3..ab0195a 100644 --- a/sdk/typescript/next-auth-examples/app/components/AuthCodeCredentialsForm.tsx +++ b/sdk/typescript/next-auth-examples/app/components/AuthCodeCredentialsForm.tsx @@ -1,6 +1,6 @@ "use client"; -import type { Dispatch, SetStateAction, SubmitEvent } from "react"; +import type { ComponentProps, Dispatch, SetStateAction } from "react"; import { Button } from "@/app/components/Button"; import { FormField } from "@/app/components/FormField"; import { CONSOLE_URL } from "@/app/lib/constants"; @@ -9,7 +9,7 @@ import type { AuthCodeFormState } from "@/app/lib/types"; type AuthCodeCredentialsFormProps = { form: AuthCodeFormState; setForm: Dispatch>; - onSubmit: (e: SubmitEvent) => void; + onSubmit: NonNullable["onSubmit"]>; tokenError: string | null; tokenLoading: boolean; }; @@ -39,6 +39,7 @@ export function AuthCodeCredentialsForm({
>; - onSubmit: (e: SubmitEvent) => void; + onSubmit: NonNullable["onSubmit"]>; tokenError: string | null; tokenLoading: boolean; }; @@ -39,6 +39,7 @@ export function ClientCredentialsForm({
onChange(e.target.value)} diff --git a/sdk/typescript/next-auth-examples/app/components/PkceCredentialsForm.tsx b/sdk/typescript/next-auth-examples/app/components/PkceCredentialsForm.tsx index e81cea7..6fc2b30 100644 --- a/sdk/typescript/next-auth-examples/app/components/PkceCredentialsForm.tsx +++ b/sdk/typescript/next-auth-examples/app/components/PkceCredentialsForm.tsx @@ -1,6 +1,6 @@ "use client"; -import type { Dispatch, SetStateAction, SubmitEvent } from "react"; +import type { ComponentProps, Dispatch, SetStateAction } from "react"; import { Button } from "@/app/components/Button"; import { FormField } from "@/app/components/FormField"; import { CONSOLE_URL } from "@/app/lib/constants"; @@ -9,7 +9,7 @@ import type { PkceFormState } from "@/app/lib/types"; type PkceCredentialsFormProps = { form: PkceFormState; setForm: Dispatch>; - onSubmit: (e: SubmitEvent) => void; + onSubmit: NonNullable["onSubmit"]>; tokenError: string | null; tokenLoading: boolean; }; @@ -39,6 +39,7 @@ export function PkceCredentialsForm({
>; - onSubmit: (e: SubmitEvent) => void; + onSubmit: NonNullable["onSubmit"]>; tokenError: string | null; tokenLoading: boolean; }; @@ -39,6 +39,7 @@ export function RopcCredentialsForm({
void; + refreshTokenLoading: boolean; }; function hasId(item: unknown): item is { id: unknown } { @@ -34,9 +36,12 @@ export function SuccessView({ interactionsList, interactionsLoading, interactionsError, + onRefreshToken, + refreshTokenLoading, }: SuccessViewProps) { const preview = interactionsList.slice(0, 5); const remaining = interactionsList.length - 5; + const refreshTokenDisabled = refreshTokenLoading || !token.refreshToken || !onRefreshToken; return (
@@ -60,6 +65,17 @@ export function SuccessView({ )}

+
+ +
+

Request to Corti API

diff --git a/sdk/typescript/next-auth-examples/app/lib/forms.ts b/sdk/typescript/next-auth-examples/app/lib/forms.ts new file mode 100644 index 0000000..c98e574 --- /dev/null +++ b/sdk/typescript/next-auth-examples/app/lib/forms.ts @@ -0,0 +1,52 @@ +export function getRequiredTrimmedFields< + T extends Record, + const K extends readonly (keyof T)[], +>( + form: T, + keys: K, +): { ok: true; values: { [P in K[number]]: string } } | { ok: false; missing: K[number][] } { + const values = {} as { [P in K[number]]: string }; + const missing: K[number][] = []; + + for (const key of keys) { + const value = form[key].trim(); + if (!value) { + missing.push(key); + } + values[key] = value; + } + + if (missing.length > 0) { + return { ok: false, missing }; + } + + return { ok: true, values }; +} + +export function getRequiredFormValues(formEl: HTMLFormElement): + | { + ok: true; + values: Record; + } + | { + ok: false; + missing: string[]; + } { + const data = new FormData(formEl); + const values: Record = {}; + const missing: string[] = []; + + for (const [key, value] of data.entries()) { + const v = String(value).trim(); + values[key] = v; + if (!v) { + missing.push(key); + } + } + + if (missing.length > 0) { + return { ok: false, missing }; + } + + return { ok: true, values }; +} diff --git a/sdk/typescript/next-auth-examples/app/lib/sessionJson.ts b/sdk/typescript/next-auth-examples/app/lib/sessionJson.ts new file mode 100644 index 0000000..e63ca01 --- /dev/null +++ b/sdk/typescript/next-auth-examples/app/lib/sessionJson.ts @@ -0,0 +1,16 @@ +export function cacheFormValues(key: string, value: unknown): void { + sessionStorage.setItem(key, JSON.stringify(value)); +} + +export function consumeCachedFormValues(key: string): T | null { + const raw = sessionStorage.getItem(key); + if (!raw) { + return null; + } + sessionStorage.removeItem(key); + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} diff --git a/sdk/typescript/next-auth-examples/app/lib/tokenRequest.ts b/sdk/typescript/next-auth-examples/app/lib/tokenRequest.ts index 6e6a056..6251fd7 100644 --- a/sdk/typescript/next-auth-examples/app/lib/tokenRequest.ts +++ b/sdk/typescript/next-auth-examples/app/lib/tokenRequest.ts @@ -7,8 +7,8 @@ export type TokenRequestResult = export async function requestToken( url: string, body: Record, - environment: string, - tenant: string, + environment: unknown, + tenant: unknown, defaultError: string, ): Promise { try { @@ -24,11 +24,12 @@ export async function requestToken( error: data?.error ?? `Request failed (${res.status})`, }; } + return { ok: true, data: data as TokenResponse, - environment, - tenant, + environment: environment as string, + tenant: tenant as string, }; } catch (e) { return { diff --git a/sdk/typescript/next-auth-examples/app/lib/useAuthExampleState.ts b/sdk/typescript/next-auth-examples/app/lib/useAuthExampleState.ts new file mode 100644 index 0000000..c48dd65 --- /dev/null +++ b/sdk/typescript/next-auth-examples/app/lib/useAuthExampleState.ts @@ -0,0 +1,156 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + initialAuthCodeForm, + initialForm, + initialPkceForm, + initialRopcForm, +} from "@/app/lib/constants"; +import { consumeCachedFormValues } from "@/app/lib/sessionJson"; +import type { + AuthCodeFormState, + FormState, + PkceFormState, + RopcFormState, + TokenResponse, +} from "@/app/lib/types"; + +const AUTH_CODE_SESSION_KEY = "authcode_form"; +const PKCE_SESSION_KEY = "pkce_form"; + +export type Flow = "cc" | "ropc" | "authCode" | "pkce" | null; + +export function useAuthExampleState() { + const [flow, setFlow] = useState(null); + + const [form, setForm] = useState(initialForm); + const [ropcForm, setRopcForm] = useState(initialRopcForm); + const [authCodeForm, setAuthCodeForm] = useState(initialAuthCodeForm); + const [pkceForm, setPkceForm] = useState(initialPkceForm); + + const [receivedCode, setReceivedCode] = useState(null); + const [pkceReceivedCode, setPkceReceivedCode] = useState(null); + + const [tokenLoading, setTokenLoading] = useState(false); + const [tokenError, setTokenError] = useState(null); + const [token, setToken] = useState(null); + const [tokenEnvTenant, setTokenEnvTenant] = useState<{ + environment: string; + tenant: string; + } | null>(null); + const [tokenClient, setTokenClient] = useState<{ clientId: string } | null>(null); + + useEffect(() => { + const defaultRedirectUri = window.location.origin; + + setAuthCodeForm((prev) => { + if (prev.redirectUri) { + return prev; + } + + return { ...prev, redirectUri: defaultRedirectUri }; + }); + + setPkceForm((prev) => { + if (prev.redirectUri) { + return prev; + } + + return { ...prev, redirectUri: defaultRedirectUri }; + }); + }, []); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + if (!code) return; + + window.history.replaceState({}, "", window.location.pathname); + + const pkceSaved = consumeCachedFormValues(PKCE_SESSION_KEY); + if (pkceSaved) { + setFlow("pkce"); + setPkceForm(pkceSaved); + setPkceReceivedCode(code); + return; + } + + const authCodeSaved = consumeCachedFormValues(AUTH_CODE_SESSION_KEY); + if (!authCodeSaved) return; + + setFlow("authCode"); + setAuthCodeForm(authCodeSaved); + setReceivedCode(code); + }, []); + + const handleBack = useCallback(() => { + setFlow(null); + setToken(null); + setTokenEnvTenant(null); + setTokenClient(null); + setTokenError(null); + setReceivedCode(null); + setPkceReceivedCode(null); + }, []); + + const withTokenStates = useCallback(async (fn: () => Promise, defaultError: string) => { + setTokenError(null); + setTokenLoading(true); + + try { + await fn(); + } catch (e) { + setTokenError(e instanceof Error ? e.message : defaultError); + } finally { + setTokenLoading(false); + } + }, []); + + const setTokenResult = useCallback( + (tokenResponse: TokenResponse, environment: string, tenant: string, clientId?: string) => { + setToken(tokenResponse); + setTokenEnvTenant({ environment, tenant }); + + if (clientId) { + setTokenClient({ clientId }); + } else { + setTokenClient(null); + } + }, + [], + ); + + return { + flow, + setFlow, + + form, + setForm, + ropcForm, + setRopcForm, + authCodeForm, + setAuthCodeForm, + pkceForm, + setPkceForm, + + receivedCode, + setReceivedCode, + pkceReceivedCode, + setPkceReceivedCode, + + tokenLoading, + setTokenLoading, + tokenError, + setTokenError, + token, + setToken, + tokenEnvTenant, + setTokenEnvTenant, + tokenClient, + + handleBack, + withTokenStates, + setTokenResult, + }; +} diff --git a/sdk/typescript/next-auth-examples/app/lib/utils.ts b/sdk/typescript/next-auth-examples/app/lib/utils.ts index 04d0f07..9d25f62 100644 --- a/sdk/typescript/next-auth-examples/app/lib/utils.ts +++ b/sdk/typescript/next-auth-examples/app/lib/utils.ts @@ -9,9 +9,11 @@ export function isNonEmptyString(v: unknown): v is string { return typeof v === "string" && v.trim().length > 0; } -export async function parseJsonBody(request: Request): Promise { +export async function parseJsonBody(request: Request): Promise | null> { try { - return await request.json(); + const body = await request.json(); + + return typeof body === "object" && body !== null ? (body as Record) : null; } catch { return null; } diff --git a/sdk/typescript/next-auth-examples/app/page.tsx b/sdk/typescript/next-auth-examples/app/page.tsx index 7e2ecbd..2e0235a 100644 --- a/sdk/typescript/next-auth-examples/app/page.tsx +++ b/sdk/typescript/next-auth-examples/app/page.tsx @@ -2,7 +2,7 @@ import { CortiAuth } from "@corti/sdk"; import type { SubmitEvent } from "react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { AuthCodeCredentialsForm } from "@/app/components/AuthCodeCredentialsForm"; import { AuthCodeReceivedView } from "@/app/components/AuthCodeReceivedView"; import { BackButton } from "@/app/components/BackButton"; @@ -12,42 +12,40 @@ import { PkceCredentialsForm } from "@/app/components/PkceCredentialsForm"; import { RopcCredentialsForm } from "@/app/components/RopcCredentialsForm"; import { SuccessView } from "@/app/components/SuccessView"; import { WarningBanner } from "@/app/components/WarningBanner"; -import { - initialAuthCodeForm, - initialForm, - initialPkceForm, - initialRopcForm, -} from "@/app/lib/constants"; +import { getRequiredFormValues } from "@/app/lib/forms"; +import { cacheFormValues } from "@/app/lib/sessionJson"; import { requestToken } from "@/app/lib/tokenRequest"; -import type { - AuthCodeFormState, - FormState, - PkceFormState, - RopcFormState, - TokenResponse, -} from "@/app/lib/types"; +import { useAuthExampleState } from "@/app/lib/useAuthExampleState"; import { useInteractionsList } from "@/app/lib/useInteractionsList"; const AUTH_CODE_SESSION_KEY = "authcode_form"; const PKCE_SESSION_KEY = "pkce_form"; -type Flow = "cc" | "ropc" | "authCode" | "pkce" | null; - export default function Home() { - const [flow, setFlow] = useState(null); - const [form, setForm] = useState(initialForm); - const [ropcForm, setRopcForm] = useState(initialRopcForm); - const [authCodeForm, setAuthCodeForm] = useState(initialAuthCodeForm); - const [pkceForm, setPkceForm] = useState(initialPkceForm); - const [receivedCode, setReceivedCode] = useState(null); - const [pkceReceivedCode, setPkceReceivedCode] = useState(null); - const [tokenLoading, setTokenLoading] = useState(false); - const [tokenError, setTokenError] = useState(null); - const [token, setToken] = useState(null); - const [tokenEnvTenant, setTokenEnvTenant] = useState<{ - environment: string; - tenant: string; - } | null>(null); + const { + flow, + setFlow, + form, + setForm, + ropcForm, + setRopcForm, + authCodeForm, + setAuthCodeForm, + pkceForm, + setPkceForm, + receivedCode, + setReceivedCode, + pkceReceivedCode, + tokenLoading, + tokenError, + setTokenError, + token, + tokenEnvTenant, + tokenClient, + handleBack, + withTokenStates, + setTokenResult, + } = useAuthExampleState(); const { list: interactionsList, @@ -59,218 +57,203 @@ export default function Home() { tokenEnvTenant?.tenant ?? "", ); - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const code = params.get("code"); - if (!code) return; - - window.history.replaceState({}, "", window.location.pathname); - - const pkceRaw = sessionStorage.getItem(PKCE_SESSION_KEY); - if (pkceRaw) { - try { - const saved = JSON.parse(pkceRaw) as PkceFormState; - sessionStorage.removeItem(PKCE_SESSION_KEY); - setFlow("pkce"); - setPkceForm(saved); - setPkceReceivedCode(code); - } catch { - // ignore invalid stored state - } - return; - } - - const authCodeRaw = sessionStorage.getItem(AUTH_CODE_SESSION_KEY); - if (!authCodeRaw) return; - - try { - const saved = JSON.parse(authCodeRaw) as AuthCodeFormState; - sessionStorage.removeItem(AUTH_CODE_SESSION_KEY); - setFlow("authCode"); - setAuthCodeForm(saved); - setReceivedCode(code); - } catch { - // ignore invalid stored state - } - }, []); - const handleSubmit = useCallback( - async (e: SubmitEvent) => { + async (e: SubmitEvent) => { e.preventDefault(); - const clientId = form.clientId.trim(); - const clientSecret = form.clientSecret.trim(); - const environment = form.environment.trim(); - const tenant = form.tenant.trim(); - if (!clientId || !clientSecret || !environment || !tenant) { + + const required = getRequiredFormValues(e.currentTarget); + + if (!required.ok) { setTokenError("All fields are required."); return; } - setTokenError(null); - setTokenLoading(true); - const result = await requestToken( - "/api/auth/token", - { clientId, clientSecret, environment, tenant }, - environment, - tenant, - "Failed to get token", - ); - setTokenLoading(false); - if (result.ok) { - setToken(result.data); - setTokenEnvTenant({ environment: result.environment, tenant: result.tenant }); - } else { - setTokenError(result.error); - } + + await withTokenStates(async () => { + const result = await requestToken( + "/api/auth/token", + required.values, + required.values.environment, + required.values.tenant, + "Failed to get token", + ); + + if (!result.ok) { + throw new Error(result.error); + } + + setTokenResult(result.data, result.environment, result.tenant, required.values.clientId); + }, "Failed to get token"); }, - [form], + [setTokenError, setTokenResult, withTokenStates], ); const handleRopcSubmit = useCallback( - async (e: SubmitEvent) => { + async (e: SubmitEvent) => { e.preventDefault(); - const clientId = ropcForm.clientId.trim(); - const environment = ropcForm.environment.trim(); - const tenant = ropcForm.tenant.trim(); - const username = ropcForm.username.trim(); - const password = ropcForm.password.trim(); - if (!clientId || !environment || !tenant || !username || !password) { + const required = getRequiredFormValues(e.currentTarget); + + if (!required.ok) { setTokenError("All fields are required."); return; } - setTokenError(null); - setTokenLoading(true); - const result = await requestToken( - "/api/auth/token/ropc", - { clientId, environment, tenant, username, password }, - environment, - tenant, - "Failed to get token", - ); - setTokenLoading(false); - if (result.ok) { - setToken(result.data); - setTokenEnvTenant({ environment: result.environment, tenant: result.tenant }); - } else { - setTokenError(result.error); - } + + await withTokenStates(async () => { + const result = await requestToken( + "/api/auth/token/ropc", + required.values, + required.values.environment, + required.values.tenant, + "Failed to get token", + ); + + if (!result.ok) { + throw new Error(result.error); + } + + setTokenResult(result.data, result.environment, result.tenant, required.values.clientId); + }, "Failed to get token"); }, - [ropcForm], + [setTokenError, setTokenResult, withTokenStates], ); const handleAuthCodeSubmit = useCallback( - async (e: SubmitEvent) => { + async (e: SubmitEvent) => { e.preventDefault(); - const clientId = authCodeForm.clientId.trim(); - const clientSecret = authCodeForm.clientSecret.trim(); - const environment = authCodeForm.environment.trim(); - const tenant = authCodeForm.tenant.trim(); - const redirectUri = authCodeForm.redirectUri.trim(); - if (!clientId || !clientSecret || !environment || !tenant || !redirectUri) { + const required = getRequiredFormValues(e.currentTarget); + + if (!required.ok) { setTokenError("All fields are required."); return; } + setTokenError(null); - sessionStorage.setItem( - AUTH_CODE_SESSION_KEY, - JSON.stringify({ clientId, clientSecret, environment, tenant, redirectUri }), - ); - const cortiAuth = new CortiAuth({ tenantName: tenant, environment }); - await cortiAuth.authorizeUrl({ clientId, redirectUri }); + cacheFormValues(AUTH_CODE_SESSION_KEY, required.values); + + const cortiAuth = new CortiAuth({ + tenantName: required.values.tenant, + environment: required.values.environment, + }); + + await cortiAuth.authorizeURL({ + clientId: required.values.clientId, + redirectUri: required.values.redirectUri, + }); }, - [authCodeForm], + [setTokenError], ); const handlePkceSubmit = useCallback( - async (e: SubmitEvent) => { + async (e: SubmitEvent) => { e.preventDefault(); - const clientId = pkceForm.clientId.trim(); - const environment = pkceForm.environment.trim(); - const tenant = pkceForm.tenant.trim(); - const redirectUri = pkceForm.redirectUri.trim(); - if (!clientId || !environment || !tenant || !redirectUri) { + const required = getRequiredFormValues(e.currentTarget); + + if (!required.ok) { setTokenError("All fields are required."); return; } + setTokenError(null); - sessionStorage.setItem( - PKCE_SESSION_KEY, - JSON.stringify({ clientId, environment, tenant, redirectUri }), - ); - const cortiAuth = new CortiAuth({ tenantName: tenant, environment }); - await cortiAuth.authorizePkceUrl({ clientId, redirectUri }); + cacheFormValues(PKCE_SESSION_KEY, required.values); + + const cortiAuth = new CortiAuth({ + tenantName: required.values.tenant, + environment: required.values.environment, + }); + + await cortiAuth.authorizePkceUrl({ + clientId: required.values.clientId, + redirectUri: required.values.redirectUri, + }); }, - [pkceForm], + [setTokenError], ); const handlePkceProceed = useCallback(async () => { if (!pkceReceivedCode) { return; } - setTokenError(null); - setTokenLoading(true); - const codeVerifier = CortiAuth.getCodeVerifier(); - const result = await requestToken( - "/api/auth/token/pkce", - { - clientId: pkceForm.clientId, + + await withTokenStates(async () => { + const cortiAuth = new CortiAuth({ + tenantName: pkceForm.tenant, environment: pkceForm.environment, - tenant: pkceForm.tenant, + }); + + const tokenResponse = await cortiAuth.getPkceFlowToken({ + clientId: pkceForm.clientId, code: pkceReceivedCode, redirectUri: pkceForm.redirectUri, - codeVerifier, - }, - pkceForm.environment, - pkceForm.tenant, - "Failed to exchange PKCE authorization code", - ); - setTokenLoading(false); - if (result.ok) { - setPkceReceivedCode(null); - setToken(result.data); - setTokenEnvTenant({ environment: result.environment, tenant: result.tenant }); - } else { - setTokenError(result.error); - } - }, [pkceReceivedCode, pkceForm]); + }); + + setTokenResult(tokenResponse, pkceForm.environment, pkceForm.tenant, pkceForm.clientId); + }, "Failed to exchange PKCE authorization code"); + }, [pkceForm, pkceReceivedCode, setTokenResult, withTokenStates]); const handleAuthCodeProceed = useCallback(async () => { if (!receivedCode) { return; } - setTokenError(null); - setTokenLoading(true); - const result = await requestToken( - "/api/auth/token/authcode", - { - clientId: authCodeForm.clientId, - clientSecret: authCodeForm.clientSecret, - environment: authCodeForm.environment, - tenant: authCodeForm.tenant, - code: receivedCode, - redirectUri: authCodeForm.redirectUri, - }, - authCodeForm.environment, - authCodeForm.tenant, - "Failed to exchange authorization code", - ); - setTokenLoading(false); - if (result.ok) { + await withTokenStates(async () => { + const result = await requestToken( + "/api/auth/token/authcode", + { + clientId: authCodeForm.clientId, + clientSecret: authCodeForm.clientSecret, + environment: authCodeForm.environment, + tenant: authCodeForm.tenant, + code: receivedCode, + redirectUri: authCodeForm.redirectUri, + }, + authCodeForm.environment, + authCodeForm.tenant, + "Failed to exchange authorization code", + ); + + if (!result.ok) { + throw new Error(result.error); + } + + setTokenResult(result.data, result.environment, result.tenant, authCodeForm.clientId); setReceivedCode(null); - setToken(result.data); - setTokenEnvTenant({ environment: result.environment, tenant: result.tenant }); - } else { - setTokenError(result.error); + }, "Failed to exchange authorization code"); + }, [authCodeForm, receivedCode, setReceivedCode, setTokenResult, withTokenStates]); + + const handleRefreshToken = useCallback(() => { + const canRefresh = + token != null && + token.refreshToken != null && + tokenEnvTenant != null && + tokenClient != null && + !!tokenClient.clientId; + + if (!canRefresh) { + return; } - }, [receivedCode, authCodeForm]); - const handleBack = useCallback(() => { - setFlow(null); - setToken(null); - setTokenEnvTenant(null); - setTokenError(null); - setReceivedCode(null); - setPkceReceivedCode(null); - }, []); + void withTokenStates(async () => { + const refreshToken = token.refreshToken; + if (!refreshToken) { + return; + } + + const cortiAuth = new CortiAuth({ + tenantName: tokenEnvTenant.tenant, + environment: tokenEnvTenant.environment, + }); + + const tokenResponse = await cortiAuth.refreshToken({ + clientId: tokenClient.clientId, + refreshToken, + }); + + setTokenResult( + tokenResponse, + tokenEnvTenant.environment, + tokenEnvTenant.tenant, + tokenClient.clientId, + ); + }, "Failed to refresh token"); + }, [setTokenResult, token, tokenClient, tokenEnvTenant, withTokenStates]); const showBack = flow != null || token != null; @@ -350,6 +333,8 @@ export default function Home() { interactionsList={interactionsList} interactionsLoading={interactionsLoading} interactionsError={interactionsError} + onRefreshToken={handleRefreshToken} + refreshTokenLoading={tokenLoading} /> )}

diff --git a/sdk/typescript/next-auth-examples/package-lock.json b/sdk/typescript/next-auth-examples/package-lock.json index 8b708be..7ec5361 100644 --- a/sdk/typescript/next-auth-examples/package-lock.json +++ b/sdk/typescript/next-auth-examples/package-lock.json @@ -8,7 +8,7 @@ "name": "next-auth-examples", "version": "0.1.0", "dependencies": { - "@corti/sdk": "alpha", + "@corti/sdk": "^1.0.0-rc.4", "clsx": "^2.1.1", "next": "16.1.6", "react": "19.2.3", @@ -201,9 +201,9 @@ } }, "node_modules/@corti/sdk": { - "version": "1.0.0-alpha", - "resolved": "https://registry.npmjs.org/@corti/sdk/-/sdk-1.0.0-alpha.tgz", - "integrity": "sha512-aM2fsEGhlwo/BlT5ogmWzYJdp8TTfjmOPviAsFRBQpTnqS6llrdso4gh8KxivH/HZjxnSVcooEzG006YKnnKzQ==", + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@corti/sdk/-/sdk-1.0.0-rc.4.tgz", + "integrity": "sha512-YjuwdCBQ0YyAHvzso26XLfjGZHe4dKB8KVADiIzbCmohxgF3DV8Mm3LzijAA5kwjk2+Hl6x7PY6rY9AEROGBMA==", "license": "MIT", "dependencies": { "ws": "^8.16.0" diff --git a/sdk/typescript/next-auth-examples/package.json b/sdk/typescript/next-auth-examples/package.json index 8365dd2..39040a1 100644 --- a/sdk/typescript/next-auth-examples/package.json +++ b/sdk/typescript/next-auth-examples/package.json @@ -11,7 +11,7 @@ "format": "biome format --write ." }, "dependencies": { - "@corti/sdk": "alpha", + "@corti/sdk": "^1.0.0-rc.4", "clsx": "^2.1.1", "next": "16.1.6", "react": "19.2.3",