@rover-studio/answer-me 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/answerme-toolkit.mjs +6 -0
  2. package/distribution/npm/migrations.json +605 -0
  3. package/distribution/npm/package-manifest.json +192 -0
  4. package/distribution/npm/skills/answerme/SKILL.md +94 -0
  5. package/distribution/npm/skills/answerme/agents/openai.yaml +4 -0
  6. package/distribution/npm/skills/answerme/references/api.md +135 -0
  7. package/distribution/npm/skills/answerme/references/creator-credential-deployment.md +19 -0
  8. package/distribution/npm/skills/answerme/references/creator-credential-recovery.md +24 -0
  9. package/distribution/npm/skills/answerme/references/errors.md +44 -0
  10. package/distribution/npm/skills/answerme/references/handoff.md +46 -0
  11. package/distribution/npm/skills/answerme/references/install-self-test.md +28 -0
  12. package/distribution/npm/skills/answerme/references/result-token-store.md +50 -0
  13. package/distribution/npm/skills/answerme/references/templates.md +139 -0
  14. package/distribution/npm/skills/answerme/scripts/answerme-api-base-url.ps1 +40 -0
  15. package/distribution/npm/skills/answerme/scripts/create-answerme.ps1 +1892 -0
  16. package/distribution/npm/skills/answerme/scripts/creator-credential-store.windows.ps1 +503 -0
  17. package/distribution/npm/skills/answerme/scripts/deploy-answerme-creator-credential.ps1 +447 -0
  18. package/distribution/npm/skills/answerme/scripts/enroll-answerme-creator.ps1 +764 -0
  19. package/distribution/npm/skills/answerme/scripts/open-answerme-page.windows.ps1 +272 -0
  20. package/distribution/npm/skills/answerme/scripts/remove-answerme-result-token.ps1 +63 -0
  21. package/distribution/npm/skills/answerme/scripts/result-token-store.windows.ps1 +261 -0
  22. package/distribution/npm/skills/answerme/scripts/test-answerme-installation.ps1 +498 -0
  23. package/distribution/npm/skills/answerme/scripts/wait-answerme-result.ps1 +908 -0
  24. package/distribution/npm/skills/answerme/scripts/windows-crypto.ps1 +57 -0
  25. package/distribution/npm/skills/answerme/scripts/windows-http.ps1 +45 -0
  26. package/distribution/npm/skills/answerme/scripts/windows-process-start-info.ps1 +76 -0
  27. package/distribution/npm/skills/ask-when-needed/SKILL.md +164 -0
  28. package/distribution/npm/skills/ask-when-needed/agents/openai.yaml +4 -0
  29. package/distribution/npm/skills/ask-when-needed/references/interview-strategies.md +43 -0
  30. package/lib/npm-cli/commands.mjs +247 -0
  31. package/lib/npm-cli/constants.mjs +51 -0
  32. package/lib/npm-cli/errors.mjs +15 -0
  33. package/lib/npm-cli/filesystem.mjs +193 -0
  34. package/lib/npm-cli/host-discovery.mjs +404 -0
  35. package/lib/npm-cli/main.mjs +42 -0
  36. package/lib/npm-cli/package-integrity.mjs +212 -0
  37. package/lib/npm-cli/transaction.mjs +375 -0
  38. package/lib/npm-cli/usage-validation.mjs +349 -0
  39. package/package.json +17 -0
