@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,272 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Mandatory = $true)]
4
+ [string]$Url,
5
+ [string]$WindowTitleContains = '',
6
+ [ValidateRange(1, 30)]
7
+ [int]$WaitSeconds = 10,
8
+ [ValidateRange(1, 10)]
9
+ [int]$ForegroundAttempts = 4,
10
+ [ValidateRange(10, 250)]
11
+ [int]$WindowPollMilliseconds = 25,
12
+ [ValidateRange(5, 100)]
13
+ [int]$ForegroundPollMilliseconds = 10,
14
+ [ValidateRange(10, 500)]
15
+ [int]$ForegroundAttemptMilliseconds = 100,
16
+ [switch]$ValidateOnly
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
+ $operationStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
28
+
29
+ function New-PopupFailureResult {
30
+ param(
31
+ [Parameter(Mandatory = $true)][string]$Code,
32
+ [Parameter(Mandatory = $true)][string]$Message
33
+ )
34
+
35
+ return [PSCustomObject]@{
36
+ ok = $false
37
+ status = 'failed'
38
+ code = $Code
39
+ message = $Message
40
+ inputMode = 'url-only'
41
+ computerControlPerformed = $false
42
+ elapsedMilliseconds = $operationStopwatch.ElapsedMilliseconds
43
+ }
44
+ }
45
+
46
+ $parsedUrl = $null
47
+ if (-not [Uri]::TryCreate($Url, [UriKind]::Absolute, [ref]$parsedUrl) -or $parsedUrl.Scheme -notin @('http', 'https')) {
48
+ New-PopupFailureResult -Code 'invalid-url' -Message 'Url must be an absolute HTTP or HTTPS URL.' |
49
+ ConvertTo-Json -Depth 4
50
+ return
51
+ }
52
+
53
+ $edgeCandidates = @(
54
+ (Get-ItemProperty -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe' -ErrorAction SilentlyContinue).'(default)',
55
+ (Get-ItemProperty -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe' -ErrorAction SilentlyContinue).'(default)',
56
+ 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
57
+ 'C:\Program Files\Microsoft\Edge\Application\msedge.exe'
58
+ ) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -Unique
59
+
60
+ if ($edgeCandidates.Count -eq 0) {
61
+ New-PopupFailureResult -Code 'browser-not-found' -Message 'Microsoft Edge executable was not found.' |
62
+ ConvertTo-Json -Depth 4
63
+ return
64
+ }
65
+ $edgePath = @($edgeCandidates)[0]
66
+
67
+ if ($ValidateOnly) {
68
+ [PSCustomObject]@{
69
+ ok = $true
70
+ status = 'validated'
71
+ inputMode = 'url-only'
72
+ browserFamily = 'edge'
73
+ browserPath = $edgePath
74
+ urlScheme = $parsedUrl.Scheme
75
+ foregroundStrategyVersion = 3
76
+ foregroundAttempts = $ForegroundAttempts
77
+ requiresNewWindowHandle = $true
78
+ computerControlPerformed = $false
79
+ elapsedMilliseconds = $operationStopwatch.ElapsedMilliseconds
80
+ } | ConvertTo-Json -Depth 4
81
+ return
82
+ }
83
+
84
+ try {
85
+ if (-not ('AnswerMePageWindowNative' -as [type])) {
86
+ Add-Type -TypeDefinition @'
87
+ using System;
88
+ using System.Collections.Generic;
89
+ using System.Runtime.InteropServices;
90
+ using System.Text;
91
+
92
+ public static class AnswerMePageWindowNative {
93
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
94
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
95
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
96
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
97
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
98
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
99
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
100
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach);
101
+ [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int command);
102
+ [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd);
103
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
104
+ [DllImport("user32.dll")] public static extern IntPtr SetActiveWindow(IntPtr hWnd);
105
+ [DllImport("user32.dll")] public static extern IntPtr SetFocus(IntPtr hWnd);
106
+ [DllImport("user32.dll")] public static extern bool PeekMessage(out NativeMessage message, IntPtr hWnd, uint min, uint max, uint remove);
107
+
108
+ [StructLayout(LayoutKind.Sequential)]
109
+ public struct NativeMessage {
110
+ public IntPtr hWnd;
111
+ public uint message;
112
+ public UIntPtr wParam;
113
+ public IntPtr lParam;
114
+ public uint time;
115
+ public int pointX;
116
+ public int pointY;
117
+ public uint privateValue;
118
+ }
119
+
120
+ public static List<WindowInfo> EdgeWindows() {
121
+ var result = new List<WindowInfo>();
122
+ EnumWindows((hWnd, _) => {
123
+ if (!IsWindowVisible(hWnd)) return true;
124
+ uint processId;
125
+ uint threadId = GetWindowThreadProcessId(hWnd, out processId);
126
+ var text = new StringBuilder(512);
127
+ GetWindowText(hWnd, text, text.Capacity);
128
+ try {
129
+ var process = System.Diagnostics.Process.GetProcessById((int)processId);
130
+ if (String.Equals(process.ProcessName, "msedge", StringComparison.OrdinalIgnoreCase)) {
131
+ result.Add(new WindowInfo { Hwnd = hWnd, Title = text.ToString(), ProcessId = processId, ThreadId = threadId });
132
+ }
133
+ } catch { }
134
+ return true;
135
+ }, IntPtr.Zero);
136
+ return result;
137
+ }
138
+
139
+ public sealed class WindowInfo {
140
+ public IntPtr Hwnd { get; set; }
141
+ public string Title { get; set; }
142
+ public uint ProcessId { get; set; }
143
+ public uint ThreadId { get; set; }
144
+ }
145
+ }
146
+ '@
147
+ }
148
+
149
+ $beforeWindows = [AnswerMePageWindowNative]::EdgeWindows()
150
+ $beforeHandles = [System.Collections.Generic.HashSet[long]]::new()
151
+ foreach ($window in $beforeWindows) { [void]$beforeHandles.Add($window.Hwnd.ToInt64()) }
152
+
153
+ $processInfo = [System.Diagnostics.ProcessStartInfo]::new()
154
+ $processInfo.FileName = $edgePath
155
+ $processInfo.UseShellExecute = $false
156
+ $processInfo.CreateNoWindow = $true
157
+ $processInfo.RedirectStandardOutput = $true
158
+ $processInfo.RedirectStandardError = $true
159
+ Set-AnswerMeProcessArguments `
160
+ -StartInfo $processInfo `
161
+ -Arguments @('--new-window', $parsedUrl.AbsoluteUri)
162
+ $startedProcess = [System.Diagnostics.Process]::Start($processInfo)
163
+ if ($startedProcess) {
164
+ $startedProcess.BeginOutputReadLine()
165
+ $startedProcess.BeginErrorReadLine()
166
+ }
167
+
168
+ $deadline = [DateTimeOffset]::UtcNow.AddSeconds($WaitSeconds)
169
+ $target = $null
170
+ while ([DateTimeOffset]::UtcNow -lt $deadline -and -not $target) {
171
+ $windows = [AnswerMePageWindowNative]::EdgeWindows()
172
+ $target = $windows | Where-Object {
173
+ -not $beforeHandles.Contains($_.Hwnd.ToInt64()) -and
174
+ ([string]::IsNullOrEmpty($WindowTitleContains) -or $_.Title -like "*$WindowTitleContains*")
175
+ } | Select-Object -First 1
176
+ if (-not $target) {
177
+ Start-Sleep -Milliseconds $WindowPollMilliseconds
178
+ }
179
+ }
180
+
181
+ if (-not $target) {
182
+ throw "Edge opened but no new visible window matching '$WindowTitleContains' was found."
183
+ }
184
+
185
+ $foregroundBefore = [AnswerMePageWindowNative]::GetForegroundWindow()
186
+ $currentThread = [AnswerMePageWindowNative]::GetCurrentThreadId()
187
+ $message = [AnswerMePageWindowNative+NativeMessage]::new()
188
+ [void][AnswerMePageWindowNative]::PeekMessage([ref]$message, [IntPtr]::Zero, 0, 0, 0)
189
+
190
+ $attemptEvidence = @()
191
+ $foregroundAfter = $foregroundBefore
192
+ for ($attempt = 1; $attempt -le $ForegroundAttempts -and $foregroundAfter -ne $target.Hwnd; $attempt++) {
193
+ $attemptBefore = [AnswerMePageWindowNative]::GetForegroundWindow()
194
+ $foregroundThread = 0
195
+ $foregroundProcess = 0
196
+ if ($attemptBefore -ne [IntPtr]::Zero) {
197
+ $foregroundThread = [AnswerMePageWindowNative]::GetWindowThreadProcessId($attemptBefore, [ref]$foregroundProcess)
198
+ }
199
+
200
+ $attachedCurrent = $false
201
+ $attachedForeground = $false
202
+ try {
203
+ if ($currentThread -ne $target.ThreadId) {
204
+ $attachedCurrent = [AnswerMePageWindowNative]::AttachThreadInput($currentThread, $target.ThreadId, $true)
205
+ }
206
+ if ($foregroundThread -ne 0 -and $foregroundThread -ne $target.ThreadId -and $foregroundThread -ne $currentThread) {
207
+ $attachedForeground = [AnswerMePageWindowNative]::AttachThreadInput($foregroundThread, $target.ThreadId, $true)
208
+ }
209
+ $show = [AnswerMePageWindowNative]::ShowWindowAsync($target.Hwnd, 9)
210
+ $bring = [AnswerMePageWindowNative]::BringWindowToTop($target.Hwnd)
211
+ $setForeground = [AnswerMePageWindowNative]::SetForegroundWindow($target.Hwnd)
212
+ $active = [AnswerMePageWindowNative]::SetActiveWindow($target.Hwnd)
213
+ $focus = [AnswerMePageWindowNative]::SetFocus($target.Hwnd)
214
+ }
215
+ finally {
216
+ if ($attachedForeground) { [void][AnswerMePageWindowNative]::AttachThreadInput($foregroundThread, $target.ThreadId, $false) }
217
+ if ($attachedCurrent) { [void][AnswerMePageWindowNative]::AttachThreadInput($currentThread, $target.ThreadId, $false) }
218
+ }
219
+
220
+ $attemptDeadline = [DateTimeOffset]::UtcNow.AddMilliseconds($ForegroundAttemptMilliseconds)
221
+ do {
222
+ $foregroundAfter = [AnswerMePageWindowNative]::GetForegroundWindow()
223
+ if ($foregroundAfter -eq $target.Hwnd) { break }
224
+ Start-Sleep -Milliseconds $ForegroundPollMilliseconds
225
+ } while ([DateTimeOffset]::UtcNow -lt $attemptDeadline)
226
+ $attemptEvidence += [PSCustomObject]@{
227
+ attempt = $attempt
228
+ foregroundBefore = $attemptBefore.ToInt64()
229
+ foregroundAfter = $foregroundAfter.ToInt64()
230
+ attachedCurrent = $attachedCurrent
231
+ attachedForeground = $attachedForeground
232
+ show = $show
233
+ bring = $bring
234
+ setForeground = $setForeground
235
+ active = $active.ToInt64()
236
+ focus = $focus.ToInt64()
237
+ }
238
+ }
239
+
240
+ $ok = $foregroundAfter -eq $target.Hwnd
241
+ [PSCustomObject]@{
242
+ ok = $ok
243
+ status = if ($ok) { 'foreground-verified' } else { 'failed' }
244
+ code = if ($ok) { $null } else { 'foreground-verification-failed' }
245
+ inputMode = 'url-only'
246
+ browserFamily = 'edge'
247
+ targetHwnd = $target.Hwnd.ToInt64()
248
+ targetTitle = $target.Title
249
+ targetProcessId = $target.ProcessId
250
+ startedProcessId = if ($startedProcess) { $startedProcess.Id } else { $null }
251
+ foregroundBefore = $foregroundBefore.ToInt64()
252
+ foregroundAfter = $foregroundAfter.ToInt64()
253
+ foregroundStrategyVersion = 3
254
+ requiresNewWindowHandle = $true
255
+ computerControlPerformed = $true
256
+ elapsedMilliseconds = $operationStopwatch.ElapsedMilliseconds
257
+ attempts = $attemptEvidence
258
+ } | ConvertTo-Json -Depth 8
259
+
260
+ }
261
+ catch {
262
+ [PSCustomObject]@{
263
+ ok = $false
264
+ status = 'failed'
265
+ code = 'open-or-foreground-failed'
266
+ message = $_.Exception.Message
267
+ inputMode = 'url-only'
268
+ browserFamily = 'edge'
269
+ computerControlPerformed = $true
270
+ elapsedMilliseconds = $operationStopwatch.ElapsedMilliseconds
271
+ } | ConvertTo-Json -Depth 4
272
+ }
@@ -0,0 +1,63 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Mandatory = $true)]
4
+ [string]$InteractionId,
5
+ [string]$ResultTokenStoreRoot = $env:ANSWERME_RESULT_TOKEN_STORE_ROOT,
6
+ [switch]$AllowNonDefaultResultTokenStore
7
+ )
8
+
9
+ $ErrorActionPreference = 'Stop'
10
+
11
+ function Write-AnswerMeCleanupFailure {
12
+ param(
13
+ [Parameter(Mandatory = $true)][string]$Code,
14
+ [Parameter(Mandatory = $true)][string]$Message,
15
+ [Parameter(Mandatory = $true)][int]$ExitCode
16
+ )
17
+
18
+ [PSCustomObject]@{
19
+ ok = $false
20
+ status = 'failed'
21
+ code = $Code
22
+ message = $Message
23
+ interactionId = $InteractionId
24
+ } | ConvertTo-Json -Compress
25
+ exit $ExitCode
26
+ }
27
+
28
+ if ([string]::IsNullOrWhiteSpace($InteractionId)) {
29
+ Write-AnswerMeCleanupFailure `
30
+ -Code 'interaction-id-missing' `
31
+ -Message 'InteractionId is required.' `
32
+ -ExitCode 2
33
+ }
34
+
35
+ $adapterPath = Join-Path $PSScriptRoot 'result-token-store.windows.ps1'
36
+ if (-not (Test-Path -LiteralPath $adapterPath -PathType Leaf)) {
37
+ Write-AnswerMeCleanupFailure `
38
+ -Code 'result-token-store-adapter-missing' `
39
+ -Message 'A protected Result Token Store adapter is required.' `
40
+ -ExitCode 3
41
+ }
42
+
43
+ try {
44
+ . $adapterPath
45
+ $result = Remove-AnswerMeResultToken `
46
+ -InteractionId $InteractionId `
47
+ -StoreRoot $ResultTokenStoreRoot `
48
+ -AllowNonDefaultStoreRoot:$AllowNonDefaultResultTokenStore
49
+ [PSCustomObject]@{
50
+ ok = $true
51
+ status = 'credential-cleanup-complete'
52
+ interactionId = $InteractionId
53
+ removed = [bool]$result.removed
54
+ provider = $result.provider
55
+ } | ConvertTo-Json -Compress
56
+ exit 0
57
+ }
58
+ catch {
59
+ Write-AnswerMeCleanupFailure `
60
+ -Code 'result-token-cleanup-failed' `
61
+ -Message $_.Exception.Message `
62
+ -ExitCode 3
63
+ }
@@ -0,0 +1,261 @@
1
+ $ErrorActionPreference = 'Stop'
2
+
3
+ $cryptoSupport = Join-Path $PSScriptRoot 'windows-crypto.ps1'
4
+ if (-not (Test-Path -LiteralPath $cryptoSupport -PathType Leaf)) {
5
+ throw 'The AnswerMe Windows cryptography helper is missing.'
6
+ }
7
+ . $cryptoSupport
8
+
9
+ function Get-AnswerMeDefaultResultTokenStoreRoot {
10
+ if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
11
+ throw 'The Windows Result Token Store adapter requires Windows.'
12
+ }
13
+ $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
14
+ if ([string]::IsNullOrWhiteSpace($localAppData)) {
15
+ throw 'LocalApplicationData is unavailable for the current Windows account.'
16
+ }
17
+ return [IO.Path]::GetFullPath((Join-Path $localAppData 'AnswerMe\result-token-credentials'))
18
+ }
19
+
20
+ function Resolve-AnswerMeResultTokenStoreRoot {
21
+ param(
22
+ [string]$StoreRoot,
23
+ [switch]$AllowNonDefaultStoreRoot
24
+ )
25
+
26
+ $defaultRoot = Get-AnswerMeDefaultResultTokenStoreRoot
27
+ if ([string]::IsNullOrWhiteSpace($StoreRoot)) {
28
+ return $defaultRoot
29
+ }
30
+ $resolved = [IO.Path]::GetFullPath($StoreRoot)
31
+ if (-not $AllowNonDefaultStoreRoot -and
32
+ -not $resolved.Equals($defaultRoot, [StringComparison]::OrdinalIgnoreCase)) {
33
+ throw "A non-default Result Token Store root is allowed only for isolated tests: $resolved"
34
+ }
35
+ return $resolved
36
+ }
37
+
38
+ function Get-AnswerMeResultTokenRecordHash {
39
+ param([Parameter(Mandatory = $true)][string]$InteractionId)
40
+
41
+ if ([string]::IsNullOrWhiteSpace($InteractionId)) {
42
+ throw 'InteractionId is required for Result Token storage.'
43
+ }
44
+ $idBytes = [Text.Encoding]::UTF8.GetBytes($InteractionId)
45
+ $hashBytes = $null
46
+ try {
47
+ $hashBytes = Get-AnswerMeSha256Bytes -Bytes $idBytes
48
+ return ConvertTo-AnswerMeLowerHex -Bytes $hashBytes
49
+ }
50
+ finally {
51
+ Clear-AnswerMeSensitiveByteArray -Bytes $idBytes
52
+ Clear-AnswerMeSensitiveByteArray -Bytes $hashBytes
53
+ }
54
+ }
55
+
56
+ function Get-AnswerMeResultTokenCredentialPath {
57
+ param(
58
+ [Parameter(Mandatory = $true)][string]$InteractionId,
59
+ [Parameter(Mandatory = $true)][string]$ResolvedStoreRoot
60
+ )
61
+
62
+ $fileName = "{0}.clixml" -f (Get-AnswerMeResultTokenRecordHash -InteractionId $InteractionId)
63
+ $path = [IO.Path]::GetFullPath((Join-Path $ResolvedStoreRoot $fileName))
64
+ $parent = [IO.Path]::GetDirectoryName($path)
65
+ if (-not $parent.Equals($ResolvedStoreRoot, [StringComparison]::OrdinalIgnoreCase)) {
66
+ throw 'Resolved credential path escaped the Result Token Store root.'
67
+ }
68
+ return $path
69
+ }
70
+
71
+ function Import-AnswerMeResultTokenCredential {
72
+ param(
73
+ [Parameter(Mandatory = $true)][string]$CredentialPath,
74
+ [Parameter(Mandatory = $true)][string]$InteractionId
75
+ )
76
+
77
+ try {
78
+ $credential = Import-Clixml -LiteralPath $CredentialPath
79
+ }
80
+ catch {
81
+ throw 'The encrypted Result Token credential cannot be read by this Windows account.'
82
+ }
83
+ $expectedUserName = 'answerme-result-token:{0}' -f (
84
+ Get-AnswerMeResultTokenRecordHash -InteractionId $InteractionId
85
+ )
86
+ if ($credential -isnot [PSCredential] -or $credential.UserName -cne $expectedUserName) {
87
+ throw 'The encrypted Result Token credential does not match the requested interaction.'
88
+ }
89
+ return $credential
90
+ }
91
+
92
+ function Assert-AnswerMeResultTokenStore {
93
+ [CmdletBinding()]
94
+ param(
95
+ [string]$StoreRoot,
96
+ [switch]$AllowNonDefaultStoreRoot
97
+ )
98
+
99
+ $resolvedRoot = Resolve-AnswerMeResultTokenStoreRoot `
100
+ -StoreRoot $StoreRoot `
101
+ -AllowNonDefaultStoreRoot:$AllowNonDefaultStoreRoot
102
+ New-Item -ItemType Directory -Path $resolvedRoot -Force | Out-Null
103
+
104
+ $probeId = "answerme-store-probe-$([Guid]::NewGuid().ToString('N'))"
105
+ $probePath = Join-Path $resolvedRoot ".$probeId.clixml"
106
+ $probeSecret = "probe-$([Guid]::NewGuid().ToString('N'))"
107
+ try {
108
+ $secure = ConvertTo-SecureString $probeSecret -AsPlainText -Force
109
+ $probeUserName = 'answerme-result-token:{0}' -f (
110
+ Get-AnswerMeResultTokenRecordHash -InteractionId $probeId
111
+ )
112
+ $credential = [PSCredential]::new($probeUserName, $secure)
113
+ $credential | Export-Clixml -LiteralPath $probePath
114
+ $roundTrip = Import-AnswerMeResultTokenCredential `
115
+ -CredentialPath $probePath `
116
+ -InteractionId $probeId
117
+ if ($roundTrip.GetNetworkCredential().Password -cne $probeSecret) {
118
+ throw 'DPAPI Result Token Store probe did not round-trip.'
119
+ }
120
+ return [PSCustomObject]@{
121
+ available = $true
122
+ provider = 'windows-dpapi'
123
+ storeRoot = $resolvedRoot
124
+ }
125
+ }
126
+ finally {
127
+ $secure = $null
128
+ $credential = $null
129
+ $roundTrip = $null
130
+ $probeSecret = $null
131
+ if (Test-Path -LiteralPath $probePath -PathType Leaf) {
132
+ Remove-Item -LiteralPath $probePath -Force
133
+ }
134
+ }
135
+ }
136
+
137
+ function Save-AnswerMeResultToken {
138
+ [CmdletBinding()]
139
+ param(
140
+ [Parameter(Mandatory = $true)][string]$InteractionId,
141
+ [Parameter(Mandatory = $true)][string]$ResultToken,
142
+ [string]$StoreRoot,
143
+ [switch]$AllowNonDefaultStoreRoot
144
+ )
145
+
146
+ if ([string]::IsNullOrWhiteSpace($ResultToken)) {
147
+ throw 'Result Token is required for protected persistence.'
148
+ }
149
+ $resolvedRoot = Resolve-AnswerMeResultTokenStoreRoot `
150
+ -StoreRoot $StoreRoot `
151
+ -AllowNonDefaultStoreRoot:$AllowNonDefaultStoreRoot
152
+ New-Item -ItemType Directory -Path $resolvedRoot -Force | Out-Null
153
+ $credentialPath = Get-AnswerMeResultTokenCredentialPath `
154
+ -InteractionId $InteractionId `
155
+ -ResolvedStoreRoot $resolvedRoot
156
+
157
+ if (Test-Path -LiteralPath $credentialPath -PathType Leaf) {
158
+ $existing = Import-AnswerMeResultTokenCredential `
159
+ -CredentialPath $credentialPath `
160
+ -InteractionId $InteractionId
161
+ if ($existing.GetNetworkCredential().Password -cne $ResultToken) {
162
+ throw 'A different Result Token is already stored for this interaction.'
163
+ }
164
+ return [PSCustomObject]@{
165
+ persisted = $true
166
+ idempotentReplay = $true
167
+ provider = 'windows-dpapi'
168
+ interactionId = $InteractionId
169
+ credentialPath = $credentialPath
170
+ }
171
+ }
172
+
173
+ $temporaryPath = Join-Path $resolvedRoot ".$([Guid]::NewGuid().ToString('N')).tmp.clixml"
174
+ try {
175
+ $secureToken = ConvertTo-SecureString $ResultToken -AsPlainText -Force
176
+ $credentialUserName = 'answerme-result-token:{0}' -f (
177
+ Get-AnswerMeResultTokenRecordHash -InteractionId $InteractionId
178
+ )
179
+ $credential = [PSCredential]::new($credentialUserName, $secureToken)
180
+ $credential | Export-Clixml -LiteralPath $temporaryPath
181
+ $roundTrip = Import-AnswerMeResultTokenCredential `
182
+ -CredentialPath $temporaryPath `
183
+ -InteractionId $InteractionId
184
+ if ($roundTrip.GetNetworkCredential().Password -cne $ResultToken) {
185
+ throw 'The persisted Result Token failed readback verification.'
186
+ }
187
+ Move-Item -LiteralPath $temporaryPath -Destination $credentialPath
188
+ return [PSCustomObject]@{
189
+ persisted = $true
190
+ idempotentReplay = $false
191
+ provider = 'windows-dpapi'
192
+ interactionId = $InteractionId
193
+ credentialPath = $credentialPath
194
+ }
195
+ }
196
+ finally {
197
+ $secureToken = $null
198
+ $credential = $null
199
+ $roundTrip = $null
200
+ if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
201
+ Remove-Item -LiteralPath $temporaryPath -Force
202
+ }
203
+ }
204
+ }
205
+
206
+ function Get-AnswerMeResultToken {
207
+ [CmdletBinding()]
208
+ param(
209
+ [Parameter(Mandatory = $true)][string]$InteractionId,
210
+ [string]$StoreRoot,
211
+ [switch]$AllowNonDefaultStoreRoot
212
+ )
213
+
214
+ $resolvedRoot = Resolve-AnswerMeResultTokenStoreRoot `
215
+ -StoreRoot $StoreRoot `
216
+ -AllowNonDefaultStoreRoot:$AllowNonDefaultStoreRoot
217
+ $credentialPath = Get-AnswerMeResultTokenCredentialPath `
218
+ -InteractionId $InteractionId `
219
+ -ResolvedStoreRoot $resolvedRoot
220
+ if (-not (Test-Path -LiteralPath $credentialPath -PathType Leaf)) {
221
+ throw 'No protected Result Token credential exists for this interaction.'
222
+ }
223
+ $credential = Import-AnswerMeResultTokenCredential `
224
+ -CredentialPath $credentialPath `
225
+ -InteractionId $InteractionId
226
+ return $credential.GetNetworkCredential().Password
227
+ }
228
+
229
+ function Remove-AnswerMeResultToken {
230
+ [CmdletBinding()]
231
+ param(
232
+ [Parameter(Mandatory = $true)][string]$InteractionId,
233
+ [string]$StoreRoot,
234
+ [switch]$AllowNonDefaultStoreRoot
235
+ )
236
+
237
+ $resolvedRoot = Resolve-AnswerMeResultTokenStoreRoot `
238
+ -StoreRoot $StoreRoot `
239
+ -AllowNonDefaultStoreRoot:$AllowNonDefaultStoreRoot
240
+ $credentialPath = Get-AnswerMeResultTokenCredentialPath `
241
+ -InteractionId $InteractionId `
242
+ -ResolvedStoreRoot $resolvedRoot
243
+ if (-not (Test-Path -LiteralPath $credentialPath -PathType Leaf)) {
244
+ return [PSCustomObject]@{
245
+ removed = $false
246
+ provider = 'windows-dpapi'
247
+ interactionId = $InteractionId
248
+ credentialPath = $credentialPath
249
+ }
250
+ }
251
+ Import-AnswerMeResultTokenCredential `
252
+ -CredentialPath $credentialPath `
253
+ -InteractionId $InteractionId | Out-Null
254
+ Remove-Item -LiteralPath $credentialPath -Force
255
+ return [PSCustomObject]@{
256
+ removed = $true
257
+ provider = 'windows-dpapi'
258
+ interactionId = $InteractionId
259
+ credentialPath = $credentialPath
260
+ }
261
+ }