@naswerks/periscope 1.0.0

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 (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. package/package.json +98 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The device-code fallback — for a box with no browser on it, which for this package is not an edge
3
+ * case but a deployment target.
4
+ *
5
+ * It is off unless explicitly configured, and it is never reached by falling back. Microsoft's
6
+ * guidance classes device code as a high-risk flow that phishing campaigns exploit and recommends
7
+ * that organisations block it (the Conditional Access authentication-flows documentation,
8
+ * https://learn.microsoft.com/entra/identity/conditional-access/concept-authentication-flows#device-code-flow);
9
+ * it is also exactly what workplace Conditional Access
10
+ * policies block, so a tenant refusing it outright is an expected outcome rather than a bug. A flow
11
+ * with that standing must be asked for by name — silently falling back to it when the loopback
12
+ * listener cannot bind would be the host quietly choosing the riskier option on the operator's
13
+ * behalf, at the moment they are least able to notice.
14
+ *
15
+ * And the trap that is invisible for weeks. A provider that tracks which protocol issued a token
16
+ * marks a refresh token obtained through device code as such, and a later non-device-code refresh
17
+ * against it fails with `AADSTS530036`; the provider's guidance is that such a token is unusable
18
+ * for good and should be deleted.
19
+ * The failure does not appear at sign-in; it appears the first time the token is refreshed, which
20
+ * for an unattended host is hours or weeks later and nowhere near the cause. So a device-code
21
+ * sign-in poisons the cache for any other flow: if this
22
+ * host ever switches a deployment back to loopback, the cached material must be discarded, not
23
+ * refreshed. `discardOnProtocolMismatch` is how that is decided rather than discovered.
24
+ */
25
+ import type { Refusal } from '../core/refusal.js';
26
+ import type { Result } from '../core/result.js';
27
+ import type { IdentityConfig } from './config.js';
28
+ /** Which flow minted the material this host holds. Recorded so the trap above is decidable. */
29
+ export type AuthProtocol = 'loopback' | 'device-code';
30
+ /**
31
+ * The error code the provider returns when a Conditional Access authentication-flows policy
32
+ * rejects a refresh. Named so a reader can search for it.
33
+ *
34
+ * It is not a cross-protocol error, and naming it as one names the wrong cause. `AADSTS530036` is
35
+ * not "you refreshed device-code material with a different flow". Microsoft's documentation
36
+ * describes it as a Conditional Access authentication-flows check refusing the refresh token, and
37
+ * because such a policy applies to every application the token is unusable for good and should be
38
+ * deleted. The mechanism is protocol tracking: a session established with device code stays marked
39
+ * through every later refresh, and the tenant's policy then rejects it, so the trigger is an
40
+ * administrator enabling a policy, not this host being reconfigured. Source:
41
+ * https://learn.microsoft.com/entra/identity/conditional-access/concept-authentication-flows#device-code-flow
42
+ *
43
+ * A cross-protocol name would be dangerous rather than merely inaccurate: it would imply the fix
44
+ * is to sign in again, which re-runs the same flow into the same policy, forever. And it is the
45
+ * policy Microsoft actively recommends: allow device code only where a documented case needs it
46
+ * and block it everywhere else
47
+ * (https://learn.microsoft.com/entra/identity/conditional-access/policy-block-authentication-flows).
48
+ * See `host/sign-in.ts` for the recovery
49
+ * this routes to.
50
+ */
51
+ export declare const AUTH_FLOW_BLOCKED_ERROR = "AADSTS530036";
52
+ /**
53
+ * Every provider code known to mean "a policy has blocked the flow this material was minted by".
54
+ * One today; a provider that reports the same condition under another code is added here, and
55
+ * only here: this file is the one place in the package that may know a provider's error codes.
56
+ */
57
+ export declare const AUTH_FLOW_BLOCKED_CODES: readonly string[];
58
+ export interface DeviceAuthorization {
59
+ readonly deviceCode: string;
60
+ readonly userCode: string;
61
+ readonly verificationUri: string;
62
+ readonly expiresAt: number;
63
+ readonly intervalMs: number;
64
+ }
65
+ /** The provider's answer to one poll. */
66
+ export type DevicePollOutcome = {
67
+ readonly kind: 'pending';
68
+ }
69
+ /** The provider asked the caller to back off; the caller widens its interval by this much. */
70
+ | {
71
+ readonly kind: 'slow-down';
72
+ readonly intervalMs: number;
73
+ } | {
74
+ readonly kind: 'ready';
75
+ } | {
76
+ readonly kind: 'declined';
77
+ readonly refusal: Refusal;
78
+ };
79
+ /**
80
+ * Refuse the device-code flow unless it was turned on.
81
+ *
82
+ * Exported and total so the refusal is reachable in a test rather than being a branch nobody drives.
83
+ */
84
+ export declare function requireDeviceCodeEnabled(config: IdentityConfig): Result<true>;
85
+ export declare function deviceAuthorizationBody(config: IdentityConfig): string;
86
+ export declare function devicePollBody(config: IdentityConfig, deviceCode: string): string;
87
+ export declare function readDeviceAuthorization(status: number, body: unknown, nowMs: number): Result<DeviceAuthorization>;
88
+ /**
89
+ * Read one poll response.
90
+ *
91
+ * `authorization_pending` and `slow_down` are not failures — they are the protocol working. Mapping
92
+ * them onto a refusal would end a sign-in that was proceeding normally.
93
+ */
94
+ export declare function readDevicePoll(status: number, body: unknown, currentIntervalMs: number): DevicePollOutcome;
95
+ /**
96
+ * Must cached material be discarded rather than refreshed, because this host was reconfigured?
97
+ *
98
+ * This is not the `AADSTS530036` guard. It detects one real but narrow
99
+ * case: the cache was minted by one flow and the host is now configured for the other, so a refresh
100
+ * would fail for a reason nobody would connect to a sign-in that happened weeks ago.
101
+ *
102
+ * It cannot fire on the policy case, which is the common one. When a tenant enables the
103
+ * authentication-flows policy, the host is still configured for device code and the cache was still
104
+ * minted by device code — `cached === configured`, so this returns `false` and nothing is discarded.
105
+ * A guard named for an error it is structurally unable to catch is worse than no guard, because it
106
+ * reads as coverage. The reactive path in `host/sign-in.ts` is what handles the policy case.
107
+ */
108
+ export declare function discardOnProtocolMismatch(cached: AuthProtocol, configured: AuthProtocol): boolean;
109
+ /**
110
+ * Does this provider error mean a Conditional Access authentication-flows policy has blocked this
111
+ * material permanently? Microsoft's documentation: "the token will never be usable and should be
112
+ * deleted."
113
+ */
114
+ export declare function isAuthFlowBlocked(errorText: string): boolean;
115
+ //# sourceMappingURL=device-code.d.ts.map
@@ -0,0 +1,134 @@
1
+ import { refusal } from '../core/refusal.js';
2
+ import { ok, refuse } from '../core/result.js';
3
+ import { redactSecrets, secretsIn } from './token.js';
4
+ /**
5
+ * The error code the provider returns when a Conditional Access authentication-flows policy
6
+ * rejects a refresh. Named so a reader can search for it.
7
+ *
8
+ * It is not a cross-protocol error, and naming it as one names the wrong cause. `AADSTS530036` is
9
+ * not "you refreshed device-code material with a different flow". Microsoft's documentation
10
+ * describes it as a Conditional Access authentication-flows check refusing the refresh token, and
11
+ * because such a policy applies to every application the token is unusable for good and should be
12
+ * deleted. The mechanism is protocol tracking: a session established with device code stays marked
13
+ * through every later refresh, and the tenant's policy then rejects it, so the trigger is an
14
+ * administrator enabling a policy, not this host being reconfigured. Source:
15
+ * https://learn.microsoft.com/entra/identity/conditional-access/concept-authentication-flows#device-code-flow
16
+ *
17
+ * A cross-protocol name would be dangerous rather than merely inaccurate: it would imply the fix
18
+ * is to sign in again, which re-runs the same flow into the same policy, forever. And it is the
19
+ * policy Microsoft actively recommends: allow device code only where a documented case needs it
20
+ * and block it everywhere else
21
+ * (https://learn.microsoft.com/entra/identity/conditional-access/policy-block-authentication-flows).
22
+ * See `host/sign-in.ts` for the recovery
23
+ * this routes to.
24
+ */
25
+ export const AUTH_FLOW_BLOCKED_ERROR = 'AADSTS530036';
26
+ /**
27
+ * Every provider code known to mean "a policy has blocked the flow this material was minted by".
28
+ * One today; a provider that reports the same condition under another code is added here, and
29
+ * only here: this file is the one place in the package that may know a provider's error codes.
30
+ */
31
+ export const AUTH_FLOW_BLOCKED_CODES = [AUTH_FLOW_BLOCKED_ERROR];
32
+ /**
33
+ * Refuse the device-code flow unless it was turned on.
34
+ *
35
+ * Exported and total so the refusal is reachable in a test rather than being a branch nobody drives.
36
+ */
37
+ export function requireDeviceCodeEnabled(config) {
38
+ if (!config.deviceCodeEnabled) {
39
+ return refuse('device-code-not-enabled', 'the device-code flow is not enabled on this host; set PERISCOPE_IDENTITY_DEVICE_CODE=1 to allow it, and expect some tenants to refuse it by policy');
40
+ }
41
+ return ok(true);
42
+ }
43
+ export function deviceAuthorizationBody(config) {
44
+ const body = new URLSearchParams();
45
+ body.set('client_id', config.clientId);
46
+ body.set('scope', config.scopes.join(' '));
47
+ return body.toString();
48
+ }
49
+ export function devicePollBody(config, deviceCode) {
50
+ const body = new URLSearchParams();
51
+ // RFC 8628's grant type, urn-namespaced.
52
+ body.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
53
+ body.set('client_id', config.clientId);
54
+ body.set('device_code', deviceCode);
55
+ return body.toString();
56
+ }
57
+ export function readDeviceAuthorization(status, body, nowMs) {
58
+ const secrets = secretsIn(body);
59
+ if (typeof body !== 'object' || body === null) {
60
+ return refuse('device-code-declined', `the device authorization endpoint returned HTTP ${status} with no JSON body`);
61
+ }
62
+ const record = body;
63
+ if (status < 200 || status >= 300) {
64
+ const error = typeof record['error'] === 'string' ? record['error'] : `HTTP ${status}`;
65
+ return refuse('device-code-declined', redactSecrets(`the device authorization endpoint refused: ${error} — some tenants block this flow by policy`, secrets));
66
+ }
67
+ const deviceCode = record['device_code'];
68
+ const userCode = record['user_code'];
69
+ const verificationUri = record['verification_uri'] ?? record['verification_url'];
70
+ const expiresIn = Number(record['expires_in']);
71
+ const interval = Number(record['interval']);
72
+ if (typeof deviceCode !== 'string' || typeof userCode !== 'string' || typeof verificationUri !== 'string') {
73
+ return refuse('device-code-declined', 'the device authorization response is missing device_code, user_code or verification_uri');
74
+ }
75
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
76
+ return refuse('device-code-declined', 'the device authorization response carries no usable expires_in');
77
+ }
78
+ return ok({
79
+ deviceCode,
80
+ userCode,
81
+ verificationUri,
82
+ expiresAt: nowMs + expiresIn * 1000,
83
+ // RFC 8628 says default to 5 seconds when the provider does not state an interval.
84
+ intervalMs: (Number.isFinite(interval) && interval > 0 ? interval : 5) * 1000,
85
+ });
86
+ }
87
+ /**
88
+ * Read one poll response.
89
+ *
90
+ * `authorization_pending` and `slow_down` are not failures — they are the protocol working. Mapping
91
+ * them onto a refusal would end a sign-in that was proceeding normally.
92
+ */
93
+ export function readDevicePoll(status, body, currentIntervalMs) {
94
+ if (status >= 200 && status < 300)
95
+ return { kind: 'ready' };
96
+ const record = typeof body === 'object' && body !== null ? body : {};
97
+ const error = typeof record['error'] === 'string' ? record['error'] : `http_${status}`;
98
+ if (error === 'authorization_pending')
99
+ return { kind: 'pending' };
100
+ if (error === 'slow_down')
101
+ return { kind: 'slow-down', intervalMs: currentIntervalMs + 5_000 };
102
+ const description = typeof record['error_description'] === 'string' ? record['error_description'] : null;
103
+ return {
104
+ kind: 'declined',
105
+ refusal: refusal('device-code-declined', redactSecrets(description === null
106
+ ? `the device-code sign-in ended: ${error}`
107
+ : `the device-code sign-in ended: ${error} — ${description}`, secretsIn(body))),
108
+ };
109
+ }
110
+ /**
111
+ * Must cached material be discarded rather than refreshed, because this host was reconfigured?
112
+ *
113
+ * This is not the `AADSTS530036` guard. It detects one real but narrow
114
+ * case: the cache was minted by one flow and the host is now configured for the other, so a refresh
115
+ * would fail for a reason nobody would connect to a sign-in that happened weeks ago.
116
+ *
117
+ * It cannot fire on the policy case, which is the common one. When a tenant enables the
118
+ * authentication-flows policy, the host is still configured for device code and the cache was still
119
+ * minted by device code — `cached === configured`, so this returns `false` and nothing is discarded.
120
+ * A guard named for an error it is structurally unable to catch is worse than no guard, because it
121
+ * reads as coverage. The reactive path in `host/sign-in.ts` is what handles the policy case.
122
+ */
123
+ export function discardOnProtocolMismatch(cached, configured) {
124
+ return cached !== configured;
125
+ }
126
+ /**
127
+ * Does this provider error mean a Conditional Access authentication-flows policy has blocked this
128
+ * material permanently? Microsoft's documentation: "the token will never be usable and should be
129
+ * deleted."
130
+ */
131
+ export function isAuthFlowBlocked(errorText) {
132
+ return AUTH_FLOW_BLOCKED_CODES.some((code) => errorText.includes(code));
133
+ }
134
+ //# sourceMappingURL=device-code.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Identity — signing a real user in, holding what that produced, and presenting it.
3
+ *
4
+ * Every module behind this barrel is pure. The two impure halves live in `host/`
5
+ * (`token-cache.ts` writes the file, `loopback.ts` opens the port), which is where this package
6
+ * confines anything that touches the machine. That split is the same one the gate uses, and it is
7
+ * what makes the awkward branches — a malformed cache, a callback with the wrong `state`, a
8
+ * filesystem that ignores modes — drivable without a machine in that state.
9
+ *
10
+ * Nothing here names a provider. The authority, client and endpoints are configuration, and the
11
+ * flows are RFC 6749 / 7636 / 8628 as written. Any one commercial provider is a configuration of
12
+ * this, not the shape of it — which is what lets somebody point this at their own provider and
13
+ * have it work.
14
+ */
15
+ export type { IdentityConfig, IdentityEndpoints, IdentityPosture } from './config.js';
16
+ export { DEFAULT_SCOPES, discoveryUrl, identityPosture, readDiscoveryDocument, readIdentityConfig, } from './config.js';
17
+ export type { Pkce } from './pkce.js';
18
+ export { CODE_CHALLENGE_METHOD, challengeFor, createPkce, createState, randomUrlSafe, requireS256, stateMatches, } from './pkce.js';
19
+ export type { AuthorizationCallback, AuthorizationRequest } from './authorize.js';
20
+ export { CALLBACK_PAGE, buildAuthorizationUrl, readAuthorizationCallback, redirectUriFor, } from './authorize.js';
21
+ export type { TokenSet } from './token.js';
22
+ export { EXPIRY_SKEW_MS, authorizationValue, codeExchangeBody, isFresh, readTokenResponse, redactSecrets, refreshBody, secretsIn, } from './token.js';
23
+ export type { CredentialModeOutcome, ModeEnforcement } from './mode.js';
24
+ export { CREDENTIAL_MODE, classifyCredentialMode, classifyProbeReadings, isWiderThan, toOctal, } from './mode.js';
25
+ export type { AuthProtocol, DeviceAuthorization, DevicePollOutcome } from './device-code.js';
26
+ export { AUTH_FLOW_BLOCKED_CODES, AUTH_FLOW_BLOCKED_ERROR, deviceAuthorizationBody, devicePollBody, discardOnProtocolMismatch, isAuthFlowBlocked, readDeviceAuthorization, readDevicePoll, requireDeviceCodeEnabled, } from './device-code.js';
27
+ export type { CachedTokens, TokenStore } from './store.js';
28
+ export { readCachedTokens } from './store.js';
29
+ export type { TokenCredentialOptions, TokenRefresher } from './credential.js';
30
+ export { AUTHORIZATION_HEADER, TokenCredential } from './credential.js';
31
+ export type { PairedCredentialFile, PairedCredentialStore } from './paired-credential.js';
32
+ export { PairedHostCredential, readPairedCredentialFile } from './paired-credential.js';
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,10 @@
1
+ export { DEFAULT_SCOPES, discoveryUrl, identityPosture, readDiscoveryDocument, readIdentityConfig, } from './config.js';
2
+ export { CODE_CHALLENGE_METHOD, challengeFor, createPkce, createState, randomUrlSafe, requireS256, stateMatches, } from './pkce.js';
3
+ export { CALLBACK_PAGE, buildAuthorizationUrl, readAuthorizationCallback, redirectUriFor, } from './authorize.js';
4
+ export { EXPIRY_SKEW_MS, authorizationValue, codeExchangeBody, isFresh, readTokenResponse, redactSecrets, refreshBody, secretsIn, } from './token.js';
5
+ export { CREDENTIAL_MODE, classifyCredentialMode, classifyProbeReadings, isWiderThan, toOctal, } from './mode.js';
6
+ export { AUTH_FLOW_BLOCKED_CODES, AUTH_FLOW_BLOCKED_ERROR, deviceAuthorizationBody, devicePollBody, discardOnProtocolMismatch, isAuthFlowBlocked, readDeviceAuthorization, readDevicePoll, requireDeviceCodeEnabled, } from './device-code.js';
7
+ export { readCachedTokens } from './store.js';
8
+ export { AUTHORIZATION_HEADER, TokenCredential } from './credential.js';
9
+ export { PairedHostCredential, readPairedCredentialFile } from './paired-credential.js';
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Whether the token file on disk is actually as private as requested — decided here, purely, so
3
+ * every branch can be driven on any platform.
4
+ *
5
+ * The defect, observed on win32 rather than reasoned. On win32, `node` accepts a `0o600` mode and
6
+ * does nothing with it: writing with `{mode: 0o600}` and then `chmod`ing to `0o600` both leave
7
+ * `statSync().mode & 0o777` reading `0o666`, and a deliberately world-readable `0o666` file reads
8
+ * exactly the same. So on Windows a restricted token file and an unrestricted one are
9
+ * indistinguishable through the only API available.
10
+ *
11
+ * That makes the obvious implementation wrong in both directions, which is why this is a module and
12
+ * not an `if`:
13
+ *
14
+ * - Verify literally (`observed === 0o600`) and the host refuses every write on Windows — it
15
+ * would be unusable there.
16
+ * - Relax it to "not world-writable" and the check passes vacuously on Windows while proving
17
+ * nothing at all — a green that reads as evidence.
18
+ *
19
+ * So the third answer is the only honest one: where modes are enforced, verify and refuse if the
20
+ * file came out wider than asked. Where they are not, say so by name as a degrade. A degrade is
21
+ * a named outcome, never a silent pass — and "this could not be confirmed" is a fact an operator
22
+ * can act on, while a checkmark that means nothing is not.
23
+ *
24
+ * Enforcement is measured, not assumed from `process.platform`. The caller probes it (see
25
+ * `host/token-cache.ts`) and passes the answer in. A platform list would be a guess that ages;
26
+ * WSL, a POSIX filesystem mounted on Windows, or a future runtime that maps modes onto ACLs would
27
+ * each make the guess wrong in the direction that matters.
28
+ *
29
+ * And the measurement has an inconclusive state, which is why this is a three-way enum and not a
30
+ * boolean. A probe that reports "not enforced" is only worth anything if it could have reported
31
+ * something else. On win32, `chmod 0444` does read back as `444`, so the write bit is real even
32
+ * though the read/group/other bits are fabricated as `6`. That gives the probe a positive control
33
+ * — it can demonstrate it observes something before it claims it cannot observe privacy.
34
+ * Without that, "unenforced" and "my probe is broken" are the same answer, and an instrument with
35
+ * no inconclusive state reports confidence it has not earned.
36
+ */
37
+ import type { Refusal } from '../core/refusal.js';
38
+ /** The mode credential material is written with, everywhere. */
39
+ export declare const CREDENTIAL_MODE = 384;
40
+ /** What the filesystem was measured to do with a mode. */
41
+ export type ModeEnforcement =
42
+ /** POSIX modes are honoured: what was asked for is what came back, and wide differs from narrow. */
43
+ 'enforced'
44
+ /**
45
+ * Measured, and the answer is that only the write bit survives — the win32 shape. `0600` and
46
+ * `0666` are indistinguishable, so privacy cannot be confirmed; but `0444` reads back as `444`,
47
+ * so the instrument is live and this is a real finding rather than a dead probe.
48
+ */
49
+ | 'write-bit-only'
50
+ /**
51
+ * Inconclusive. No mode change was observable at all, not even clearing the write bit. The
52
+ * probe cannot distinguish "this filesystem records nothing" from "this probe is broken", and
53
+ * saying so is the only honest option.
54
+ */
55
+ | 'unobservable';
56
+ export type CredentialModeOutcome =
57
+ /** Modes are enforced here and the file is exactly as private as requested. */
58
+ {
59
+ readonly kind: 'verified';
60
+ readonly observed: number;
61
+ }
62
+ /** Modes are enforced here and the file is readable or writable by more than its owner. */
63
+ | {
64
+ readonly kind: 'too-wide';
65
+ readonly observed: number;
66
+ readonly refusal: Refusal;
67
+ }
68
+ /** Not a failure: privacy is unconfirmable here, and that was measured rather than assumed. */
69
+ | {
70
+ readonly kind: 'unenforced';
71
+ readonly observed: number;
72
+ readonly refusal: Refusal;
73
+ }
74
+ /** Not even a measurement: the instrument could observe nothing. Worth investigating. */
75
+ | {
76
+ readonly kind: 'unobservable';
77
+ readonly observed: number;
78
+ readonly refusal: Refusal;
79
+ };
80
+ /** Is any permission bit set that was not requested? */
81
+ export declare function isWiderThan(observed: number, requested: number): boolean;
82
+ /**
83
+ * Decide what three probe readings mean. Pure, so the shapes this machine cannot produce are still
84
+ * drivable.
85
+ *
86
+ * This function exists because the obvious control is vacuous. Without the `0444` reading, on
87
+ * win32 a probe that runs the control and a probe that merely assumes its answer both report
88
+ * `write-bit-only`, so nothing on that machine can tell them apart. What is uniquely lost is the
89
+ * ability to distinguish a filesystem that records only the write bit from one that records
90
+ * nothing — and that difference only shows up on a filesystem a win32 machine cannot produce on
91
+ * demand. Stating the readings as data is what makes it testable at all.
92
+ *
93
+ * narrow what `stat` reported after asking for `requested` (0600)
94
+ * wide what it reported after asking for 0666
95
+ * readOnly what it reported after clearing the write bit (0444) — the positive control
96
+ */
97
+ export declare function classifyProbeReadings(narrow: number, wide: number, readOnly: number, requested?: number): ModeEnforcement;
98
+ /**
99
+ * Classify what a verify-after-write actually found.
100
+ *
101
+ * `enforcement` is the measured answer to "what does this filesystem do with a mode at all".
102
+ */
103
+ export declare function classifyCredentialMode(observed: number, enforcement: ModeEnforcement, requested?: number): CredentialModeOutcome;
104
+ /** `0o600`, for a message a human reads. */
105
+ export declare function toOctal(mode: number): string;
106
+ //# sourceMappingURL=mode.d.ts.map
@@ -0,0 +1,69 @@
1
+ import { refusal } from '../core/refusal.js';
2
+ /** The mode credential material is written with, everywhere. */
3
+ export const CREDENTIAL_MODE = 0o600;
4
+ const PERMISSION_BITS = 0o777;
5
+ /** Is any permission bit set that was not requested? */
6
+ export function isWiderThan(observed, requested) {
7
+ return (observed & ~requested & PERMISSION_BITS) !== 0;
8
+ }
9
+ /**
10
+ * Decide what three probe readings mean. Pure, so the shapes this machine cannot produce are still
11
+ * drivable.
12
+ *
13
+ * This function exists because the obvious control is vacuous. Without the `0444` reading, on
14
+ * win32 a probe that runs the control and a probe that merely assumes its answer both report
15
+ * `write-bit-only`, so nothing on that machine can tell them apart. What is uniquely lost is the
16
+ * ability to distinguish a filesystem that records only the write bit from one that records
17
+ * nothing — and that difference only shows up on a filesystem a win32 machine cannot produce on
18
+ * demand. Stating the readings as data is what makes it testable at all.
19
+ *
20
+ * narrow what `stat` reported after asking for `requested` (0600)
21
+ * wide what it reported after asking for 0666
22
+ * readOnly what it reported after clearing the write bit (0444) — the positive control
23
+ */
24
+ export function classifyProbeReadings(narrow, wide, readOnly, requested = CREDENTIAL_MODE) {
25
+ const n = narrow & PERMISSION_BITS;
26
+ const w = wide & PERMISSION_BITS;
27
+ const r = readOnly & PERMISSION_BITS;
28
+ // Full POSIX: what was asked for came back, and asking for something wider changed the answer.
29
+ if (n === (requested & PERMISSION_BITS) && n !== w)
30
+ return 'enforced';
31
+ // Privacy is unconfirmable. Before saying so, the instrument must have shown it observes
32
+ // something — otherwise "unenforced" is indistinguishable from a dead probe.
33
+ return r !== w ? 'write-bit-only' : 'unobservable';
34
+ }
35
+ /**
36
+ * Classify what a verify-after-write actually found.
37
+ *
38
+ * `enforcement` is the measured answer to "what does this filesystem do with a mode at all".
39
+ */
40
+ export function classifyCredentialMode(observed, enforcement, requested = CREDENTIAL_MODE) {
41
+ const bits = observed & PERMISSION_BITS;
42
+ if (enforcement === 'unobservable') {
43
+ return {
44
+ kind: 'unobservable',
45
+ observed: bits,
46
+ refusal: refusal('credential-mode-unobservable', `mode ${toOctal(requested)} was requested and this filesystem reported no observable mode change of any kind — not even clearing the write bit — so nothing at all can be said about the token file's permissions here; this is an inconclusive instrument rather than a finding about privacy`),
47
+ };
48
+ }
49
+ if (enforcement === 'write-bit-only') {
50
+ return {
51
+ kind: 'unenforced',
52
+ observed: bits,
53
+ refusal: refusal('credential-mode-unenforced', `mode ${toOctal(requested)} was requested and this filesystem records only the write bit (it reports ${toOctal(bits)} here, and cannot distinguish owner-only from world-readable), so the token file's privacy cannot be confirmed — it is protected by the gate's credential-path denial and by the OS account, not by this mode. The probe did observe a mode change when the write bit was cleared, so this is a measurement rather than a dead check`),
54
+ };
55
+ }
56
+ if (isWiderThan(bits, requested)) {
57
+ return {
58
+ kind: 'too-wide',
59
+ observed: bits,
60
+ refusal: refusal('credential-mode-too-wide', `the token file was written with mode ${toOctal(bits)} after ${toOctal(requested)} was requested, so it is readable or writable by more than its owner`),
61
+ };
62
+ }
63
+ return { kind: 'verified', observed: bits };
64
+ }
65
+ /** `0o600`, for a message a human reads. */
66
+ export function toOctal(mode) {
67
+ return `0${(mode & PERMISSION_BITS).toString(8)}`;
68
+ }
69
+ //# sourceMappingURL=mode.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The paired-host credential — the durable identity a machine holds after `periscope pair`.
3
+ *
4
+ * The shape and its validation live here, not with the filesystem — the same split `store.ts`
5
+ * states for the token cache, for the same reason: `host/` may import this, and this may never
6
+ * import `host/`.
7
+ *
8
+ * This is not the OIDC token and not the RFC 8628 `device-code` flow, and the noun is chosen to
9
+ * keep the three apart. A paired credential is an opaque controller-minted bearer
10
+ * (`p1.<hostId>.<secret>`): it never expires on a clock, never refreshes, and dies only when the
11
+ * controller revokes it — at which point the upgrade answers 401 and the host's terminal path
12
+ * names it. That is what makes it survive the idle window after which a provider may let an OIDC
13
+ * refresh token lapse.
14
+ *
15
+ * It carries no user, and cannot. The controller derives the acting user from its own registry
16
+ * row; a host presenting this credential asserts nothing about who it acts for, which is the whole
17
+ * design — nothing in this file could smuggle an identity claim even by mistake, because there is
18
+ * no field to put one in.
19
+ */
20
+ import type { Authorization, ControllerCredential } from '../control/credential.js';
21
+ import type { Result } from '../core/result.js';
22
+ /** The persisted record — what `periscope pair` writes and the daemon reads. */
23
+ export interface PairedCredentialFile {
24
+ /** The host id the credential names. The daemon announces this id in `link_hello` — the
25
+ * controller refuses a hello naming anything else, so the file's copy is load-bearing. */
26
+ readonly hostId: string;
27
+ /** The full opaque credential, exactly as the controller minted it. */
28
+ readonly credential: string;
29
+ }
30
+ /** The port the pair verb writes through and the daemon reads through — an interface for the same
31
+ * reason `TokenStore` is one: every malformed-file branch is drivable without a disk. */
32
+ export interface PairedCredentialStore {
33
+ read(): Result<PairedCredentialFile>;
34
+ write(file: PairedCredentialFile): Result<unknown>;
35
+ }
36
+ /** Validate a parsed file into the shape the daemon relies on. */
37
+ export declare function readPairedCredentialFile(parsed: unknown): Result<PairedCredentialFile>;
38
+ /**
39
+ * The `ControllerCredential` a paired host presents — on the link, the decision POST and the bulk
40
+ * POST alike, through the one spread point in the composition root.
41
+ *
42
+ * No refresh, no expiry, no reporter, and each absence is the design: the credential is a stable
43
+ * secret whose validity lives server-side, so there is exactly one outcome here and nothing to
44
+ * distinguish. Revocation surfaces as the controller's 401, which the link already classifies as
45
+ * terminal `link-unauthorized` — this class cannot see it coming and must not pretend to.
46
+ */
47
+ export declare class PairedHostCredential implements ControllerCredential {
48
+ #private;
49
+ constructor(file: PairedCredentialFile);
50
+ authorize(): Promise<Result<Authorization>>;
51
+ }
52
+ //# sourceMappingURL=paired-credential.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ import { AUTHORIZATION_HEADER } from './credential.js';
3
+ /** Validate a parsed file into the shape the daemon relies on. */
4
+ export function readPairedCredentialFile(parsed) {
5
+ if (typeof parsed !== 'object' || parsed === null) {
6
+ return refuse('credential-cache-unreadable', 'the paired credential file is not a JSON object');
7
+ }
8
+ const record = parsed;
9
+ const hostId = record['hostId'];
10
+ const credential = record['credential'];
11
+ if (typeof hostId !== 'string' || hostId === '') {
12
+ return refuse('credential-cache-unreadable', 'the paired credential file names no hostId');
13
+ }
14
+ if (typeof credential !== 'string' || credential === '') {
15
+ return refuse('credential-cache-unreadable', 'the paired credential file carries no credential');
16
+ }
17
+ // The credential embeds the host id it speaks for; a file whose two copies disagree would make
18
+ // this host announce one identity and authenticate as another — the exact mismatch the
19
+ // controller's hello binding exists to refuse. Caught here, where the fix (re-pair) is printable.
20
+ if (!credential.startsWith(`p1.${hostId}.`)) {
21
+ return refuse('credential-cache-unreadable', 'the paired credential does not match the hostId beside it — the file is corrupt; run: periscope pair <code>');
22
+ }
23
+ return ok({ hostId, credential });
24
+ }
25
+ /**
26
+ * The `ControllerCredential` a paired host presents — on the link, the decision POST and the bulk
27
+ * POST alike, through the one spread point in the composition root.
28
+ *
29
+ * No refresh, no expiry, no reporter, and each absence is the design: the credential is a stable
30
+ * secret whose validity lives server-side, so there is exactly one outcome here and nothing to
31
+ * distinguish. Revocation surfaces as the controller's 401, which the link already classifies as
32
+ * terminal `link-unauthorized` — this class cannot see it coming and must not pretend to.
33
+ */
34
+ export class PairedHostCredential {
35
+ #value;
36
+ constructor(file) {
37
+ this.#value = `Bearer ${file.credential}`;
38
+ }
39
+ authorize() {
40
+ return Promise.resolve(ok({ header: AUTHORIZATION_HEADER, value: this.#value }));
41
+ }
42
+ }
43
+ //# sourceMappingURL=paired-credential.js.map
@@ -0,0 +1,50 @@
1
+ import type { Result } from '../core/result.js';
2
+ /**
3
+ * The only challenge method this host will use.
4
+ *
5
+ * RFC 7636 also defines `plain`, where the challenge is the verifier. This host refuses it rather
6
+ * than merely not offering it — an unused branch is one a later reader restores on a compatibility
7
+ * report, and `plain` gives away the whole property to anyone who can observe the authorization
8
+ * request.
9
+ */
10
+ export declare const CODE_CHALLENGE_METHOD = "S256";
11
+ export interface Pkce {
12
+ readonly verifier: string;
13
+ readonly challenge: string;
14
+ readonly method: typeof CODE_CHALLENGE_METHOD;
15
+ }
16
+ /**
17
+ * A cryptographically random, URL-safe string.
18
+ *
19
+ * `randomBytes`, never `Math.random`. `Math.random` is seeded, predictable and explicitly not for
20
+ * this; a predictable verifier or state defeats the property entirely while looking identical in
21
+ * every test.
22
+ */
23
+ export declare function randomUrlSafe(bytes?: number): string;
24
+ /** A fresh verifier and its S256 challenge. */
25
+ export declare function createPkce(): Pkce;
26
+ /** The S256 challenge for a verifier: base64url(SHA-256(ascii(verifier))). */
27
+ export declare function challengeFor(verifier: string): string;
28
+ /**
29
+ * The `state` value for one authorization request.
30
+ *
31
+ * Same entropy source as the verifier and deliberately a separate value — reusing the verifier as
32
+ * state would put it in a URL the browser and the provider both log, and the verifier is the one
33
+ * that must never leave this process until the token exchange.
34
+ */
35
+ export declare function createState(): string;
36
+ /**
37
+ * Refuse a challenge method this host will not use.
38
+ *
39
+ * Exported so the refusal is reachable and testable, rather than being an `if` nobody can drive.
40
+ */
41
+ export declare function requireS256(method: string): Result<typeof CODE_CHALLENGE_METHOD>;
42
+ /**
43
+ * Compare two `state` values without leaking their contents through timing.
44
+ *
45
+ * The length check is separate and deliberate: `timingSafeEqual` throws on unequal lengths, so
46
+ * comparing lengths first is required rather than an optimisation. It leaks the length and nothing
47
+ * else, which is the standard accepted residual.
48
+ */
49
+ export declare function stateMatches(expected: string, received: string): boolean;
50
+ //# sourceMappingURL=pkce.d.ts.map