@@ -0,0 +1,1892 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [string]$TemplateJson,
4
+ [string]$CompactQuestionnaireJson,
5
+ [string]$ApiBaseUrl,
6
+ [switch]$AllowInsecureLoopbackHttpForTest,
7
+ [string]$IdempotencyKey,
8
+ [string]$CreatorKeyEnvironmentVariable = 'ANSWERME_CREATOR_KEY',
9
+ [string]$CredentialPath = $env:ANSWERME_CREATOR_CREDENTIAL_PATH,
10
+ [string]$ResultTokenStoreRoot = $env:ANSWERME_RESULT_TOKEN_STORE_ROOT,
11
+ [switch]$AllowNonDefaultResultTokenStore,
12
+ [object]$WaitTimeoutSeconds = 300,
13
+ [object]$EnrollmentWaitTimeoutSeconds = 300,
14
+ [switch]$SkipPopup,
15
+ [switch]$ValidateOnly,
16
+ [switch]$ValidateCredentialOnly
17
+ )
18
+
19
+ $ErrorActionPreference = 'Stop'
20
+
21
+ $processStartInfoSupport = Join-Path $PSScriptRoot 'windows-process-start-info.ps1'
22
+ if (-not (Test-Path -LiteralPath $processStartInfoSupport -PathType Leaf)) {
23
+ throw 'The AnswerMe Windows process argument helper is missing.'
24
+ }
25
+ . $processStartInfoSupport
26
+
27
+ $cryptoSupport = Join-Path $PSScriptRoot 'windows-crypto.ps1'
28
+ if (-not (Test-Path -LiteralPath $cryptoSupport -PathType Leaf)) {
29
+ throw 'The AnswerMe Windows cryptography helper is missing.'
30
+ }
31
+ . $cryptoSupport
32
+
33
+ $httpSupport = Join-Path $PSScriptRoot 'windows-http.ps1'
34
+ if (-not (Test-Path -LiteralPath $httpSupport -PathType Leaf)) {
35
+ throw 'The AnswerMe Windows HTTP helper is missing.'
36
+ }
37
+ . $httpSupport
38
+
39
+ $AnswerMeProtocolVersion = '1.1'
40
+ $AnswerMeSkillVersion = '0.3.0'
41
+ $AnswerMeOperationStopwatch = [Diagnostics.Stopwatch]::StartNew()
42
+ $AnswerMeAutomaticEnrollmentAttempted = $false
43
+ $AnswerMeOriginalRequestRetried = $false
44
+ $AnswerMeTimings = [ordered]@{
45
+ inputValidationMilliseconds = 0L
46
+ configurationMilliseconds = 0L
47
+ capabilitiesMilliseconds = 0L
48
+ createRequestMilliseconds = 0L
49
+ responseValidationMilliseconds = 0L
50
+ tokenPersistenceMilliseconds = 0L
51
+ popupMilliseconds = 0L
52
+ }
53
+
54
+ function Get-AnswerMeTimings {
55
+ $snapshot = [ordered]@{}
56
+ foreach ($entry in $AnswerMeTimings.GetEnumerator()) {
57
+ $snapshot[$entry.Key] = [long]$entry.Value
58
+ }
59
+ $snapshot.totalMilliseconds = [long]$AnswerMeOperationStopwatch.ElapsedMilliseconds
60
+ return [PSCustomObject]$snapshot
61
+ }
62
+
63
+ function Write-AnswerMeFailure {
64
+ param(
65
+ [Parameter(Mandatory = $true)][string]$Code,
66
+ [Parameter(Mandatory = $true)][string]$Message,
67
+ [Parameter(Mandatory = $true)][int]$ExitCode,
68
+ [bool]$NetworkCalled = $false,
69
+ [AllowNull()][string]$Reason,
70
+ [int]$RetryAfterSeconds = 0,
71
+ [AllowNull()][object]$RecoveryAction,
72
+ [AllowNull()][object]$RecoveryReference,
73
+ [AllowNull()][string]$IdempotencyKey
74
+ )
75
+
76
+ $failure = [ordered]@{
77
+ ok = $false
78
+ status = 'failed'
79
+ stage = 'create'
80
+ code = $Code
81
+ message = $Message
82
+ reason = $Reason
83
+ retryAfterSeconds = $RetryAfterSeconds
84
+ recoveryAction = $RecoveryAction
85
+ recoveryReference = $RecoveryReference
86
+ timings = Get-AnswerMeTimings
87
+ networkCalled = $NetworkCalled
88
+ automaticEnrollmentAttempted = $AnswerMeAutomaticEnrollmentAttempted
89
+ originalRequestRetried = $AnswerMeOriginalRequestRetried
90
+ }
91
+ if (-not [string]::IsNullOrWhiteSpace($IdempotencyKey)) {
92
+ $failure.idempotencyKey = $IdempotencyKey
93
+ }
94
+ [PSCustomObject]$failure | ConvertTo-Json -Depth 4
95
+ exit $ExitCode
96
+ }
97
+
98
+ function Get-AnswerMeWaitDecision {
99
+ param([Parameter(Mandatory = $true)][object]$RequestedValue)
100
+
101
+ $text = [string]$RequestedValue
102
+ $integerValue = 0L
103
+ $numericValue = 0.0
104
+ if ([long]::TryParse(
105
+ $text,
106
+ [Globalization.NumberStyles]::Integer,
107
+ [Globalization.CultureInfo]::InvariantCulture,
108
+ [ref]$integerValue
109
+ )) {
110
+ $requested = $integerValue
111
+ $valid = $integerValue -eq 0 -or ($integerValue -ge 30 -and $integerValue -le 600)
112
+ }
113
+ elseif ([double]::TryParse(
114
+ $text,
115
+ [Globalization.NumberStyles]::Float,
116
+ [Globalization.CultureInfo]::InvariantCulture,
117
+ [ref]$numericValue
118
+ )) {
119
+ $requested = $numericValue
120
+ $valid = $false
121
+ }
122
+ else {
123
+ $requested = $text
124
+ $valid = $false
125
+ }
126
+
127
+ return [PSCustomObject]@{
128
+ requestedTimeoutSeconds = $requested
129
+ effectiveTimeoutSeconds = if ($valid) { [int]$integerValue } else { 0 }
130
+ reason = if ($valid) { $null } else { 'timeout_out_of_range' }
131
+ }
132
+ }
133
+
134
+ function ConvertTo-AnswerMeCanonicalJsonScalar {
135
+ param([AllowNull()][object]$Value)
136
+
137
+ return ConvertTo-Json -InputObject $Value -Compress -Depth 4
138
+ }
139
+
140
+ function Get-DefaultAnswerMeCredentialPath {
141
+ if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
142
+ return $null
143
+ }
144
+ $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
145
+ if ([string]::IsNullOrWhiteSpace($localAppData)) {
146
+ return $null
147
+ }
148
+ return Join-Path $localAppData 'AnswerMe\creator-credential.clixml'
149
+ }
150
+
151
+ function Assert-AnswerMeAllowedProperties {
152
+ param(
153
+ [Parameter(Mandatory = $true)][object]$Value,
154
+ [Parameter(Mandatory = $true)][string[]]$Allowed,
155
+ [Parameter(Mandatory = $true)][string]$Context
156
+ )
157
+
158
+ $unsupported = @($Value.PSObject.Properties.Name | Where-Object { $Allowed -cnotcontains $_ })
159
+ if ($unsupported.Count -gt 0) {
160
+ throw "$Context contains unsupported field '$($unsupported[0])'."
161
+ }
162
+ }
163
+
164
+ function Assert-AnswerMeJsonObject {
165
+ param(
166
+ [AllowNull()][object]$Value,
167
+ [Parameter(Mandatory = $true)][string]$Context
168
+ )
169
+
170
+ if ($Value -isnot [PSCustomObject]) {
171
+ throw "$Context must be a JSON object."
172
+ }
173
+ }
174
+
175
+ function Assert-AnswerMeJsonUnicodeWellFormed {
176
+ param([Parameter(Mandatory = $true)][string]$Json)
177
+
178
+ $insideString = $false
179
+ for ($index = 0; $index -lt $Json.Length; $index += 1) {
180
+ $current = $Json[$index]
181
+ if ([char]::IsHighSurrogate($current)) {
182
+ if ($index + 1 -ge $Json.Length -or -not [char]::IsLowSurrogate($Json[$index + 1])) {
183
+ throw 'JSON contains an unpaired Unicode surrogate.'
184
+ }
185
+ $index += 1
186
+ continue
187
+ }
188
+ if ([char]::IsLowSurrogate($current)) {
189
+ throw 'JSON contains an unpaired Unicode surrogate.'
190
+ }
191
+ if (-not $insideString) {
192
+ if ($current -ceq [char]'"') { $insideString = $true }
193
+ continue
194
+ }
195
+ if ($current -ceq [char]'"') {
196
+ $insideString = $false
197
+ continue
198
+ }
199
+ if ($current -cne [char]'\') { continue }
200
+ if ($index + 1 -ge $Json.Length) { return }
201
+
202
+ $escape = $Json[$index + 1]
203
+ if ($escape -cne [char]'u') {
204
+ $index += 1
205
+ continue
206
+ }
207
+ if ($index + 5 -ge $Json.Length) { return }
208
+
209
+ $hex = $Json.Substring($index + 2, 4)
210
+ $codeUnit = 0
211
+ if (-not [int]::TryParse(
212
+ $hex,
213
+ [Globalization.NumberStyles]::AllowHexSpecifier,
214
+ [Globalization.CultureInfo]::InvariantCulture,
215
+ [ref]$codeUnit
216
+ )) { return }
217
+ if ($codeUnit -ge 0xD800 -and $codeUnit -le 0xDBFF) {
218
+ if ($index + 11 -ge $Json.Length -or
219
+ $Json[$index + 6] -cne [char]'\' -or
220
+ $Json[$index + 7] -cne [char]'u') {
221
+ throw 'JSON contains an unpaired Unicode surrogate escape.'
222
+ }
223
+ $lowHex = $Json.Substring($index + 8, 4)
224
+ $lowCodeUnit = 0
225
+ if (-not [int]::TryParse(
226
+ $lowHex,
227
+ [Globalization.NumberStyles]::AllowHexSpecifier,
228
+ [Globalization.CultureInfo]::InvariantCulture,
229
+ [ref]$lowCodeUnit
230
+ ) -or $lowCodeUnit -lt 0xDC00 -or $lowCodeUnit -gt 0xDFFF) {
231
+ throw 'JSON contains an unpaired Unicode surrogate escape.'
232
+ }
233
+ $index += 11
234
+ continue
235
+ }
236
+ if ($codeUnit -ge 0xDC00 -and $codeUnit -le 0xDFFF) {
237
+ throw 'JSON contains an unpaired Unicode surrogate escape.'
238
+ }
239
+ $index += 5
240
+ }
241
+ }
242
+
243
+ function Get-AnswerMeProperty {
244
+ param(
245
+ [Parameter(Mandatory = $true)][object]$Value,
246
+ [Parameter(Mandatory = $true)][string]$Name,
247
+ [Parameter(Mandatory = $true)][string]$Context,
248
+ [switch]$Required
249
+ )
250
+
251
+ $properties = @($Value.PSObject.Properties | Where-Object { $_.Name -ceq $Name })
252
+ if ($properties.Count -gt 1) {
253
+ throw "$Context contains duplicate field '$Name'."
254
+ }
255
+ if ($properties.Count -eq 0) {
256
+ if ($Required) {
257
+ throw "$Context is missing required field '$Name'."
258
+ }
259
+ return $null
260
+ }
261
+ return $properties[0]
262
+ }
263
+
264
+ function Get-AnswerMeJsonArray {
265
+ param(
266
+ [AllowNull()][object]$Value,
267
+ [Parameter(Mandatory = $true)][string]$Context
268
+ )
269
+
270
+ if ($null -eq $Value -or $Value -isnot [Array]) {
271
+ throw "$Context must be a JSON array."
272
+ }
273
+ return [object[]]$Value
274
+ }
275
+
276
+ function Get-AnswerMeInteger {
277
+ param(
278
+ [AllowNull()][object]$Value,
279
+ [Parameter(Mandatory = $true)][string]$Context
280
+ )
281
+
282
+ if ($Value -isnot [int] -and $Value -isnot [long]) {
283
+ throw "$Context must be an integer."
284
+ }
285
+ return [long]$Value
286
+ }
287
+
288
+ function Get-AnswerMeUnicodeCodePointCount {
289
+ param(
290
+ [Parameter(Mandatory = $true)][string]$Value,
291
+ [Parameter(Mandatory = $true)][string]$Context
292
+ )
293
+
294
+ $count = 0
295
+ for ($index = 0; $index -lt $Value.Length; $index += 1) {
296
+ $current = $Value[$index]
297
+ if ([char]::IsHighSurrogate($current)) {
298
+ if ($index + 1 -ge $Value.Length -or -not [char]::IsLowSurrogate($Value[$index + 1])) {
299
+ throw "$Context contains an unpaired Unicode surrogate."
300
+ }
301
+ $index += 1
302
+ }
303
+ elseif ([char]::IsLowSurrogate($current)) {
304
+ throw "$Context contains an unpaired Unicode surrogate."
305
+ }
306
+ $count += 1
307
+ }
308
+ return $count
309
+ }
310
+
311
+ function Get-AnswerMeNormalizedString {
312
+ param(
313
+ [AllowNull()][object]$Value,
314
+ [Parameter(Mandatory = $true)][string]$Context,
315
+ [int]$MinimumCodePoints = 0,
316
+ [int]$MaximumCodePoints = [int]::MaxValue
317
+ )
318
+
319
+ if ($Value -isnot [string]) {
320
+ throw "$Context must be a string."
321
+ }
322
+ $trimmed = $Value.Trim()
323
+ $codePointCount = Get-AnswerMeUnicodeCodePointCount -Value $trimmed -Context $Context
324
+ if ($codePointCount -lt $MinimumCodePoints -or $codePointCount -gt $MaximumCodePoints) {
325
+ throw "$Context must contain between $MinimumCodePoints and $MaximumCodePoints Unicode code points."
326
+ }
327
+ return $trimmed
328
+ }
329
+
330
+ function Get-AnswerMeSubstringByCodePoints {
331
+ param(
332
+ [Parameter(Mandatory = $true)][string]$Value,
333
+ [Parameter(Mandatory = $true)][ValidateRange(1, [int]::MaxValue)][int]$MaximumCodePoints
334
+ )
335
+
336
+ $endIndex = 0
337
+ $codePointCount = 0
338
+ while ($endIndex -lt $Value.Length -and $codePointCount -lt $MaximumCodePoints) {
339
+ $current = $Value[$endIndex]
340
+ if ([char]::IsHighSurrogate($current)) {
341
+ if ($endIndex + 1 -ge $Value.Length -or -not [char]::IsLowSurrogate($Value[$endIndex + 1])) {
342
+ throw 'String contains an unpaired Unicode surrogate.'
343
+ }
344
+ $endIndex += 2
345
+ }
346
+ elseif ([char]::IsLowSurrogate($current)) {
347
+ throw 'String contains an unpaired Unicode surrogate.'
348
+ }
349
+ else {
350
+ $endIndex += 1
351
+ }
352
+ $codePointCount += 1
353
+ }
354
+ return $Value.Substring(0, $endIndex)
355
+ }
356
+
357
+ function Get-AnswerMeCompactShortLabel {
358
+ param([Parameter(Mandatory = $true)][string]$Title)
359
+
360
+ return Get-AnswerMeSubstringByCodePoints -Value $Title -MaximumCodePoints 20
361
+ }
362
+
363
+ function ConvertFrom-AnswerMeCompactQuestionnaire {
364
+ param([Parameter(Mandatory = $true)][object]$Compact)
365
+
366
+ Assert-AnswerMeJsonObject -Value $Compact -Context 'Compact questionnaire'
367
+ Assert-AnswerMeAllowedProperties `
368
+ -Value $Compact `
369
+ -Allowed @('topic', 'intro', 'questions', 'allowAdditionalContext', 'expiresInSeconds') `
370
+ -Context 'Compact questionnaire'
371
+
372
+ $topicProperty = Get-AnswerMeProperty -Value $Compact -Name 'topic' -Context 'Compact questionnaire' -Required
373
+ $topic = Get-AnswerMeNormalizedString -Value $topicProperty.Value -Context 'Compact questionnaire topic' -MinimumCodePoints 1 -MaximumCodePoints 100
374
+ $questionsProperty = Get-AnswerMeProperty -Value $Compact -Name 'questions' -Context 'Compact questionnaire' -Required
375
+ $compactQuestions = @(Get-AnswerMeJsonArray -Value $questionsProperty.Value -Context 'Compact questionnaire questions')
376
+ if ($compactQuestions.Count -lt 1 -or $compactQuestions.Count -gt 20) {
377
+ throw 'Compact questionnaire must contain between 1 and 20 questions.'
378
+ }
379
+
380
+ $intro = $null
381
+ $introProperty = Get-AnswerMeProperty -Value $Compact -Name 'intro' -Context 'Compact questionnaire'
382
+ if ($null -ne $introProperty) {
383
+ $intro = Get-AnswerMeNormalizedString -Value $introProperty.Value -Context 'Compact questionnaire intro' -MaximumCodePoints 1000
384
+ }
385
+ $allowAdditionalContext = $false
386
+ $additionalContextProperty = Get-AnswerMeProperty -Value $Compact -Name 'allowAdditionalContext' -Context 'Compact questionnaire'
387
+ if ($null -ne $additionalContextProperty) {
388
+ if ($additionalContextProperty.Value -isnot [bool]) {
389
+ throw 'Compact questionnaire allowAdditionalContext must be a boolean.'
390
+ }
391
+ $allowAdditionalContext = [bool]$additionalContextProperty.Value
392
+ }
393
+ $expiresInSeconds = 3600
394
+ $expiresProperty = Get-AnswerMeProperty -Value $Compact -Name 'expiresInSeconds' -Context 'Compact questionnaire'
395
+ if ($null -ne $expiresProperty) {
396
+ $expiresInSeconds = Get-AnswerMeInteger -Value $expiresProperty.Value -Context 'Compact questionnaire expiresInSeconds'
397
+ if ($expiresInSeconds -lt 1 -or $expiresInSeconds -gt 604800) {
398
+ throw 'Compact questionnaire expiresInSeconds must be between 1 and 604800.'
399
+ }
400
+ }
401
+
402
+ $expandedQuestions = @()
403
+ $schemaVersion = 1
404
+ for ($questionIndex = 0; $questionIndex -lt $compactQuestions.Count; $questionIndex += 1) {
405
+ $question = $compactQuestions[$questionIndex]
406
+ $questionNumber = $questionIndex + 1
407
+ $context = "Compact question $questionNumber"
408
+ Assert-AnswerMeJsonObject -Value $question -Context $context
409
+ Assert-AnswerMeAllowedProperties `
410
+ -Value $question `
411
+ -Allowed @('title', 'type', 'body', 'maxLength', 'options', 'minSelections', 'maxSelections') `
412
+ -Context $context
413
+
414
+ $titleProperty = Get-AnswerMeProperty -Value $question -Name 'title' -Context $context -Required
415
+ $title = Get-AnswerMeNormalizedString -Value $titleProperty.Value -Context "$context title" -MinimumCodePoints 1 -MaximumCodePoints 1000
416
+ $typeProperty = Get-AnswerMeProperty -Value $question -Name 'type' -Context $context -Required
417
+ $questionType = Get-AnswerMeNormalizedString -Value $typeProperty.Value -Context "$context type" -MinimumCodePoints 1 -MaximumCodePoints 32
418
+ if ($questionType -cnotin @('single-choice', 'multiple-choice', 'text')) {
419
+ throw "$context must use type=single-choice, type=multiple-choice or type=text."
420
+ }
421
+ $bodyProperty = Get-AnswerMeProperty -Value $question -Name 'body' -Context $context
422
+ if ($null -ne $bodyProperty -or $questionType -ceq 'text') { $schemaVersion = 2 }
423
+ if ($questionType -ceq 'text') {
424
+ Assert-AnswerMeAllowedProperties -Value $question -Allowed @('title', 'type', 'body', 'maxLength') -Context $context
425
+ $expandedText = [ordered]@{
426
+ id = "q$questionNumber"
427
+ type = 'text'
428
+ title = $title
429
+ shortLabel = Get-AnswerMeCompactShortLabel -Title $title
430
+ required = $true
431
+ }
432
+ if ($null -ne $bodyProperty) { $expandedText.body = $bodyProperty.Value }
433
+ $lengthProperty = Get-AnswerMeProperty -Value $question -Name 'maxLength' -Context $context
434
+ if ($null -ne $lengthProperty) { $expandedText.maxLength = $lengthProperty.Value }
435
+ $expandedQuestions += [PSCustomObject]$expandedText
436
+ continue
437
+ }
438
+ if ($null -ne (Get-AnswerMeProperty -Value $question -Name 'maxLength' -Context $context)) {
439
+ throw "$context choice questions forbid maxLength."
440
+ }
441
+ $optionsProperty = Get-AnswerMeProperty -Value $question -Name 'options' -Context $context -Required
442
+ $compactOptions = @(Get-AnswerMeJsonArray -Value $optionsProperty.Value -Context "$context options")
443
+ if ($compactOptions.Count -lt 2 -or $compactOptions.Count -gt 20) {
444
+ throw "$context must contain between 2 and 20 option labels."
445
+ }
446
+
447
+ $questionId = "q$questionNumber"
448
+ $expandedOptions = @()
449
+ for ($optionIndex = 0; $optionIndex -lt $compactOptions.Count; $optionIndex += 1) {
450
+ $optionLabel = Get-AnswerMeNormalizedString `
451
+ -Value $compactOptions[$optionIndex] `
452
+ -Context "$context option $($optionIndex + 1)" `
453
+ -MinimumCodePoints 1 `
454
+ -MaximumCodePoints 100
455
+ $expandedOptions += [PSCustomObject][ordered]@{
456
+ id = "$questionId-o$($optionIndex + 1)"
457
+ label = $optionLabel
458
+ }
459
+ }
460
+
461
+ if ($questionType -ceq 'single-choice') {
462
+ if ($null -ne (Get-AnswerMeProperty -Value $question -Name 'minSelections' -Context $context) -or
463
+ $null -ne (Get-AnswerMeProperty -Value $question -Name 'maxSelections' -Context $context)) {
464
+ throw "$context single-choice selection range is fixed by the adapter."
465
+ }
466
+ $minSelections = 1
467
+ $maxSelections = 1
468
+ }
469
+ else {
470
+ $minSelections = 1
471
+ $maxSelections = $compactOptions.Count
472
+ $minimumProperty = Get-AnswerMeProperty -Value $question -Name 'minSelections' -Context $context
473
+ if ($null -ne $minimumProperty) {
474
+ $minSelections = Get-AnswerMeInteger -Value $minimumProperty.Value -Context "$context minSelections"
475
+ }
476
+ $maximumProperty = Get-AnswerMeProperty -Value $question -Name 'maxSelections' -Context $context
477
+ if ($null -ne $maximumProperty) {
478
+ $maxSelections = Get-AnswerMeInteger -Value $maximumProperty.Value -Context "$context maxSelections"
479
+ }
480
+ }
481
+
482
+ $expandedQuestions += [PSCustomObject][ordered]@{
483
+ id = $questionId
484
+ type = $questionType
485
+ title = $title
486
+ shortLabel = Get-AnswerMeCompactShortLabel -Title $title
487
+ required = $true
488
+ minSelections = $minSelections
489
+ maxSelections = $maxSelections
490
+ options = $expandedOptions
491
+ allowCustomAnswer = $false
492
+ }
493
+ if ($null -ne $bodyProperty) { $expandedQuestions[-1] | Add-Member -NotePropertyName body -NotePropertyValue $bodyProperty.Value }
494
+ }
495
+
496
+ $expanded = [ordered]@{
497
+ schemaVersion = $schemaVersion
498
+ type = 'questionnaire'
499
+ topic = $topic
500
+ questions = $expandedQuestions
501
+ allowAdditionalContext = $allowAdditionalContext
502
+ expiresInSeconds = $expiresInSeconds
503
+ visual = [PSCustomObject]@{ theme = 'auto' }
504
+ }
505
+ if ($null -ne $intro -and $intro.Length -gt 0) {
506
+ $expanded.intro = $intro
507
+ }
508
+ return [PSCustomObject]$expanded
509
+ }
510
+
511
+ function Assert-AnswerMeTemplate {
512
+ param([Parameter(Mandatory = $true)][object]$Template)
513
+
514
+ Assert-AnswerMeJsonObject -Value $Template -Context 'Template'
515
+ Assert-AnswerMeAllowedProperties `
516
+ -Value $Template `
517
+ -Allowed @('schemaVersion', 'type', 'topic', 'intro', 'questions', 'allowAdditionalContext', 'expiresInSeconds', 'visual') `
518
+ -Context 'Template'
519
+
520
+ $schemaVersionProperty = Get-AnswerMeProperty -Value $Template -Name 'schemaVersion' -Context 'Template' -Required
521
+ $schemaVersion = Get-AnswerMeInteger -Value $schemaVersionProperty.Value -Context 'Template schemaVersion'
522
+ if ($schemaVersion -notin @(1, 2)) {
523
+ throw 'Template must use schemaVersion=1 or schemaVersion=2.'
524
+ }
525
+ $typeProperty = Get-AnswerMeProperty -Value $Template -Name 'type' -Context 'Template' -Required
526
+ $templateType = Get-AnswerMeNormalizedString -Value $typeProperty.Value -Context 'Template type' -MinimumCodePoints 1 -MaximumCodePoints 32
527
+ if ($templateType -cne 'questionnaire') {
528
+ throw 'Template must use type=questionnaire.'
529
+ }
530
+ $typeProperty.Value = $templateType
531
+ $topicProperty = Get-AnswerMeProperty -Value $Template -Name 'topic' -Context 'Template' -Required
532
+ $topicProperty.Value = Get-AnswerMeNormalizedString -Value $topicProperty.Value -Context 'Template topic' -MinimumCodePoints 1 -MaximumCodePoints 100
533
+
534
+ $introProperty = Get-AnswerMeProperty -Value $Template -Name 'intro' -Context 'Template'
535
+ if ($null -ne $introProperty) {
536
+ $introProperty.Value = Get-AnswerMeNormalizedString -Value $introProperty.Value -Context 'Template intro' -MaximumCodePoints 1000
537
+ }
538
+ $additionalContextProperty = Get-AnswerMeProperty -Value $Template -Name 'allowAdditionalContext' -Context 'Template'
539
+ if ($null -ne $additionalContextProperty -and $additionalContextProperty.Value -isnot [bool]) {
540
+ throw 'Template allowAdditionalContext must be a boolean.'
541
+ }
542
+ $expiresProperty = Get-AnswerMeProperty -Value $Template -Name 'expiresInSeconds' -Context 'Template'
543
+ if ($null -ne $expiresProperty) {
544
+ $expiresInSeconds = Get-AnswerMeInteger -Value $expiresProperty.Value -Context 'Template expiresInSeconds'
545
+ if ($expiresInSeconds -lt 1 -or $expiresInSeconds -gt 604800) {
546
+ throw 'Template expiresInSeconds must be between 1 and 604800.'
547
+ }
548
+ $expiresProperty.Value = $expiresInSeconds
549
+ }
550
+ $visualProperty = Get-AnswerMeProperty -Value $Template -Name 'visual' -Context 'Template' -Required
551
+ $visual = $visualProperty.Value
552
+ Assert-AnswerMeJsonObject -Value $visual -Context 'Template visual'
553
+ Assert-AnswerMeAllowedProperties -Value $visual -Allowed @('theme') -Context 'Template visual'
554
+ $themeProperty = Get-AnswerMeProperty -Value $visual -Name 'theme' -Context 'Template visual' -Required
555
+ $theme = Get-AnswerMeNormalizedString -Value $themeProperty.Value -Context 'Template visual theme' -MinimumCodePoints 1 -MaximumCodePoints 16
556
+ if ($theme -cne 'auto') {
557
+ throw 'Template visual theme must be auto.'
558
+ }
559
+ $themeProperty.Value = $theme
560
+
561
+ $questionsProperty = Get-AnswerMeProperty -Value $Template -Name 'questions' -Context 'Template' -Required
562
+ $questions = @(Get-AnswerMeJsonArray -Value $questionsProperty.Value -Context 'Template questions')
563
+ if ($questions.Count -lt 1 -or $questions.Count -gt 20) {
564
+ throw 'Template must contain between 1 and 20 questions.'
565
+ }
566
+ $questionIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
567
+ for ($questionIndex = 0; $questionIndex -lt $questions.Count; $questionIndex += 1) {
568
+ $question = $questions[$questionIndex]
569
+ $questionContext = "Question $($questionIndex + 1)"
570
+ Assert-AnswerMeJsonObject -Value $question -Context $questionContext
571
+ Assert-AnswerMeAllowedProperties `
572
+ -Value $question `
573
+ -Allowed @('id', 'type', 'title', 'shortLabel', 'required', 'body', 'maxLength', 'minSelections', 'maxSelections', 'options', 'allowCustomAnswer', 'customAnswerMaxLength') `
574
+ -Context $questionContext
575
+
576
+ $questionIdProperty = Get-AnswerMeProperty -Value $question -Name 'id' -Context $questionContext -Required
577
+ $questionId = Get-AnswerMeNormalizedString -Value $questionIdProperty.Value -Context "$questionContext id" -MinimumCodePoints 1
578
+ $questionIdProperty.Value = $questionId
579
+ if (-not $questionIds.Add($questionId)) {
580
+ throw "Duplicate question id '$questionId'."
581
+ }
582
+ $questionTypeProperty = Get-AnswerMeProperty -Value $question -Name 'type' -Context $questionContext -Required
583
+ $questionType = Get-AnswerMeNormalizedString -Value $questionTypeProperty.Value -Context "$questionContext type" -MinimumCodePoints 1 -MaximumCodePoints 32
584
+ if ($questionType -cnotin @('single-choice', 'multiple-choice', 'text')) {
585
+ throw "$questionContext must use type=single-choice, type=multiple-choice or type=text."
586
+ }
587
+ $bodyProperty = Get-AnswerMeProperty -Value $question -Name 'body' -Context $questionContext
588
+ $lengthProperty = Get-AnswerMeProperty -Value $question -Name 'maxLength' -Context $questionContext
589
+ if ($schemaVersion -eq 1 -and ($null -ne $bodyProperty -or $null -ne $lengthProperty -or $questionType -ceq 'text')) {
590
+ throw "$questionContext body, maxLength and text require schemaVersion=2."
591
+ }
592
+ if ($null -ne $bodyProperty) {
593
+ if ($bodyProperty.Value -isnot [string]) { throw "$questionContext body must be a string." }
594
+ if ($bodyProperty.Value.Length -gt 0 -and (Get-AnswerMeUnicodeCodePointCount -Value $bodyProperty.Value -Context "$questionContext body") -gt 10000) {
595
+ throw "$questionContext body must contain at most 10000 Unicode code points."
596
+ }
597
+ if ([string]::IsNullOrWhiteSpace($bodyProperty.Value)) { $question.PSObject.Properties.Remove('body') }
598
+ }
599
+ $questionTypeProperty.Value = $questionType
600
+ $titleProperty = Get-AnswerMeProperty -Value $question -Name 'title' -Context $questionContext -Required
601
+ $titleProperty.Value = Get-AnswerMeNormalizedString -Value $titleProperty.Value -Context "$questionContext title" -MinimumCodePoints 1 -MaximumCodePoints 1000
602
+ $shortLabelProperty = Get-AnswerMeProperty -Value $question -Name 'shortLabel' -Context $questionContext -Required
603
+ $shortLabelProperty.Value = Get-AnswerMeNormalizedString -Value $shortLabelProperty.Value -Context "$questionContext shortLabel" -MinimumCodePoints 1 -MaximumCodePoints 20
604
+ $requiredProperty = Get-AnswerMeProperty -Value $question -Name 'required' -Context $questionContext -Required
605
+ if ($requiredProperty.Value -isnot [bool]) {
606
+ throw "$questionContext required must be a boolean."
607
+ }
608
+ if ($questionType -ceq 'text') {
609
+ Assert-AnswerMeAllowedProperties -Value $question -Allowed @('id', 'type', 'title', 'shortLabel', 'required', 'body', 'maxLength') -Context $questionContext
610
+ $maxLength = 2000
611
+ if ($null -ne $lengthProperty) {
612
+ $maxLength = Get-AnswerMeInteger -Value $lengthProperty.Value -Context "$questionContext maxLength"
613
+ if ($maxLength -lt 1 -or $maxLength -gt 2000) { throw "$questionContext maxLength must be between 1 and 2000." }
614
+ $lengthProperty.Value = $maxLength
615
+ }
616
+ else { $question | Add-Member -NotePropertyName maxLength -NotePropertyValue $maxLength }
617
+ continue
618
+ }
619
+ if ($null -ne $lengthProperty) { throw "$questionContext choice questions forbid maxLength." }
620
+ $minimumProperty = Get-AnswerMeProperty -Value $question -Name 'minSelections' -Context $questionContext -Required
621
+ $minSelections = Get-AnswerMeInteger -Value $minimumProperty.Value -Context "$questionContext minSelections"
622
+ $minimumProperty.Value = $minSelections
623
+ $maximumProperty = Get-AnswerMeProperty -Value $question -Name 'maxSelections' -Context $questionContext -Required
624
+ $maxSelections = Get-AnswerMeInteger -Value $maximumProperty.Value -Context "$questionContext maxSelections"
625
+ $maximumProperty.Value = $maxSelections
626
+ $allowCustomAnswerProperty = Get-AnswerMeProperty -Value $question -Name 'allowCustomAnswer' -Context $questionContext -Required
627
+ if ($allowCustomAnswerProperty.Value -isnot [bool]) {
628
+ throw "$questionContext allowCustomAnswer must be a boolean."
629
+ }
630
+ $allowCustomAnswer = [bool]$allowCustomAnswerProperty.Value
631
+ $optionsProperty = Get-AnswerMeProperty -Value $question -Name 'options' -Context $questionContext -Required
632
+ $options = @(Get-AnswerMeJsonArray -Value $optionsProperty.Value -Context "$questionContext options")
633
+ if ($options.Count -lt 2 -or $options.Count -gt 20) {
634
+ throw "Question '$questionId' must contain between 2 and 20 options."
635
+ }
636
+ if ($questionType -ceq 'single-choice') {
637
+ if ($minSelections -ne 1 -or $maxSelections -ne 1) {
638
+ throw "Question '$questionId' must use minSelections=1 and maxSelections=1 for single-choice."
639
+ }
640
+ }
641
+ else {
642
+ $availableSelections = $options.Count + $(if ($allowCustomAnswer) { 1 } else { 0 })
643
+ if ($minSelections -lt 1 -or
644
+ $maxSelections -lt 2 -or
645
+ $minSelections -gt $maxSelections -or
646
+ $maxSelections -gt 20 -or
647
+ $maxSelections -gt $availableSelections) {
648
+ throw "Question '$questionId' has an invalid multiple-choice selection range."
649
+ }
650
+ }
651
+ $customAnswerLengthProperty = Get-AnswerMeProperty -Value $question -Name 'customAnswerMaxLength' -Context $questionContext
652
+ if ($null -ne $customAnswerLengthProperty) {
653
+ $customAnswerMaxLength = Get-AnswerMeInteger -Value $customAnswerLengthProperty.Value -Context "$questionContext customAnswerMaxLength"
654
+ if (-not $allowCustomAnswer -or $customAnswerMaxLength -lt 1 -or $customAnswerMaxLength -gt 2000) {
655
+ throw "Question '$questionId' has an invalid customAnswerMaxLength."
656
+ }
657
+ $customAnswerLengthProperty.Value = $customAnswerMaxLength
658
+ }
659
+
660
+ $optionIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
661
+ for ($optionIndex = 0; $optionIndex -lt $options.Count; $optionIndex += 1) {
662
+ $option = $options[$optionIndex]
663
+ $optionContext = "Question '$questionId' option $($optionIndex + 1)"
664
+ Assert-AnswerMeJsonObject -Value $option -Context $optionContext
665
+ Assert-AnswerMeAllowedProperties -Value $option -Allowed @('id', 'label', 'description') -Context $optionContext
666
+ $optionIdProperty = Get-AnswerMeProperty -Value $option -Name 'id' -Context $optionContext -Required
667
+ $optionId = Get-AnswerMeNormalizedString -Value $optionIdProperty.Value -Context "$optionContext id" -MinimumCodePoints 1
668
+ $optionIdProperty.Value = $optionId
669
+ if (-not $optionIds.Add($optionId)) {
670
+ throw "Duplicate option id '$optionId' in question '$questionId'."
671
+ }
672
+ $labelProperty = Get-AnswerMeProperty -Value $option -Name 'label' -Context $optionContext -Required
673
+ $labelProperty.Value = Get-AnswerMeNormalizedString -Value $labelProperty.Value -Context "$optionContext label" -MinimumCodePoints 1 -MaximumCodePoints 100
674
+ $descriptionProperty = Get-AnswerMeProperty -Value $option -Name 'description' -Context $optionContext
675
+ if ($null -ne $descriptionProperty) {
676
+ $descriptionProperty.Value = Get-AnswerMeNormalizedString -Value $descriptionProperty.Value -Context "$optionContext description" -MaximumCodePoints 500
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ function Get-AnswerMeIdempotencyKey {
683
+ param(
684
+ [AllowNull()][string]$Value,
685
+ [switch]$Generate
686
+ )
687
+
688
+ if (-not $Generate) {
689
+ if ($Value -cnotmatch '^[0-9a-f]{32}$') {
690
+ throw 'IdempotencyKey must be exactly 32 lowercase hexadecimal characters.'
691
+ }
692
+ return $Value
693
+ }
694
+
695
+ $randomBytes = New-AnswerMeCryptographicRandomBytes -Length 16
696
+ try {
697
+ return ConvertTo-AnswerMeLowerHex -Bytes $randomBytes
698
+ }
699
+ finally {
700
+ Clear-AnswerMeSensitiveByteArray -Bytes $randomBytes
701
+ }
702
+ }
703
+
704
+ function Get-AnswerMeRetryAfterSeconds {
705
+ param([Parameter(Mandatory = $true)][object]$Response)
706
+
707
+ $rawValue = [string]$Response.Headers['Retry-After']
708
+ $parsedValue = 0
709
+ if ([int]::TryParse($rawValue, [ref]$parsedValue) -and $parsedValue -ge 0) {
710
+ return $parsedValue
711
+ }
712
+ return 0
713
+ }
714
+
715
+ function Test-AnswerMeWriterAdmissionResponse {
716
+ param(
717
+ [Parameter(Mandatory = $true)][object]$Response,
718
+ [AllowNull()][object]$Body
719
+ )
720
+
721
+ return (Test-AnswerMeCreateErrorResponse -StatusCode ([int]$Response.StatusCode) -Body $Body) -and
722
+ $Body.code -ceq 'service_unavailable' -and
723
+ $Body.reason -cin @('writer_admission_full', 'writer_admission_timeout')
724
+ }
725
+
726
+ function ConvertFrom-AnswerMeResponseBody {
727
+ param([Parameter(Mandatory = $true)][object]$Response)
728
+
729
+ if ([string]::IsNullOrWhiteSpace([string]$Response.Content)) {
730
+ return $null
731
+ }
732
+ try {
733
+ if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) {
734
+ return $Response.Content | ConvertFrom-Json -DateKind String
735
+ }
736
+ return $Response.Content | ConvertFrom-Json
737
+ }
738
+ catch {
739
+ return $null
740
+ }
741
+ }
742
+
743
+ function Test-AnswerMeNoStoreResponse {
744
+ param([Parameter(Mandatory = $true)][object]$Response)
745
+
746
+ return ([string]$Response.Headers['Cache-Control']).Trim() -ceq 'no-store'
747
+ }
748
+
749
+ function Test-AnswerMeResponseFields {
750
+ param(
751
+ [AllowNull()][object]$Body,
752
+ [Parameter(Mandatory = $true)][string[]]$ExpectedFields
753
+ )
754
+
755
+ if ($null -eq $Body) { return $false }
756
+ $actual = @($Body.PSObject.Properties.Name | Sort-Object)
757
+ $expected = @($ExpectedFields | Sort-Object)
758
+ return $actual.Count -eq $expected.Count -and
759
+ -not (Compare-Object -ReferenceObject $expected -DifferenceObject $actual -CaseSensitive)
760
+ }
761
+
762
+ function Test-AnswerMeCreateErrorResponse {
763
+ param(
764
+ [Parameter(Mandatory = $true)][int]$StatusCode,
765
+ [AllowNull()][object]$Body
766
+ )
767
+
768
+ if ($Body -isnot [PSCustomObject]) { return $false }
769
+ $allowedFields = @('code', 'requestId', 'path', 'reason')
770
+ $actualFields = @($Body.PSObject.Properties.Name)
771
+ if ($actualFields.Count -lt 2 -or
772
+ @($actualFields | Where-Object { $allowedFields -cnotcontains $_ }).Count -gt 0 -or
773
+ @($actualFields | Where-Object { $_ -ceq 'code' }).Count -ne 1 -or
774
+ @($actualFields | Where-Object { $_ -ceq 'requestId' }).Count -ne 1 -or
775
+ $Body.code -isnot [string] -or
776
+ $Body.requestId -isnot [string] -or
777
+ [string]::IsNullOrWhiteSpace([string]$Body.requestId)) {
778
+ return $false
779
+ }
780
+ if ($null -ne $Body.PSObject.Properties['path'] -and
781
+ ($Body.path -isnot [string] -or [string]$Body.path -cne '/api/v1/interactions')) {
782
+ return $false
783
+ }
784
+ if ($null -ne $Body.PSObject.Properties['reason'] -and $Body.reason -isnot [string]) {
785
+ return $false
786
+ }
787
+
788
+ $reason = if ($null -ne $Body.PSObject.Properties['reason']) { [string]$Body.reason } else { $null }
789
+ switch ($StatusCode) {
790
+ 400 { return $Body.code -ceq 'invalid_request' -and $null -eq $reason }
791
+ 401 {
792
+ return $Body.code -ceq 'invalid_creator_credential' -and
793
+ $reason -cin @('invalid', 'idle_expired', 'max_lifetime_expired', 'abuse_revoked')
794
+ }
795
+ 404 { return $Body.code -ceq 'interaction_not_found' -and $null -eq $reason }
796
+ 405 { return $Body.code -ceq 'invalid_request' -and $null -eq $reason }
797
+ 409 { return $Body.code -ceq 'idempotency_conflict' -and $null -eq $reason }
798
+ 413 { return $Body.code -ceq 'invalid_request' -and $null -eq $reason }
799
+ 422 { return $Body.code -ceq 'definition_invalid' -and $null -eq $reason }
800
+ 429 {
801
+ return $Body.code -ceq 'rate_limited' -and
802
+ ($null -eq $reason -or $reason -cin @('creator_rolling_create_limit', 'temporary_credential_daily_limit'))
803
+ }
804
+ 503 {
805
+ return $Body.code -cin @('service_unavailable', 'creation_unavailable') -and
806
+ ($null -eq $reason -or $reason -cin @('writer_admission_full', 'writer_admission_timeout'))
807
+ }
808
+ default { return $false }
809
+ }
810
+ }
811
+
812
+ function Test-AnswerMeCreateResponse {
813
+ param(
814
+ [Parameter(Mandatory = $true)][object]$Response,
815
+ [AllowNull()][object]$Body
816
+ )
817
+
818
+ if (-not (Test-AnswerMeNoStoreResponse -Response $Response) -or $Body -isnot [PSCustomObject]) {
819
+ return $false
820
+ }
821
+ $requiredFields = @('interactionId', 'answerUrl', 'resultToken', 'status', 'expiresAt', 'requestId')
822
+ $allowedFields = @($requiredFields + @('idempotentReplay', 'waitDecision'))
823
+ $actualFields = @($Body.PSObject.Properties.Name)
824
+ if (@($actualFields | Where-Object { $allowedFields -cnotcontains $_ }).Count -gt 0) { return $false }
825
+ foreach ($requiredField in $requiredFields) {
826
+ if (@($actualFields | Where-Object { $_ -ceq $requiredField }).Count -ne 1) { return $false }
827
+ }
828
+ foreach ($textField in @('interactionId', 'answerUrl', 'resultToken', 'status', 'expiresAt', 'requestId')) {
829
+ if ($Body.$textField -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$Body.$textField)) {
830
+ return $false
831
+ }
832
+ }
833
+ if ([string]$Body.status -cne 'pending') { return $false }
834
+ if ($null -ne $Body.PSObject.Properties['idempotentReplay'] -and
835
+ ($Body.idempotentReplay -isnot [bool] -or $Body.idempotentReplay -ne $true)) {
836
+ return $false
837
+ }
838
+
839
+ $expiresAt = [string]$Body.expiresAt
840
+ $parsedExpiresAt = [DateTimeOffset]::MinValue
841
+ if ($expiresAt -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$' -or
842
+ -not [DateTimeOffset]::TryParse(
843
+ $expiresAt,
844
+ [Globalization.CultureInfo]::InvariantCulture,
845
+ [Globalization.DateTimeStyles]::RoundtripKind,
846
+ [ref]$parsedExpiresAt
847
+ )) {
848
+ return $false
849
+ }
850
+
851
+ $answerUri = $null
852
+ if (-not [Uri]::TryCreate([string]$Body.answerUrl, [UriKind]::Absolute, [ref]$answerUri) -or
853
+ -not [string]::IsNullOrEmpty($answerUri.UserInfo)) {
854
+ return $false
855
+ }
856
+ if ($answerUri.Scheme -ceq 'https') { return $true }
857
+ if (-not $AllowInsecureLoopbackHttpForTest -or $answerUri.Scheme -cne 'http') { return $false }
858
+ return $answerUri.DnsSafeHost -cin @('127.0.0.1', 'localhost', '::1')
859
+ }
860
+
861
+ function Test-AnswerMeCapabilitiesResponse {
862
+ param([AllowNull()][object]$Body, [int]$SchemaVersion = 1)
863
+
864
+ if ($Body -isnot [PSCustomObject] -or
865
+ -not (Test-AnswerMeResponseFields `
866
+ -Body $Body `
867
+ -ExpectedFields @('protocolVersion', 'schemaVersions', 'clientProtocol', 'capabilities', 'requestId'))) {
868
+ return $false
869
+ }
870
+ if ($Body.protocolVersion -isnot [string] -or
871
+ [string]$Body.protocolVersion -cne $AnswerMeProtocolVersion -or
872
+ $Body.requestId -isnot [string] -or
873
+ [string]::IsNullOrWhiteSpace([string]$Body.requestId) -or
874
+ $Body.schemaVersions -isnot [Array] -or
875
+ @($Body.schemaVersions).Count -ne $SchemaVersion -or
876
+ @($Body.schemaVersions | Where-Object { ($_ -isnot [int] -and $_ -isnot [long]) -or $_ -notin @(1, 2) }).Count -gt 0 -or
877
+ $Body.schemaVersions -notcontains 1 -or
878
+ ($SchemaVersion -eq 2 -and $Body.schemaVersions -notcontains 2)) {
879
+ return $false
880
+ }
881
+
882
+ $clientProtocol = $Body.clientProtocol
883
+ if ($clientProtocol -isnot [PSCustomObject] -or
884
+ -not (Test-AnswerMeResponseFields `
885
+ -Body $clientProtocol `
886
+ -ExpectedFields @('supportedVersions', 'recommendedVersion', 'minimumCompatibleSkillVersion')) -or
887
+ $clientProtocol.recommendedVersion -isnot [string] -or
888
+ [string]$clientProtocol.recommendedVersion -cne $AnswerMeProtocolVersion -or
889
+ $clientProtocol.minimumCompatibleSkillVersion -isnot [string] -or
890
+ [string]$clientProtocol.minimumCompatibleSkillVersion -cne '0.1.0' -or
891
+ $clientProtocol.supportedVersions -isnot [Array]) {
892
+ return $false
893
+ }
894
+ $supportedVersions = @($clientProtocol.supportedVersions)
895
+ if ($supportedVersions.Count -ne 2 -or
896
+ $supportedVersions[0] -isnot [string] -or
897
+ $supportedVersions[1] -isnot [string] -or
898
+ $supportedVersions -cnotcontains '1.0' -or
899
+ $supportedVersions -cnotcontains $AnswerMeProtocolVersion) {
900
+ return $false
901
+ }
902
+
903
+ $capabilities = $Body.capabilities
904
+ if ($capabilities -isnot [PSCustomObject] -or
905
+ -not (Test-AnswerMeResponseFields `
906
+ -Body $capabilities `
907
+ -ExpectedFields @('createInteraction', 'resultQuery', 'resultWait')) -or
908
+ $capabilities.createInteraction -isnot [bool] -or
909
+ $capabilities.createInteraction -ne $true -or
910
+ $capabilities.resultQuery -isnot [bool]) {
911
+ return $false
912
+ }
913
+ $resultWait = $capabilities.resultWait
914
+ return $resultWait -is [PSCustomObject] -and
915
+ (Test-AnswerMeResponseFields `
916
+ -Body $resultWait `
917
+ -ExpectedFields @('enabled', 'defaultWaitSeconds', 'maxWaitSeconds')) -and
918
+ $resultWait.enabled -is [bool] -and
919
+ ($resultWait.defaultWaitSeconds -is [int] -or $resultWait.defaultWaitSeconds -is [long]) -and
920
+ [long]$resultWait.defaultWaitSeconds -eq 55 -and
921
+ ($resultWait.maxWaitSeconds -is [int] -or $resultWait.maxWaitSeconds -is [long]) -and
922
+ [long]$resultWait.maxWaitSeconds -eq 600
923
+ }
924
+
925
+ function Test-AnswerMeIncompatibleCapabilitiesResponse {
926
+ param([AllowNull()][object]$Body)
927
+
928
+ if ($Body -isnot [PSCustomObject] -or
929
+ -not (Test-AnswerMeResponseFields `
930
+ -Body $Body `
931
+ -ExpectedFields @('protocolVersion', 'schemaVersions', 'clientProtocol', 'capabilities', 'requestId')) -or
932
+ $Body.protocolVersion -isnot [string] -or
933
+ [string]$Body.protocolVersion -cne $AnswerMeProtocolVersion -or
934
+ $Body.requestId -isnot [string] -or
935
+ [string]::IsNullOrWhiteSpace([string]$Body.requestId) -or
936
+ $Body.schemaVersions -isnot [Array] -or
937
+ @($Body.schemaVersions).Count -ne 1 -or
938
+ ($Body.schemaVersions[0] -isnot [int] -and $Body.schemaVersions[0] -isnot [long]) -or
939
+ [long]$Body.schemaVersions[0] -ne 1) {
940
+ return $false
941
+ }
942
+ $clientProtocol = $Body.clientProtocol
943
+ if ($clientProtocol -isnot [PSCustomObject] -or
944
+ -not (Test-AnswerMeResponseFields `
945
+ -Body $clientProtocol `
946
+ -ExpectedFields @('supportedVersions', 'recommendedVersion', 'minimumCompatibleSkillVersion')) -or
947
+ $clientProtocol.recommendedVersion -isnot [string] -or
948
+ [string]$clientProtocol.recommendedVersion -cne $AnswerMeProtocolVersion -or
949
+ $clientProtocol.minimumCompatibleSkillVersion -isnot [string] -or
950
+ [string]::IsNullOrWhiteSpace([string]$clientProtocol.minimumCompatibleSkillVersion) -or
951
+ $clientProtocol.supportedVersions -isnot [Array]) {
952
+ return $false
953
+ }
954
+ $supportedVersions = @($clientProtocol.supportedVersions)
955
+ if ($supportedVersions.Count -lt 1 -or
956
+ @($supportedVersions | Where-Object { $_ -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$_) }).Count -gt 0 -or
957
+ $supportedVersions -ccontains $AnswerMeProtocolVersion) {
958
+ return $false
959
+ }
960
+ $capabilities = $Body.capabilities
961
+ if ($capabilities -isnot [PSCustomObject] -or
962
+ -not (Test-AnswerMeResponseFields `
963
+ -Body $capabilities `
964
+ -ExpectedFields @('createInteraction', 'resultQuery', 'resultWait')) -or
965
+ $capabilities.createInteraction -isnot [bool] -or
966
+ $capabilities.resultQuery -isnot [bool]) {
967
+ return $false
968
+ }
969
+ $resultWait = $capabilities.resultWait
970
+ return $resultWait -is [PSCustomObject] -and
971
+ (Test-AnswerMeResponseFields `
972
+ -Body $resultWait `
973
+ -ExpectedFields @('enabled', 'defaultWaitSeconds', 'maxWaitSeconds')) -and
974
+ $resultWait.enabled -is [bool] -and
975
+ ($resultWait.defaultWaitSeconds -is [int] -or $resultWait.defaultWaitSeconds -is [long]) -and
976
+ [long]$resultWait.defaultWaitSeconds -eq 55 -and
977
+ ($resultWait.maxWaitSeconds -is [int] -or $resultWait.maxWaitSeconds -is [long]) -and
978
+ [long]$resultWait.maxWaitSeconds -eq 600
979
+ }
980
+
981
+ function Test-AnswerMeProtocolUnsupportedErrorResponse {
982
+ param([AllowNull()][object]$Body)
983
+
984
+ if ($Body -isnot [PSCustomObject] -or
985
+ -not (Test-AnswerMeResponseFields `
986
+ -Body $Body `
987
+ -ExpectedFields @(
988
+ 'code',
989
+ 'requestId',
990
+ 'reason',
991
+ 'receivedProtocolVersion',
992
+ 'supportedProtocolVersions',
993
+ 'minimumCompatibleSkillVersion'
994
+ )) -or
995
+ $Body.code -isnot [string] -or
996
+ [string]$Body.code -cne 'client_protocol_unsupported' -or
997
+ $Body.reason -isnot [string] -or
998
+ [string]$Body.reason -cne 'answerme_client_protocol_version_is_not_supported' -or
999
+ $Body.requestId -isnot [string] -or
1000
+ [string]::IsNullOrWhiteSpace([string]$Body.requestId) -or
1001
+ $Body.receivedProtocolVersion -isnot [string] -or
1002
+ [string]::IsNullOrWhiteSpace([string]$Body.receivedProtocolVersion) -or
1003
+ $Body.minimumCompatibleSkillVersion -isnot [string] -or
1004
+ [string]::IsNullOrWhiteSpace([string]$Body.minimumCompatibleSkillVersion) -or
1005
+ $Body.supportedProtocolVersions -isnot [Array]) {
1006
+ return $false
1007
+ }
1008
+ $supportedVersions = @($Body.supportedProtocolVersions)
1009
+ return $supportedVersions.Count -gt 0 -and
1010
+ @($supportedVersions | Where-Object { $_ -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$_) }).Count -eq 0
1011
+ }
1012
+
1013
+ function Test-AnswerMeEnrollmentProgressEvent {
1014
+ param(
1015
+ [Parameter(Mandatory = $true)][string]$Line,
1016
+ [Parameter(Mandatory = $true)][Uri]$ResolvedApiBaseUrl
1017
+ )
1018
+
1019
+ try {
1020
+ $eventBody = $Line | ConvertFrom-Json
1021
+ }
1022
+ catch {
1023
+ return $false
1024
+ }
1025
+ if (-not (Test-AnswerMeResponseFields `
1026
+ -Body $eventBody `
1027
+ -ExpectedFields @('event', 'stage', 'verificationUrl')) -or
1028
+ $eventBody.event -isnot [string] -or
1029
+ [string]$eventBody.event -cne 'answerme.creator-enrollment.verification-url' -or
1030
+ $eventBody.stage -isnot [string] -or
1031
+ [string]$eventBody.stage -cne 'verification-handoff' -or
1032
+ $eventBody.verificationUrl -isnot [string]) {
1033
+ return $false
1034
+ }
1035
+
1036
+ $eventUri = $null
1037
+ return [Uri]::TryCreate([string]$eventBody.verificationUrl, [UriKind]::Absolute, [ref]$eventUri) -and
1038
+ $eventUri.Scheme.Equals($ResolvedApiBaseUrl.Scheme, [StringComparison]::OrdinalIgnoreCase) -and
1039
+ $eventUri.DnsSafeHost.Equals($ResolvedApiBaseUrl.DnsSafeHost, [StringComparison]::OrdinalIgnoreCase) -and
1040
+ $eventUri.Port -eq $ResolvedApiBaseUrl.Port -and
1041
+ [string]::IsNullOrEmpty($eventUri.UserInfo) -and
1042
+ [string]::IsNullOrEmpty($eventUri.Fragment)
1043
+ }
1044
+
1045
+ function Invoke-AnswerMeEnrollmentChildProcess {
1046
+ param(
1047
+ [Parameter(Mandatory = $true)][string]$PowerShellPath,
1048
+ [Parameter(Mandatory = $true)][string[]]$Arguments,
1049
+ [Parameter(Mandatory = $true)][Uri]$ResolvedApiBaseUrl
1050
+ )
1051
+
1052
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
1053
+ $startInfo.FileName = $PowerShellPath
1054
+ $startInfo.UseShellExecute = $false
1055
+ $startInfo.CreateNoWindow = $true
1056
+ $startInfo.RedirectStandardOutput = $true
1057
+ $startInfo.RedirectStandardError = $true
1058
+ $startInfo.RedirectStandardInput = $true
1059
+ $acknowledgementToken = [Guid]::NewGuid().ToString('N')
1060
+ Set-AnswerMeProcessEnvironmentVariable `
1061
+ -StartInfo $startInfo `
1062
+ -Name 'ANSWERME_INTERNAL_VERIFICATION_EVENT_ACK' `
1063
+ -Value $acknowledgementToken
1064
+ Set-AnswerMeProcessArguments -StartInfo $startInfo -Arguments $Arguments
1065
+
1066
+ $process = [Diagnostics.Process]::new()
1067
+ $process.StartInfo = $startInfo
1068
+ try {
1069
+ if (-not $process.Start()) { throw 'Enrollment client process could not be started.' }
1070
+ $stdoutTask = $process.StandardOutput.ReadToEndAsync()
1071
+ $invalidStderr = $false
1072
+ $progressEventCount = 0
1073
+ while ($null -ne ($line = $process.StandardError.ReadLine())) {
1074
+ if (Test-AnswerMeEnrollmentProgressEvent -Line $line -ResolvedApiBaseUrl $ResolvedApiBaseUrl) {
1075
+ $progressEventCount += 1
1076
+ [Console]::Error.WriteLine($line)
1077
+ [Console]::Error.Flush()
1078
+ $selfTestAcknowledgementToken = [Environment]::GetEnvironmentVariable(
1079
+ 'ANSWERME_INTERNAL_SELFTEST_CREATE_EVENT_ACK',
1080
+ 'Process'
1081
+ )
1082
+ if (-not [string]::IsNullOrWhiteSpace($selfTestAcknowledgementToken)) {
1083
+ $selfTestAcknowledgement = [Console]::In.ReadLine()
1084
+ if ($selfTestAcknowledgement -cne $selfTestAcknowledgementToken) {
1085
+ $invalidStderr = $true
1086
+ $process.StandardInput.WriteLine('invalid-event')
1087
+ $process.StandardInput.Flush()
1088
+ continue
1089
+ }
1090
+ }
1091
+ $process.StandardInput.WriteLine($acknowledgementToken)
1092
+ $process.StandardInput.Flush()
1093
+ }
1094
+ else {
1095
+ $invalidStderr = $true
1096
+ $process.StandardInput.WriteLine('invalid-event')
1097
+ $process.StandardInput.Flush()
1098
+ }
1099
+ }
1100
+ $process.WaitForExit()
1101
+ return [PSCustomObject]@{
1102
+ stdout = $stdoutTask.GetAwaiter().GetResult()
1103
+ exitCode = $process.ExitCode
1104
+ invalidStderr = $invalidStderr
1105
+ progressEventCount = $progressEventCount
1106
+ }
1107
+ }
1108
+ finally {
1109
+ $process.Dispose()
1110
+ }
1111
+ }
1112
+
1113
+ function Test-AnswerMeRecoverableCredentialReason {
1114
+ param([AllowNull()][string]$Reason)
1115
+
1116
+ return $Reason -cin @('invalid', 'idle_expired', 'max_lifetime_expired', 'abuse_revoked')
1117
+ }
1118
+
1119
+ function Invoke-AnswerMeAutomaticEnrollment {
1120
+ param(
1121
+ [Parameter(Mandatory = $true)][Uri]$ResolvedApiBaseUrl,
1122
+ [Parameter(Mandatory = $true)][string]$ResolvedCredentialPath
1123
+ )
1124
+
1125
+ if ($AnswerMeAutomaticEnrollmentAttempted) {
1126
+ Write-AnswerMeFailure `
1127
+ -Code 'automatic-enrollment-exhausted' `
1128
+ -Message 'This operation has already attempted its one automatic Creator enrollment.' `
1129
+ -ExitCode 4 `
1130
+ -NetworkCalled $true
1131
+ }
1132
+ $script:AnswerMeAutomaticEnrollmentAttempted = $true
1133
+
1134
+ $enrollmentScript = Join-Path $PSScriptRoot 'enroll-answerme-creator.ps1'
1135
+ if (-not (Test-Path -LiteralPath $enrollmentScript -PathType Leaf)) {
1136
+ Write-AnswerMeFailure `
1137
+ -Code 'enrollment-client-missing' `
1138
+ -Message 'The AnswerMe Creator enrollment client is required for automatic recovery.' `
1139
+ -ExitCode 3
1140
+ }
1141
+
1142
+ $arguments = @(
1143
+ '-NoLogo',
1144
+ '-NoProfile',
1145
+ '-File', $enrollmentScript,
1146
+ '-ApiBaseUrl', $ResolvedApiBaseUrl.AbsoluteUri.TrimEnd('/'),
1147
+ '-CredentialPath', $ResolvedCredentialPath,
1148
+ '-WaitTimeoutSeconds', [string]$localEnrollmentWaitDecision.effectiveTimeoutSeconds
1149
+ )
1150
+ if ($AllowInsecureLoopbackHttpForTest) { $arguments += '-AllowInsecureLoopbackHttpForTest' }
1151
+ if ($SkipPopup) { $arguments += '-SkipPopup' }
1152
+
1153
+ $pwshPath = (Get-Process -Id $PID).Path
1154
+ try {
1155
+ $childResult = Invoke-AnswerMeEnrollmentChildProcess `
1156
+ -PowerShellPath $pwshPath `
1157
+ -Arguments $arguments `
1158
+ -ResolvedApiBaseUrl $ResolvedApiBaseUrl
1159
+ }
1160
+ catch {
1161
+ Write-AnswerMeFailure `
1162
+ -Code 'automatic-enrollment-process-failed' `
1163
+ -Message 'The automatic Creator enrollment process could not be executed.' `
1164
+ -ExitCode 4 `
1165
+ -NetworkCalled $true
1166
+ }
1167
+ if ($childResult.invalidStderr -or $childResult.progressEventCount -gt 1) {
1168
+ Write-AnswerMeFailure `
1169
+ -Code 'automatic-enrollment-event-invalid' `
1170
+ -Message 'Automatic Creator enrollment emitted an invalid progress event.' `
1171
+ -ExitCode 4 `
1172
+ -NetworkCalled $true
1173
+ }
1174
+ $enrollmentExitCode = [int]$childResult.exitCode
1175
+ $enrollmentText = ([string]$childResult.stdout).Trim()
1176
+ $enrollment = $null
1177
+ if (-not [string]::IsNullOrWhiteSpace($enrollmentText)) {
1178
+ try {
1179
+ $enrollment = $enrollmentText | ConvertFrom-Json
1180
+ }
1181
+ catch {
1182
+ $enrollment = $null
1183
+ }
1184
+ }
1185
+ if ($enrollmentExitCode -ne 0 -or $null -eq $enrollment -or $enrollment.ok -ne $true) {
1186
+ Write-AnswerMeFailure `
1187
+ -Code $(if ($null -ne $enrollment -and $enrollment.code) { [string]$enrollment.code } else { 'automatic-enrollment-failed' }) `
1188
+ -Message 'Automatic Creator enrollment did not configure a protected credential.' `
1189
+ -ExitCode $(if ($enrollmentExitCode -gt 0) { [int]$enrollmentExitCode } else { 4 }) `
1190
+ -NetworkCalled $(if ($null -ne $enrollment) { $enrollment.networkCalled -eq $true } else { $true }) `
1191
+ -Reason $(if ($null -ne $enrollment) { [string]$enrollment.reason } else { $null }) `
1192
+ -RetryAfterSeconds $(if ($null -ne $enrollment) { [int]$enrollment.retryAfterSeconds } else { 0 })
1193
+ }
1194
+ $foregroundVerified = $null -ne $enrollment.verificationDelivery -and
1195
+ $enrollment.verificationDelivery.foregroundVerified -eq $true
1196
+ $expectedEventCount = if ($foregroundVerified) { 0 } else { 1 }
1197
+ if ($childResult.progressEventCount -ne $expectedEventCount) {
1198
+ Write-AnswerMeFailure `
1199
+ -Code 'automatic-enrollment-event-missing' `
1200
+ -Message 'Automatic Creator enrollment did not deliver its required verification URL event.' `
1201
+ -ExitCode 4 `
1202
+ -NetworkCalled $true
1203
+ }
1204
+
1205
+ try {
1206
+ $recoveredCredential = Import-Clixml -LiteralPath $ResolvedCredentialPath
1207
+ if ($recoveredCredential -isnot [PSCredential]) {
1208
+ throw 'Protected Creator credential did not contain a PSCredential.'
1209
+ }
1210
+ $recoveredBaseUrl = Resolve-AnswerMeApiBaseUrl `
1211
+ -ApiBaseUrl $recoveredCredential.UserName `
1212
+ -AllowInsecureLoopbackHttpForTest:$AllowInsecureLoopbackHttpForTest
1213
+ if ($recoveredBaseUrl.AbsoluteUri.TrimEnd('/') -cne $ResolvedApiBaseUrl.AbsoluteUri.TrimEnd('/')) {
1214
+ throw 'Protected Creator credential does not match the requested API origin.'
1215
+ }
1216
+ $recoveredKey = $recoveredCredential.GetNetworkCredential().Password
1217
+ if ([string]::IsNullOrWhiteSpace($recoveredKey)) {
1218
+ throw 'Protected Creator credential did not contain a Creator Key.'
1219
+ }
1220
+ return [PSCustomObject]@{
1221
+ creatorKey = $recoveredKey
1222
+ credentialSource = 'encrypted-file-recovered'
1223
+ }
1224
+ }
1225
+ catch {
1226
+ Write-AnswerMeFailure `
1227
+ -Code 'credential-recovery-readback-failed' `
1228
+ -Message 'The enrolled Creator credential could not be read back from protected storage.' `
1229
+ -ExitCode 5 `
1230
+ -NetworkCalled $true
1231
+ }
1232
+ finally {
1233
+ $recoveredCredential = $null
1234
+ $recoveredKey = $null
1235
+ }
1236
+ }
1237
+
1238
+ function Get-AnswerMeCredentialVerification {
1239
+ param(
1240
+ [Parameter(Mandatory = $true)][Uri]$ResolvedApiBaseUrl,
1241
+ [Parameter(Mandatory = $true)][string]$CreatorKey
1242
+ )
1243
+
1244
+ $verificationUri = ([Uri]::new($ResolvedApiBaseUrl, '/api/v1/creator-credentials/verify')).AbsoluteUri
1245
+ $verificationHeaders = @{
1246
+ Accept = 'application/json'
1247
+ Authorization = "Bearer $CreatorKey"
1248
+ 'X-AnswerMe-Protocol-Version' = $AnswerMeProtocolVersion
1249
+ 'X-AnswerMe-Skill-Version' = $AnswerMeSkillVersion
1250
+ }
1251
+ try {
1252
+ $verificationResponse = Invoke-AnswerMeWebRequest `
1253
+ -Uri $verificationUri `
1254
+ -Method Get `
1255
+ -Headers $verificationHeaders `
1256
+ -TimeoutSec 15
1257
+ }
1258
+ catch {
1259
+ Write-AnswerMeFailure `
1260
+ -Code 'credential-verification-unavailable' `
1261
+ -Message 'The Creator credential could not be verified safely.' `
1262
+ -ExitCode 4 `
1263
+ -NetworkCalled $true
1264
+ }
1265
+ $verificationBody = ConvertFrom-AnswerMeResponseBody -Response $verificationResponse
1266
+ if ([int]$verificationResponse.StatusCode -ne 200) {
1267
+ if ([int]$verificationResponse.StatusCode -eq 503 -and
1268
+ (Test-AnswerMeNoStoreResponse -Response $verificationResponse) -and
1269
+ (Test-AnswerMeResponseFields -Body $verificationBody -ExpectedFields @('code', 'requestId')) -and
1270
+ $verificationBody.code -is [string] -and
1271
+ [string]$verificationBody.code -ceq 'service_unavailable' -and
1272
+ $verificationBody.requestId -is [string] -and
1273
+ -not [string]::IsNullOrWhiteSpace([string]$verificationBody.requestId)) {
1274
+ Write-AnswerMeFailure `
1275
+ -Code 'service_unavailable' `
1276
+ -Message 'The Creator credential verification service is unavailable.' `
1277
+ -ExitCode 4 `
1278
+ -NetworkCalled $true
1279
+ }
1280
+ Write-AnswerMeFailure `
1281
+ -Code 'credential-verification-response-invalid' `
1282
+ -Message 'The Creator credential verification service returned an invalid failure response.' `
1283
+ -ExitCode 4 `
1284
+ -NetworkCalled $true
1285
+ }
1286
+ if (-not (Test-AnswerMeNoStoreResponse -Response $verificationResponse) -or $null -eq $verificationBody) {
1287
+ Write-AnswerMeFailure `
1288
+ -Code 'credential-verification-response-invalid' `
1289
+ -Message 'Creator credential verification omitted its no-store contract or response body.' `
1290
+ -ExitCode 4 `
1291
+ -NetworkCalled $true
1292
+ }
1293
+ if ($verificationBody.valid -is [bool] -and
1294
+ $verificationBody.valid -eq $true -and
1295
+ (Test-AnswerMeResponseFields -Body $verificationBody -ExpectedFields @('valid', 'creatorId', 'reason', 'requestId')) -and
1296
+ $verificationBody.creatorId -is [string] -and
1297
+ $verificationBody.reason -is [string] -and
1298
+ $verificationBody.requestId -is [string] -and
1299
+ $verificationBody.reason -ceq 'active' -and
1300
+ -not [string]::IsNullOrWhiteSpace($verificationBody.creatorId) -and
1301
+ -not [string]::IsNullOrWhiteSpace($verificationBody.requestId)) {
1302
+ return [PSCustomObject]@{ valid = $true; reason = 'active' }
1303
+ }
1304
+ if ($verificationBody.valid -is [bool] -and
1305
+ $verificationBody.valid -eq $false -and
1306
+ (Test-AnswerMeResponseFields -Body $verificationBody -ExpectedFields @('valid', 'reason', 'requestId')) -and
1307
+ $verificationBody.reason -is [string] -and
1308
+ $verificationBody.requestId -is [string] -and
1309
+ (Test-AnswerMeRecoverableCredentialReason -Reason $verificationBody.reason) -and
1310
+ -not [string]::IsNullOrWhiteSpace($verificationBody.requestId)) {
1311
+ return [PSCustomObject]@{ valid = $false; reason = $verificationBody.reason }
1312
+ }
1313
+ Write-AnswerMeFailure `
1314
+ -Code 'credential-verification-response-invalid' `
1315
+ -Message 'Creator credential verification returned an unknown state.' `
1316
+ -ExitCode 4 `
1317
+ -NetworkCalled $true
1318
+ }
1319
+
1320
+ $apiBaseUrlPolicyPath = Join-Path $PSScriptRoot 'answerme-api-base-url.ps1'
1321
+ if (-not (Test-Path -LiteralPath $apiBaseUrlPolicyPath -PathType Leaf)) {
1322
+ Write-AnswerMeFailure -Code 'api-base-url-policy-missing' -Message 'The AnswerMe API base URL policy is required.' -ExitCode 3
1323
+ }
1324
+ try {
1325
+ . $apiBaseUrlPolicyPath
1326
+ }
1327
+ catch {
1328
+ Write-AnswerMeFailure -Code 'api-base-url-policy-unavailable' -Message 'The AnswerMe API base URL policy could not be loaded.' -ExitCode 3
1329
+ }
1330
+
1331
+ $inputStopwatch = [Diagnostics.Stopwatch]::StartNew()
1332
+ try {
1333
+ $hasTemplateJson = -not [string]::IsNullOrWhiteSpace($TemplateJson)
1334
+ $hasCompactJson = -not [string]::IsNullOrWhiteSpace($CompactQuestionnaireJson)
1335
+ if ($hasTemplateJson -eq $hasCompactJson) {
1336
+ throw 'Specify exactly one of TemplateJson or CompactQuestionnaireJson.'
1337
+ }
1338
+ if ($hasCompactJson) {
1339
+ Assert-AnswerMeJsonUnicodeWellFormed -Json $CompactQuestionnaireJson
1340
+ $compactQuestionnaire = $CompactQuestionnaireJson | ConvertFrom-Json
1341
+ $template = ConvertFrom-AnswerMeCompactQuestionnaire -Compact $compactQuestionnaire
1342
+ $inputMode = 'compact-questionnaire-json-argument'
1343
+ }
1344
+ else {
1345
+ Assert-AnswerMeJsonUnicodeWellFormed -Json $TemplateJson
1346
+ $template = $TemplateJson | ConvertFrom-Json
1347
+ $inputMode = 'json-argument'
1348
+ }
1349
+ Assert-AnswerMeTemplate -Template $template
1350
+ $localWaitDecision = Get-AnswerMeWaitDecision -RequestedValue $WaitTimeoutSeconds
1351
+ $localEnrollmentWaitDecision = Get-AnswerMeWaitDecision -RequestedValue $EnrollmentWaitTimeoutSeconds
1352
+ $template | Add-Member -NotePropertyName 'waitPreference' -NotePropertyValue ([PSCustomObject]@{
1353
+ timeoutSeconds = $localWaitDecision.requestedTimeoutSeconds
1354
+ }) -Force
1355
+ $requestJson = $template | ConvertTo-Json -Depth 32 -Compress
1356
+ }
1357
+ catch {
1358
+ $AnswerMeTimings.inputValidationMilliseconds = [long]$inputStopwatch.ElapsedMilliseconds
1359
+ Write-AnswerMeFailure `
1360
+ -Code 'invalid-template' `
1361
+ -Message $_.Exception.Message `
1362
+ -ExitCode 2 `
1363
+ -RecoveryAction 'correct-input-and-retry-once'
1364
+ }
1365
+
1366
+ if ($localEnrollmentWaitDecision.effectiveTimeoutSeconds -lt 30) {
1367
+ $AnswerMeTimings.inputValidationMilliseconds = [long]$inputStopwatch.ElapsedMilliseconds
1368
+ Write-AnswerMeFailure `
1369
+ -Code 'enrollment-wait-timeout-invalid' `
1370
+ -Message 'EnrollmentWaitTimeoutSeconds must be an integer from 30 through 600.' `
1371
+ -ExitCode 2
1372
+ }
1373
+
1374
+ $validatedApiBaseUrl = $null
1375
+ if (-not [string]::IsNullOrWhiteSpace($ApiBaseUrl)) {
1376
+ try {
1377
+ $validatedApiBaseUrl = Resolve-AnswerMeApiBaseUrl `
1378
+ -ApiBaseUrl $ApiBaseUrl `
1379
+ -AllowInsecureLoopbackHttpForTest:$AllowInsecureLoopbackHttpForTest
1380
+ }
1381
+ catch {
1382
+ $AnswerMeTimings.inputValidationMilliseconds = [long]$inputStopwatch.ElapsedMilliseconds
1383
+ Write-AnswerMeFailure -Code 'api-base-url-invalid' -Message $_.Exception.Message -ExitCode 2
1384
+ }
1385
+ }
1386
+ $resolvedIdempotencyKey = $null
1387
+ if ($PSBoundParameters.ContainsKey('IdempotencyKey')) {
1388
+ try {
1389
+ $resolvedIdempotencyKey = Get-AnswerMeIdempotencyKey -Value $IdempotencyKey
1390
+ }
1391
+ catch {
1392
+ $AnswerMeTimings.inputValidationMilliseconds = [long]$inputStopwatch.ElapsedMilliseconds
1393
+ Write-AnswerMeFailure -Code 'idempotency-key-invalid' -Message $_.Exception.Message -ExitCode 2
1394
+ }
1395
+ }
1396
+ $AnswerMeTimings.inputValidationMilliseconds = [long]$inputStopwatch.ElapsedMilliseconds
1397
+
1398
+ if ($ValidateOnly) {
1399
+ [PSCustomObject]@{
1400
+ ok = $true
1401
+ status = 'validated'
1402
+ inputMode = $inputMode
1403
+ questionCount = @($template.questions).Count
1404
+ waitDecision = $localWaitDecision
1405
+ enrollmentWaitDecision = $localEnrollmentWaitDecision
1406
+ protocolVersion = $AnswerMeProtocolVersion
1407
+ skillVersion = $AnswerMeSkillVersion
1408
+ timings = Get-AnswerMeTimings
1409
+ networkCalled = $false
1410
+ automaticEnrollmentAttempted = $false
1411
+ originalRequestRetried = $false
1412
+ } | ConvertTo-Json -Depth 6
1413
+ exit 0
1414
+ }
1415
+
1416
+ $configurationStopwatch = [Diagnostics.Stopwatch]::StartNew()
1417
+ $creatorKey = [Environment]::GetEnvironmentVariable($CreatorKeyEnvironmentVariable, 'Process')
1418
+ $credentialSource = 'environment'
1419
+ if ([string]::IsNullOrWhiteSpace($creatorKey)) {
1420
+ $credentialSource = 'missing'
1421
+ }
1422
+ if ([string]::IsNullOrWhiteSpace($creatorKey) -or [string]::IsNullOrWhiteSpace($ApiBaseUrl)) {
1423
+ if ([string]::IsNullOrWhiteSpace($CredentialPath)) {
1424
+ $CredentialPath = Get-DefaultAnswerMeCredentialPath
1425
+ }
1426
+ if (-not [string]::IsNullOrWhiteSpace($CredentialPath) -and (Test-Path -LiteralPath $CredentialPath -PathType Leaf)) {
1427
+ try {
1428
+ $encryptedCredential = Import-Clixml -LiteralPath $CredentialPath
1429
+ if ($encryptedCredential -isnot [PSCredential]) {
1430
+ throw 'Encrypted credential file does not contain a PSCredential.'
1431
+ }
1432
+ if ([string]::IsNullOrWhiteSpace($ApiBaseUrl)) {
1433
+ $ApiBaseUrl = $encryptedCredential.UserName
1434
+ }
1435
+ if ([string]::IsNullOrWhiteSpace($creatorKey)) {
1436
+ $creatorKey = $encryptedCredential.GetNetworkCredential().Password
1437
+ $credentialSource = 'encrypted-file'
1438
+ }
1439
+ $encryptedCredential = $null
1440
+ }
1441
+ catch {
1442
+ Write-AnswerMeFailure -Code 'credential-file-invalid' -Message 'The encrypted Creator credential cannot be read by this Windows account.' -ExitCode 3
1443
+ }
1444
+ }
1445
+ }
1446
+ if ([string]::IsNullOrWhiteSpace($CredentialPath)) {
1447
+ $CredentialPath = Get-DefaultAnswerMeCredentialPath
1448
+ }
1449
+
1450
+ if ([string]::IsNullOrWhiteSpace($ApiBaseUrl)) {
1451
+ Write-AnswerMeFailure -Code 'api-base-url-missing' -Message 'A trusted AnswerMe API base URL is required.' -ExitCode 2
1452
+ }
1453
+ $parsedBaseUrl = $validatedApiBaseUrl
1454
+ try {
1455
+ $parsedBaseUrl = Resolve-AnswerMeApiBaseUrl `
1456
+ -ApiBaseUrl $ApiBaseUrl `
1457
+ -AllowInsecureLoopbackHttpForTest:$AllowInsecureLoopbackHttpForTest
1458
+ }
1459
+ catch {
1460
+ Write-AnswerMeFailure -Code 'api-base-url-invalid' -Message $_.Exception.Message -ExitCode 2
1461
+ }
1462
+
1463
+ if ($ValidateCredentialOnly) {
1464
+ if ([string]::IsNullOrWhiteSpace($creatorKey)) {
1465
+ Write-AnswerMeFailure -Code 'creator-key-missing' -Message 'A Creator Key is missing from the configured protected credential.' -ExitCode 3
1466
+ }
1467
+ $AnswerMeTimings.configurationMilliseconds = [long]$configurationStopwatch.ElapsedMilliseconds
1468
+ [PSCustomObject]@{
1469
+ ok = $true
1470
+ status = 'credential-validated'
1471
+ apiBaseUrl = $parsedBaseUrl.AbsoluteUri.TrimEnd('/')
1472
+ credentialSource = $credentialSource
1473
+ timings = Get-AnswerMeTimings
1474
+ networkCalled = $false
1475
+ automaticEnrollmentAttempted = $false
1476
+ originalRequestRetried = $false
1477
+ } | ConvertTo-Json -Depth 6
1478
+ $creatorKey = $null
1479
+ exit 0
1480
+ }
1481
+
1482
+ $resultTokenStoreAdapter = Join-Path $PSScriptRoot 'result-token-store.windows.ps1'
1483
+ if (-not (Test-Path -LiteralPath $resultTokenStoreAdapter -PathType Leaf)) {
1484
+ Write-AnswerMeFailure -Code 'result-token-store-adapter-missing' -Message 'A protected Result Token Store adapter is required.' -ExitCode 3
1485
+ }
1486
+ try {
1487
+ . $resultTokenStoreAdapter
1488
+ $resultTokenStore = Assert-AnswerMeResultTokenStore `
1489
+ -StoreRoot $ResultTokenStoreRoot `
1490
+ -AllowNonDefaultStoreRoot:$AllowNonDefaultResultTokenStore
1491
+ }
1492
+ catch {
1493
+ Write-AnswerMeFailure -Code 'result-token-store-unavailable' -Message $_.Exception.Message -ExitCode 3
1494
+ }
1495
+ $AnswerMeTimings.configurationMilliseconds = [long]$configurationStopwatch.ElapsedMilliseconds
1496
+
1497
+ $capabilitiesUri = ([Uri]::new($parsedBaseUrl, '/api/v1/capabilities')).AbsoluteUri
1498
+ if ($template.schemaVersion -eq 2) { $capabilitiesUri += '?schemaVersion=2' }
1499
+ $capabilitiesHeaders = @{
1500
+ Accept = 'application/json'
1501
+ 'X-AnswerMe-Protocol-Version' = $AnswerMeProtocolVersion
1502
+ 'X-AnswerMe-Skill-Version' = $AnswerMeSkillVersion
1503
+ }
1504
+ $capabilitiesStopwatch = [Diagnostics.Stopwatch]::StartNew()
1505
+ try {
1506
+ $capabilitiesResponse = Invoke-AnswerMeWebRequest -Uri $capabilitiesUri -Method Get -Headers $capabilitiesHeaders -TimeoutSec 15
1507
+ }
1508
+ catch {
1509
+ $AnswerMeTimings.capabilitiesMilliseconds = [long]$capabilitiesStopwatch.ElapsedMilliseconds
1510
+ Write-AnswerMeFailure -Code 'capabilities-unavailable' -Message $_.Exception.Message -ExitCode 4 -NetworkCalled $true
1511
+ }
1512
+ $AnswerMeTimings.capabilitiesMilliseconds = [long]$capabilitiesStopwatch.ElapsedMilliseconds
1513
+ $capabilitiesBody = $null
1514
+ try {
1515
+ if (-not [string]::IsNullOrWhiteSpace([string]$capabilitiesResponse.Content)) {
1516
+ $capabilitiesBody = $capabilitiesResponse.Content | ConvertFrom-Json
1517
+ }
1518
+ }
1519
+ catch {
1520
+ $capabilitiesBody = $null
1521
+ }
1522
+ if ([int]$capabilitiesResponse.StatusCode -eq 409 -and
1523
+ (Test-AnswerMeProtocolUnsupportedErrorResponse -Body $capabilitiesBody)) {
1524
+ [PSCustomObject]@{
1525
+ ok = $false
1526
+ status = 'failed'
1527
+ stage = 'create'
1528
+ code = 'client_protocol_unsupported'
1529
+ message = 'The installed AnswerMe client protocol is not supported by the service.'
1530
+ httpStatus = 409
1531
+ reason = [string]$capabilitiesBody.reason
1532
+ receivedProtocolVersion = [string]$capabilitiesBody.receivedProtocolVersion
1533
+ supportedProtocolVersions = @($capabilitiesBody.supportedProtocolVersions)
1534
+ minimumCompatibleSkillVersion = [string]$capabilitiesBody.minimumCompatibleSkillVersion
1535
+ recoveryReference = 'references/errors.md'
1536
+ networkCalled = $true
1537
+ automaticEnrollmentAttempted = $AnswerMeAutomaticEnrollmentAttempted
1538
+ originalRequestRetried = $AnswerMeOriginalRequestRetried
1539
+ } | ConvertTo-Json -Depth 8
1540
+ exit 6
1541
+ }
1542
+ $supportedProtocolVersions = @($capabilitiesBody.clientProtocol.supportedVersions)
1543
+ if ([int]$capabilitiesResponse.StatusCode -eq 200 -and
1544
+ (Test-AnswerMeIncompatibleCapabilitiesResponse -Body $capabilitiesBody)) {
1545
+ [PSCustomObject]@{
1546
+ ok = $false
1547
+ status = 'failed'
1548
+ stage = 'create'
1549
+ code = 'client_protocol_unsupported'
1550
+ message = 'The installed AnswerMe client protocol is not supported by the service.'
1551
+ httpStatus = 200
1552
+ reason = 'answerme_client_protocol_version_is_not_supported'
1553
+ receivedProtocolVersion = $AnswerMeProtocolVersion
1554
+ supportedProtocolVersions = $supportedProtocolVersions
1555
+ minimumCompatibleSkillVersion = [string]$capabilitiesBody.clientProtocol.minimumCompatibleSkillVersion
1556
+ recoveryReference = 'references/errors.md'
1557
+ networkCalled = $true
1558
+ automaticEnrollmentAttempted = $AnswerMeAutomaticEnrollmentAttempted
1559
+ originalRequestRetried = $AnswerMeOriginalRequestRetried
1560
+ } | ConvertTo-Json -Depth 8
1561
+ exit 6
1562
+ }
1563
+ if ($template.schemaVersion -eq 2 -and
1564
+ ([int]$capabilitiesResponse.StatusCode -eq 400 -or
1565
+ ([int]$capabilitiesResponse.StatusCode -eq 200 -and
1566
+ @($capabilitiesBody.schemaVersions | Where-Object { ($_ -is [int] -or $_ -is [long]) -and $_ -eq 2 }).Count -eq 0))) {
1567
+ Write-AnswerMeFailure -Code 'unsupported-schema' -Message 'The service did not declare support for schemaVersion=2; creation was not attempted.' -ExitCode 6 -NetworkCalled $true
1568
+ }
1569
+ if ([int]$capabilitiesResponse.StatusCode -ne 200 -or
1570
+ -not (Test-AnswerMeCapabilitiesResponse -Body $capabilitiesBody -SchemaVersion $template.schemaVersion)) {
1571
+ Write-AnswerMeFailure -Code 'capabilities-invalid' -Message 'AnswerMe creation capabilities could not be verified.' -ExitCode 4 -NetworkCalled $true
1572
+ }
1573
+
1574
+ if ([string]::IsNullOrWhiteSpace($creatorKey)) {
1575
+ if ([string]::IsNullOrWhiteSpace($CredentialPath)) {
1576
+ Write-AnswerMeFailure `
1577
+ -Code 'credential-path-unavailable' `
1578
+ -Message 'A protected Creator credential target is required for automatic enrollment.' `
1579
+ -ExitCode 3 `
1580
+ -NetworkCalled $true
1581
+ }
1582
+ $recovered = Invoke-AnswerMeAutomaticEnrollment `
1583
+ -ResolvedApiBaseUrl $parsedBaseUrl `
1584
+ -ResolvedCredentialPath $CredentialPath
1585
+ $creatorKey = [string]$recovered.creatorKey
1586
+ $credentialSource = [string]$recovered.credentialSource
1587
+ $recovered = $null
1588
+ }
1589
+ elseif ($credentialSource -ceq 'encrypted-file') {
1590
+ $credentialVerification = Get-AnswerMeCredentialVerification `
1591
+ -ResolvedApiBaseUrl $parsedBaseUrl `
1592
+ -CreatorKey $creatorKey
1593
+ if ($credentialVerification.valid -ne $true) {
1594
+ $recovered = Invoke-AnswerMeAutomaticEnrollment `
1595
+ -ResolvedApiBaseUrl $parsedBaseUrl `
1596
+ -ResolvedCredentialPath $CredentialPath
1597
+ $creatorKey = [string]$recovered.creatorKey
1598
+ $credentialSource = [string]$recovered.credentialSource
1599
+ $recovered = $null
1600
+ }
1601
+ }
1602
+
1603
+ $resolvedIdempotencyKey = if ($null -ne $resolvedIdempotencyKey) {
1604
+ $resolvedIdempotencyKey
1605
+ }
1606
+ else {
1607
+ Get-AnswerMeIdempotencyKey -Generate
1608
+ }
1609
+ $uri = ([Uri]::new($parsedBaseUrl, '/api/v1/interactions')).AbsoluteUri
1610
+
1611
+ try {
1612
+ $createRequestStopwatch = [Diagnostics.Stopwatch]::StartNew()
1613
+ try {
1614
+ $headers = @{
1615
+ Authorization = "Bearer $creatorKey"
1616
+ 'Idempotency-Key' = $resolvedIdempotencyKey
1617
+ 'X-AnswerMe-Protocol-Version' = $AnswerMeProtocolVersion
1618
+ 'X-AnswerMe-Skill-Version' = $AnswerMeSkillVersion
1619
+ }
1620
+ $requestBytes = [Text.Encoding]::UTF8.GetBytes($requestJson)
1621
+ $response = Invoke-AnswerMeWebRequest -Uri $uri -Method Post -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $requestBytes
1622
+ $errorBody = $null
1623
+ if ([int]$response.StatusCode -ne 201) {
1624
+ $errorBody = ConvertFrom-AnswerMeResponseBody -Response $response
1625
+ }
1626
+ if (Test-AnswerMeWriterAdmissionResponse -Response $response -Body $errorBody) {
1627
+ $retryAfter = Get-AnswerMeRetryAfterSeconds -Response $response
1628
+ if ($retryAfter -gt 0) {
1629
+ Start-Sleep -Seconds $retryAfter
1630
+ }
1631
+ $script:AnswerMeOriginalRequestRetried = $true
1632
+ $response = Invoke-AnswerMeWebRequest -Uri $uri -Method Post -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $requestBytes
1633
+ $errorBody = $null
1634
+ if ([int]$response.StatusCode -ne 201) {
1635
+ $errorBody = ConvertFrom-AnswerMeResponseBody -Response $response
1636
+ }
1637
+ }
1638
+ if ([int]$response.StatusCode -eq 401 -and
1639
+ (Test-AnswerMeCreateErrorResponse -StatusCode 401 -Body $errorBody) -and
1640
+ [string]$errorBody.code -ceq 'invalid_creator_credential' -and
1641
+ (Test-AnswerMeRecoverableCredentialReason -Reason ([string]$errorBody.reason)) -and
1642
+ -not $AnswerMeAutomaticEnrollmentAttempted -and
1643
+ -not $AnswerMeOriginalRequestRetried) {
1644
+ $recovered = Invoke-AnswerMeAutomaticEnrollment `
1645
+ -ResolvedApiBaseUrl $parsedBaseUrl `
1646
+ -ResolvedCredentialPath $CredentialPath
1647
+ $creatorKey = [string]$recovered.creatorKey
1648
+ $credentialSource = [string]$recovered.credentialSource
1649
+ $recovered = $null
1650
+ $script:AnswerMeOriginalRequestRetried = $true
1651
+ $headers.Authorization = "Bearer $creatorKey"
1652
+ $response = Invoke-AnswerMeWebRequest `
1653
+ -Uri $uri `
1654
+ -Method Post `
1655
+ -Headers $headers `
1656
+ -ContentType 'application/json; charset=utf-8' `
1657
+ -Body $requestBytes
1658
+ $errorBody = $null
1659
+ if ([int]$response.StatusCode -ne 201) {
1660
+ $errorBody = ConvertFrom-AnswerMeResponseBody -Response $response
1661
+ }
1662
+ }
1663
+ }
1664
+ finally {
1665
+ $AnswerMeTimings.createRequestMilliseconds = [long]$createRequestStopwatch.ElapsedMilliseconds
1666
+ }
1667
+
1668
+ $responseValidationStopwatch = [Diagnostics.Stopwatch]::StartNew()
1669
+ if ([int]$response.StatusCode -ne 201) {
1670
+ if ([int]$response.StatusCode -eq 409 -and
1671
+ (Test-AnswerMeProtocolUnsupportedErrorResponse -Body $errorBody)) {
1672
+ [PSCustomObject]@{
1673
+ ok = $false
1674
+ status = 'failed'
1675
+ stage = 'create'
1676
+ code = 'client_protocol_unsupported'
1677
+ message = 'The installed AnswerMe client protocol is not supported by the service.'
1678
+ httpStatus = 409
1679
+ reason = [string]$errorBody.reason
1680
+ receivedProtocolVersion = [string]$errorBody.receivedProtocolVersion
1681
+ supportedProtocolVersions = @($errorBody.supportedProtocolVersions)
1682
+ minimumCompatibleSkillVersion = [string]$errorBody.minimumCompatibleSkillVersion
1683
+ recoveryReference = 'references/errors.md'
1684
+ idempotencyKey = $resolvedIdempotencyKey
1685
+ networkCalled = $true
1686
+ automaticEnrollmentAttempted = $AnswerMeAutomaticEnrollmentAttempted
1687
+ originalRequestRetried = $AnswerMeOriginalRequestRetried
1688
+ } | ConvertTo-Json -Depth 8
1689
+ exit 6
1690
+ }
1691
+ if ([int]$response.StatusCode -eq 429 -and
1692
+ (Test-AnswerMeCreateErrorResponse -StatusCode 429 -Body $errorBody) -and
1693
+ $errorBody.reason -ceq 'creator_rolling_create_limit') {
1694
+ $retryAfter = Get-AnswerMeRetryAfterSeconds -Response $response
1695
+ Write-AnswerMeFailure `
1696
+ -Code 'rate_limited' `
1697
+ -Message 'Create rate limit reached; combine related questions and retry later.' `
1698
+ -ExitCode 4 `
1699
+ -NetworkCalled $true `
1700
+ -Reason 'creator_rolling_create_limit' `
1701
+ -RetryAfterSeconds $retryAfter `
1702
+ -IdempotencyKey $resolvedIdempotencyKey
1703
+ }
1704
+ if (Test-AnswerMeWriterAdmissionResponse -Response $response -Body $errorBody) {
1705
+ $retryAfter = Get-AnswerMeRetryAfterSeconds -Response $response
1706
+ Write-AnswerMeFailure `
1707
+ -Code 'writer-admission-unavailable' `
1708
+ -Message 'AnswerMe writer admission did not accept this creation request.' `
1709
+ -ExitCode 4 `
1710
+ -NetworkCalled $true `
1711
+ -Reason ([string]$errorBody.reason) `
1712
+ -RetryAfterSeconds $retryAfter `
1713
+ -RecoveryAction 'retry-create-with-same-idempotency-key' `
1714
+ -RecoveryReference 'references/errors.md' `
1715
+ -IdempotencyKey $resolvedIdempotencyKey
1716
+ }
1717
+ $trustedErrorBody = Test-AnswerMeCreateErrorResponse `
1718
+ -StatusCode ([int]$response.StatusCode) `
1719
+ -Body $errorBody
1720
+ Write-AnswerMeFailure `
1721
+ -Code $(if ($trustedErrorBody) { [string]$errorBody.code } else { 'create-http-status' }) `
1722
+ -Message "AnswerMe create returned HTTP $($response.StatusCode)." `
1723
+ -ExitCode 4 `
1724
+ -NetworkCalled $true `
1725
+ -Reason $(if ($trustedErrorBody -and $null -ne $errorBody.PSObject.Properties['reason']) { [string]$errorBody.reason } else { $null }) `
1726
+ -RetryAfterSeconds (Get-AnswerMeRetryAfterSeconds -Response $response) `
1727
+ -IdempotencyKey $resolvedIdempotencyKey
1728
+ }
1729
+ $body = ConvertFrom-AnswerMeResponseBody -Response $response
1730
+ if (-not (Test-AnswerMeCreateResponse -Response $response -Body $body)) {
1731
+ Write-AnswerMeFailure `
1732
+ -Code 'create-response-invalid' `
1733
+ -Message 'Create response did not match the required no-store handoff contract.' `
1734
+ -ExitCode 4 `
1735
+ -NetworkCalled $true `
1736
+ -IdempotencyKey $resolvedIdempotencyKey
1737
+ }
1738
+ $waitDecisionMatches = $null -eq $body.PSObject.Properties['waitDecision']
1739
+ if ($null -ne $body.waitDecision) {
1740
+ $actualWaitDecisionFields = @($body.waitDecision.PSObject.Properties.Name | Sort-Object)
1741
+ $expectedWaitDecisionFields = @('effectiveTimeoutSeconds', 'requestedTimeoutSeconds')
1742
+ if ($null -ne $localWaitDecision.reason) {
1743
+ $expectedWaitDecisionFields += 'reason'
1744
+ }
1745
+ $expectedWaitDecisionFields = @($expectedWaitDecisionFields | Sort-Object)
1746
+ $shapeMatches = $actualWaitDecisionFields.Count -eq $expectedWaitDecisionFields.Count -and
1747
+ -not (Compare-Object -ReferenceObject $expectedWaitDecisionFields -DifferenceObject $actualWaitDecisionFields)
1748
+ $requestedMatches = $null -ne $body.waitDecision.PSObject.Properties['requestedTimeoutSeconds'] -and
1749
+ (ConvertTo-AnswerMeCanonicalJsonScalar $body.waitDecision.requestedTimeoutSeconds) -ceq
1750
+ (ConvertTo-AnswerMeCanonicalJsonScalar $localWaitDecision.requestedTimeoutSeconds)
1751
+ $effectiveMatches = $null -ne $body.waitDecision.PSObject.Properties['effectiveTimeoutSeconds'] -and
1752
+ (ConvertTo-AnswerMeCanonicalJsonScalar $body.waitDecision.effectiveTimeoutSeconds) -ceq
1753
+ (ConvertTo-AnswerMeCanonicalJsonScalar $localWaitDecision.effectiveTimeoutSeconds)
1754
+ $reasonMatches = if ($null -eq $localWaitDecision.reason) {
1755
+ $null -eq $body.waitDecision.PSObject.Properties['reason']
1756
+ }
1757
+ else {
1758
+ $null -ne $body.waitDecision.PSObject.Properties['reason'] -and
1759
+ (ConvertTo-AnswerMeCanonicalJsonScalar $body.waitDecision.reason) -ceq
1760
+ (ConvertTo-AnswerMeCanonicalJsonScalar $localWaitDecision.reason)
1761
+ }
1762
+ $waitDecisionMatches = $shapeMatches -and $requestedMatches -and $effectiveMatches -and $reasonMatches
1763
+ }
1764
+ if (-not $waitDecisionMatches) {
1765
+ Write-AnswerMeFailure `
1766
+ -Code 'create-wait-decision-invalid' `
1767
+ -Message 'Create response omitted or changed the requested wait decision.' `
1768
+ -ExitCode 4 `
1769
+ -NetworkCalled $true `
1770
+ -IdempotencyKey $resolvedIdempotencyKey
1771
+ }
1772
+ $AnswerMeTimings.responseValidationMilliseconds = [long]$responseValidationStopwatch.ElapsedMilliseconds
1773
+
1774
+ $tokenPersistenceStopwatch = [Diagnostics.Stopwatch]::StartNew()
1775
+ try {
1776
+ $resultToken = [string]$body.resultToken
1777
+ $persistedToken = Save-AnswerMeResultToken `
1778
+ -InteractionId ([string]$body.interactionId) `
1779
+ -ResultToken $resultToken `
1780
+ -StoreRoot $ResultTokenStoreRoot `
1781
+ -AllowNonDefaultStoreRoot:$AllowNonDefaultResultTokenStore
1782
+ if (-not $persistedToken.persisted) {
1783
+ throw 'Result Token Store did not confirm persistence.'
1784
+ }
1785
+ }
1786
+ catch {
1787
+ Write-AnswerMeFailure `
1788
+ -Code 'result-token-persistence-failed' `
1789
+ -Message 'The Result Token could not be verified in protected storage.' `
1790
+ -ExitCode 5 `
1791
+ -NetworkCalled $true `
1792
+ -IdempotencyKey $resolvedIdempotencyKey
1793
+ }
1794
+ finally {
1795
+ $resultToken = $null
1796
+ $body.resultToken = $null
1797
+ }
1798
+ $AnswerMeTimings.tokenPersistenceMilliseconds = [long]$tokenPersistenceStopwatch.ElapsedMilliseconds
1799
+
1800
+ $popupStopwatch = [Diagnostics.Stopwatch]::StartNew()
1801
+ $delivery = [PSCustomObject]@{
1802
+ ok = $false
1803
+ status = 'link-only'
1804
+ code = 'popup-skipped'
1805
+ browserFamily = $null
1806
+ targetTitle = $null
1807
+ computerControlPerformed = $false
1808
+ elapsedMilliseconds = 0
1809
+ }
1810
+ if (-not $SkipPopup) {
1811
+ if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
1812
+ $delivery = [PSCustomObject]@{
1813
+ ok = $false
1814
+ status = 'link-only'
1815
+ code = 'desktop-adapter-unavailable'
1816
+ browserFamily = $null
1817
+ targetTitle = $null
1818
+ computerControlPerformed = $false
1819
+ elapsedMilliseconds = 0
1820
+ }
1821
+ }
1822
+ else {
1823
+ $popupAdapter = Join-Path $PSScriptRoot 'open-answerme-page.windows.ps1'
1824
+ if (-not (Test-Path -LiteralPath $popupAdapter -PathType Leaf)) {
1825
+ $delivery = [PSCustomObject]@{
1826
+ ok = $false
1827
+ status = 'failed'
1828
+ code = 'popup-adapter-missing'
1829
+ browserFamily = $null
1830
+ targetTitle = $null
1831
+ computerControlPerformed = $false
1832
+ elapsedMilliseconds = 0
1833
+ }
1834
+ }
1835
+ else {
1836
+ try {
1837
+ $popupRaw = & $popupAdapter -Url ([string]$body.answerUrl)
1838
+ $popup = ($popupRaw | Out-String) | ConvertFrom-Json
1839
+ $delivery = [PSCustomObject]@{
1840
+ ok = $popup.ok -eq $true
1841
+ status = [string]$popup.status
1842
+ code = [string]$popup.code
1843
+ browserFamily = [string]$popup.browserFamily
1844
+ targetTitle = [string]$popup.targetTitle
1845
+ computerControlPerformed = $popup.computerControlPerformed -eq $true
1846
+ elapsedMilliseconds = [long]$popup.elapsedMilliseconds
1847
+ }
1848
+ }
1849
+ catch {
1850
+ $delivery = [PSCustomObject]@{
1851
+ ok = $false
1852
+ status = 'failed'
1853
+ code = 'popup-adapter-invalid-result'
1854
+ browserFamily = $null
1855
+ targetTitle = $null
1856
+ computerControlPerformed = $false
1857
+ elapsedMilliseconds = 0
1858
+ }
1859
+ }
1860
+ }
1861
+ }
1862
+ }
1863
+ $AnswerMeTimings.popupMilliseconds = [long]$popupStopwatch.ElapsedMilliseconds
1864
+
1865
+ [PSCustomObject]@{
1866
+ ok = $true
1867
+ status = [string]$body.status
1868
+ inputMode = $inputMode
1869
+ interactionId = $body.interactionId
1870
+ answerUrl = $body.answerUrl
1871
+ resultTokenPersisted = $true
1872
+ resultTokenProvider = $persistedToken.provider
1873
+ expiresAt = $body.expiresAt
1874
+ requestId = $body.requestId
1875
+ questionCount = @($template.questions).Count
1876
+ waitDecision = $body.waitDecision
1877
+ delivery = $delivery
1878
+ timings = Get-AnswerMeTimings
1879
+ networkCalled = $true
1880
+ automaticEnrollmentAttempted = $AnswerMeAutomaticEnrollmentAttempted
1881
+ originalRequestRetried = $AnswerMeOriginalRequestRetried
1882
+ } | ConvertTo-Json -Depth 8
1883
+ exit 0
1884
+ }
1885
+ catch {
1886
+ Write-AnswerMeFailure `
1887
+ -Code 'create-request-failed' `
1888
+ -Message 'AnswerMe creation failed before a verified response could be returned.' `
1889
+ -ExitCode 4 `
1890
+ -NetworkCalled $true `
1891
+ -IdempotencyKey $resolvedIdempotencyKey
1892
+ }