@agentxm/registry-auth 0.28.11 → 0.28.13

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 (42) hide show
  1. package/dist/src/auth-client.d.ts +13 -5
  2. package/dist/src/auth-client.js +80 -39
  3. package/dist/src/credential-store.d.ts +39 -2
  4. package/dist/src/credential-store.js +50 -46
  5. package/dist/src/device-login.d.ts +20 -8
  6. package/dist/src/device-login.js +9 -4
  7. package/dist/src/errors.d.ts +37 -12
  8. package/dist/src/errors.js +20 -0
  9. package/dist/src/identity.d.ts +46 -0
  10. package/dist/src/identity.js +49 -0
  11. package/dist/src/index.d.ts +12 -2
  12. package/dist/src/index.js +14 -1
  13. package/dist/src/internal/environment.d.ts +23 -5
  14. package/dist/src/internal/environment.js +59 -9
  15. package/dist/src/live.d.ts +1 -0
  16. package/dist/src/live.js +1 -0
  17. package/dist/src/login-presenter.d.ts +55 -0
  18. package/dist/src/login-presenter.js +22 -0
  19. package/dist/src/login-suggestion.d.ts +18 -0
  20. package/dist/src/login-suggestion.js +34 -0
  21. package/dist/src/login.d.ts +80 -0
  22. package/dist/src/login.js +155 -0
  23. package/dist/src/logout.d.ts +35 -0
  24. package/dist/src/logout.js +37 -0
  25. package/dist/src/loopback-login.js +2 -1
  26. package/dist/src/loopback-server.d.ts +3 -1
  27. package/dist/src/loopback-server.js +51 -7
  28. package/dist/src/pending-publish-authorization-store.d.ts +33 -0
  29. package/dist/src/pending-publish-authorization-store.js +90 -0
  30. package/dist/src/publish-authorization-polling.d.ts +13 -0
  31. package/dist/src/publish-authorization-polling.js +176 -0
  32. package/dist/src/publish-authorization.d.ts +4 -1
  33. package/dist/src/publish-authorization.js +24 -5
  34. package/dist/src/selected-registry.d.ts +25 -0
  35. package/dist/src/selected-registry.js +18 -0
  36. package/dist/src/step-up.d.ts +49 -0
  37. package/dist/src/step-up.js +174 -0
  38. package/dist/src/testing.d.ts +1 -0
  39. package/dist/src/testing.js +1 -0
  40. package/dist/src/tokens.d.ts +57 -0
  41. package/dist/src/tokens.js +78 -0
  42. package/package.json +7 -6
@@ -0,0 +1,176 @@
1
+ import { DeviceLoginInteraction } from "./device-login.js";
2
+ import { AuthLoginPresenter } from "./login-presenter.js";
3
+ import * as DateTime from "effect/DateTime";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Option from "effect/Option";
6
+ import { publicationSetDigest } from "@agentxm/registry-protocol/unstable/registry/publication-set";
7
+ import { AuthClient } from "./auth-client.js";
8
+ import { PublishAuthorizationPending, RegistryAuthFailed } from "./errors.js";
9
+ import { makePkceChallenge, makePkceVerifier } from "./loopback-login.js";
10
+ import { PendingPublishAuthorizationStore, } from "./pending-publish-authorization-store.js";
11
+ const invalidResume = (detail) => new RegistryAuthFailed({
12
+ category: "validation",
13
+ detail,
14
+ recover: "Resume with the original publication inputs and requestRef. To request new consent, rerun publish without --authorization-request.",
15
+ });
16
+ export const readPublishAuthorizationReference = (reference, registryUrl) => Effect.try({
17
+ try: () => {
18
+ const url = new URL(reference);
19
+ const registry = new URL(registryUrl);
20
+ const match = /^\/v1\/auth\/publish-requests\/(pubreq_[a-z0-9]{26})$/.exec(url.pathname);
21
+ if (url.origin !== registry.origin ||
22
+ url.username ||
23
+ url.password ||
24
+ url.search ||
25
+ url.hash ||
26
+ match?.[1] === undefined)
27
+ throw new Error("Invalid reference");
28
+ return { requestId: match[1], requestRef: url.href };
29
+ },
30
+ catch: () => invalidResume("The authorization request URL must identify a publish request on the selected Registry."),
31
+ });
32
+ const pendingHuman = (pending, timedOut = false) => new PublishAuthorizationPending({
33
+ timedOut,
34
+ action: {
35
+ kind: "open-url",
36
+ purpose: "publish",
37
+ registryUrl: pending.registryUrl,
38
+ requestRef: pending.requestRef,
39
+ url: pending.authorizationUrl,
40
+ expiresAt: DateTime.formatIso(pending.expiresAt),
41
+ intervalSeconds: pending.interval,
42
+ resume: `Rerun the same publish command with unchanged inputs and --authorization-request ${pending.requestRef}. Add --wait-for-human SECONDS for a bounded wait.`,
43
+ },
44
+ });
45
+ export const runPollingPublishAuthorization = Effect.fn("Auth.runPollingPublishAuthorization")(function* (input) {
46
+ const auth = yield* AuthClient;
47
+ const store = yield* PendingPublishAuthorizationStore;
48
+ const expectedDigest = publicationSetDigest(input.publicationSet.candidates);
49
+ const pending = yield* Effect.gen(function* () {
50
+ if (input.authorizationRequest !== undefined) {
51
+ const reference = yield* readPublishAuthorizationReference(input.authorizationRequest, input.registryUrl);
52
+ const saved = yield* store.load(reference.requestRef);
53
+ if (Option.isNone(saved))
54
+ return yield* invalidResume("The private proof for this publish request is unavailable on this machine. A public URL alone cannot resume it.");
55
+ if (saved.value.registryUrl !== input.registryUrl ||
56
+ saved.value.requestId !== reference.requestId ||
57
+ saved.value.publicationSetDigest !== expectedDigest)
58
+ return yield* invalidResume("The publication set, archive bytes, visibility inputs or Registry changed. This request cannot authorize the changed material; review a new request.");
59
+ return saved.value;
60
+ }
61
+ const initiatorProof = makePkceVerifier();
62
+ const request = yield* auth.createPublishAuthorizationRequest({
63
+ registryUrl: input.registryUrl,
64
+ publicationSet: input.publicationSet,
65
+ delivery: {
66
+ kind: "polling",
67
+ proof_challenge: makePkceChallenge(initiatorProof),
68
+ proof_challenge_method: "S256",
69
+ },
70
+ });
71
+ const reference = yield* readPublishAuthorizationReference(`${input.registryUrl.replace(/\/+$/, "")}/v1/auth/publish-requests/${request.requestId}`, input.registryUrl);
72
+ const value = {
73
+ version: 1,
74
+ purpose: "publish",
75
+ registryUrl: input.registryUrl,
76
+ ...reference,
77
+ authorizationUrl: request.authorizationUrl,
78
+ expiresAt: request.expiresAt,
79
+ interval: request.interval,
80
+ publicationSetDigest: expectedDigest,
81
+ initiatorProof,
82
+ };
83
+ yield* store.save(value);
84
+ return value;
85
+ });
86
+ const expired = () => new RegistryAuthFailed({
87
+ category: "auth_expired",
88
+ detail: "This publish authorization expired. Review a new request; no capability was acquired.",
89
+ recover: "Rerun publish without --authorization-request to request new consent.",
90
+ });
91
+ const now = yield* DateTime.now;
92
+ const remaining = DateTime.toEpochMillis(pending.expiresAt) - DateTime.toEpochMillis(now);
93
+ if (remaining <= 0) {
94
+ yield* store.clear(pending.requestRef);
95
+ return yield* expired();
96
+ }
97
+ if (input.authorizationRequest === undefined &&
98
+ input.waitForHumanSeconds === undefined &&
99
+ input.unattended === true)
100
+ return yield* pendingHuman(pending);
101
+ if (input.authorizationRequest === undefined && input.unattended !== true) {
102
+ const interaction = yield* DeviceLoginInteraction;
103
+ const presenter = yield* AuthLoginPresenter;
104
+ const browserOpened = yield* interaction.openBrowser(pending.authorizationUrl);
105
+ yield* presenter.notePublishReview({
106
+ browserOpened,
107
+ candidateCount: input.publicationSet.candidates.length,
108
+ authorizationUrl: pending.authorizationUrl,
109
+ });
110
+ }
111
+ const wait = Effect.gen(function* () {
112
+ while (true) {
113
+ const status = yield* auth.pollPublishAuthorization({
114
+ registryUrl: pending.registryUrl,
115
+ requestId: pending.requestId,
116
+ initiatorProof: pending.initiatorProof,
117
+ });
118
+ if (status.purpose !== "publish" ||
119
+ status.publication_set_digest !== expectedDigest ||
120
+ DateTime.toEpochMillis(status.expires_at) !== DateTime.toEpochMillis(pending.expiresAt))
121
+ return yield* invalidResume("The Registry returned a different publication binding or deadline. This request cannot be resumed.");
122
+ if (status.status === "expired") {
123
+ yield* store.clear(pending.requestRef);
124
+ return yield* expired();
125
+ }
126
+ if (status.status === "denied") {
127
+ yield* store.clear(pending.requestRef);
128
+ return yield* new RegistryAuthFailed({
129
+ category: "auth_denied",
130
+ detail: "Publish authorization was cancelled. No publication was authorized.",
131
+ recover: "Request new consent by rerunning publish without --authorization-request.",
132
+ });
133
+ }
134
+ if (status.status === "exchanged") {
135
+ yield* store.clear(pending.requestRef);
136
+ return yield* new RegistryAuthFailed({
137
+ category: "conflict",
138
+ detail: "This publish authorization was already exchanged. Verify the prior publication results before requesting new consent; the capabilities cannot be replayed.",
139
+ recover: "Rerun publish with the original inputs to verify existing versions and request consent for any remaining material.",
140
+ });
141
+ }
142
+ if (status.status === "approved")
143
+ return;
144
+ if (input.unattended === true && input.waitForHumanSeconds === undefined)
145
+ return yield* pendingHuman(pending);
146
+ const current = yield* DateTime.now;
147
+ const left = DateTime.toEpochMillis(pending.expiresAt) - DateTime.toEpochMillis(current);
148
+ if (left <= 0)
149
+ return yield* expired();
150
+ yield* Effect.sleep(Math.min(status.interval * 1000, left));
151
+ }
152
+ });
153
+ const bound = Math.min(remaining, input.waitForHumanSeconds === undefined ? remaining : input.waitForHumanSeconds * 1000);
154
+ const result = yield* wait.pipe(Effect.timeoutOption(bound));
155
+ if (Option.isSome(result)) {
156
+ // A lost exchange response is not retried: a later resume observes the
157
+ // retained request and takes explicit publication-outcome recovery.
158
+ const exchange = yield* auth.exchangePublishAuthorization({
159
+ registryUrl: pending.registryUrl,
160
+ requestId: pending.requestId,
161
+ initiatorProof: pending.initiatorProof,
162
+ });
163
+ if (exchange.preview.publicationSetDigest !== expectedDigest ||
164
+ exchange.grants.some((grant) => grant.publishRequestId !== pending.requestId ||
165
+ grant.publicationSetDigest !== expectedDigest))
166
+ return yield* invalidResume("The exchanged capabilities do not match the reviewed publication set.");
167
+ return exchange;
168
+ }
169
+ const afterWait = yield* DateTime.now;
170
+ if (DateTime.toEpochMillis(pending.expiresAt) <= DateTime.toEpochMillis(afterWait)) {
171
+ yield* store.clear(pending.requestRef);
172
+ return yield* expired();
173
+ }
174
+ return yield* pendingHuman(pending, true);
175
+ }, Effect.satisfiesSuccessType());
176
+ //# sourceMappingURL=publish-authorization-polling.js.map
@@ -6,6 +6,9 @@ import { AuthLoginPresenter } from "./login-presenter.js";
6
6
  export interface PublishAuthorizationInput {
7
7
  readonly registryUrl: string;
8
8
  readonly publicationSet: PreviewPublicationSetRequest;
9
+ readonly unattended?: boolean;
10
+ readonly authorizationRequest?: string;
11
+ readonly waitForHumanSeconds?: number;
9
12
  }
10
- export declare const runPublishAuthorization: (input: PublishAuthorizationInput) => Effect.Effect<PublishAuthorizationExchangeResponse, import("./errors.js").AuthError, AuthClient | AuthLoginPresenter | DeviceLoginInteraction>;
13
+ export declare const runPublishAuthorization: (input: PublishAuthorizationInput) => Effect.Effect<PublishAuthorizationExchangeResponse, import("./errors.js").AuthError, AuthClient | AuthLoginPresenter | DeviceLoginInteraction | import("./pending-publish-authorization-store.ts").PendingPublishAuthorizationStore>;
11
14
  //# sourceMappingURL=publish-authorization.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { runPollingPublishAuthorization } from "./publish-authorization-polling.js";
1
2
  import * as Effect from "effect/Effect";
2
3
  import { RegistryAuthFailed } from "./errors.js";
3
4
  import { AuthClient } from "./auth-client.js";
@@ -35,6 +36,16 @@ const loopbackFailureToAuthFailure = (error) => {
35
36
  });
36
37
  };
37
38
  export const runPublishAuthorization = Effect.fn("Auth.runPublishAuthorization")(function* (input) {
39
+ if (input.waitForHumanSeconds !== undefined &&
40
+ (!Number.isSafeInteger(input.waitForHumanSeconds) || input.waitForHumanSeconds <= 0))
41
+ return yield* new RegistryAuthFailed({
42
+ category: "validation",
43
+ detail: "--wait-for-human must be a positive whole number of seconds.",
44
+ });
45
+ if (input.unattended === true ||
46
+ input.authorizationRequest !== undefined ||
47
+ input.waitForHumanSeconds !== undefined)
48
+ return yield* runPollingPublishAuthorization(input);
38
49
  return yield* Effect.scoped(Effect.gen(function* () {
39
50
  const authClient = yield* AuthClient;
40
51
  const presenter = yield* AuthLoginPresenter;
@@ -42,12 +53,16 @@ export const runPublishAuthorization = Effect.fn("Auth.runPublishAuthorization")
42
53
  const verifier = makePkceVerifier();
43
54
  const challenge = makePkceChallenge(verifier);
44
55
  const state = makeOAuthState();
45
- const server = yield* startLoopbackServer(state).pipe(Effect.mapError(loopbackFailureToAuthFailure));
56
+ const server = yield* startLoopbackServer(state, "publish").pipe(Effect.mapError(loopbackFailureToAuthFailure));
46
57
  const request = yield* authClient.createPublishAuthorizationRequest({
47
58
  registryUrl: input.registryUrl,
48
- redirectUri: server.redirectUri,
49
- state,
50
- codeChallenge: challenge,
59
+ delivery: {
60
+ kind: "loopback",
61
+ redirect_uri: server.redirectUri,
62
+ state,
63
+ code_challenge: challenge,
64
+ code_challenge_method: "S256",
65
+ },
51
66
  publicationSet: input.publicationSet,
52
67
  });
53
68
  const openedBrowser = yield* interaction.openBrowser(request.authorizationUrl);
@@ -57,7 +72,9 @@ export const runPublishAuthorization = Effect.fn("Auth.runPublishAuthorization")
57
72
  authorizationUrl: request.authorizationUrl,
58
73
  });
59
74
  const callback = yield* server
60
- .awaitCallback(PUBLISH_AUTHORIZATION_TIMEOUT_MS)
75
+ .awaitCallback(Math.min(PUBLISH_AUTHORIZATION_TIMEOUT_MS, input.waitForHumanSeconds === undefined
76
+ ? PUBLISH_AUTHORIZATION_TIMEOUT_MS
77
+ : input.waitForHumanSeconds * 1000))
61
78
  .pipe(Effect.mapError(loopbackFailureToAuthFailure));
62
79
  const expectedIssuer = new URL(request.authorizationUrl).origin;
63
80
  if (callback.iss !== expectedIssuer) {
@@ -73,6 +90,8 @@ export const runPublishAuthorization = Effect.fn("Auth.runPublishAuthorization")
73
90
  verifier,
74
91
  redirectUri: server.redirectUri,
75
92
  });
93
+ if (capability.status === "admitted")
94
+ yield* server.complete;
76
95
  return capability;
77
96
  }));
78
97
  }, Effect.satisfiesSuccessType());
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The Registry this invocation authenticates against.
3
+ *
4
+ * Which Registry is selected is transport configuration, so the value itself
5
+ * belongs to the Registry client. What a caller needs from it is an
6
+ * authentication concern: the endpoint every use case in this package takes,
7
+ * and the host a person is told they are signing in to, signing out of, or
8
+ * identified on. Publishing both here is what lets a caller name the selected
9
+ * Registry without reaching into the transport package for its configuration
10
+ * tag.
11
+ *
12
+ * @experimental This API is unstable and may change without notice.
13
+ */
14
+ import * as Effect from "effect/Effect";
15
+ import { RegistryUrl } from "@agentxm/registry-client";
16
+ /** The selected Registry, as an endpoint and as a host a person reads. */
17
+ export interface SelectedRegistry {
18
+ /** The endpoint every registry-auth use case takes. */
19
+ readonly url: string;
20
+ /** The host, for the operation names and result documents that show it. */
21
+ readonly host: string;
22
+ }
23
+ /** Read the selected Registry's endpoint and its human-readable host. */
24
+ export declare const selectedRegistry: Effect.Effect<SelectedRegistry, never, RegistryUrl>;
25
+ //# sourceMappingURL=selected-registry.d.ts.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The Registry this invocation authenticates against.
3
+ *
4
+ * Which Registry is selected is transport configuration, so the value itself
5
+ * belongs to the Registry client. What a caller needs from it is an
6
+ * authentication concern: the endpoint every use case in this package takes,
7
+ * and the host a person is told they are signing in to, signing out of, or
8
+ * identified on. Publishing both here is what lets a caller name the selected
9
+ * Registry without reaching into the transport package for its configuration
10
+ * tag.
11
+ *
12
+ * @experimental This API is unstable and may change without notice.
13
+ */
14
+ import * as Effect from "effect/Effect";
15
+ import { RegistryUrl } from "@agentxm/registry-client";
16
+ /** Read the selected Registry's endpoint and its human-readable host. */
17
+ export const selectedRegistry = Effect.map(RegistryUrl, (url) => ({ url, host: new URL(url).host }));
18
+ //# sourceMappingURL=selected-registry.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Step-up verification: the protocol that carries one challenged Registry
3
+ * write through human verification and retries it exactly once.
4
+ *
5
+ * The capability owns challenge detection, request-reference validation,
6
+ * resumption and terminal-status rules, expiry, the bounded wait, and the
7
+ * retry-once contract. The application supplies only presentation and browser
8
+ * launch through the login presenter and interaction ports.
9
+ *
10
+ * @experimental This API is unstable and may change without notice.
11
+ */
12
+ import * as Effect from "effect/Effect";
13
+ import { AuthClient } from "./auth-client.js";
14
+ import { CredentialStore } from "./credential-store.js";
15
+ import { StepUpVerificationPending, type AuthError } from "./errors.js";
16
+ import { AuthLoginInteraction } from "./login-interaction.js";
17
+ import { AuthLoginPresenter } from "./login-presenter.js";
18
+ /** How one challenged write names itself to a person watching the terminal. */
19
+ export interface StepUpPresentation {
20
+ /** Lifecycle label for the challenged write itself. */
21
+ readonly operationLabel: string;
22
+ /** Lifecycle label for the bounded wait on human verification. */
23
+ readonly waitingLabel: string;
24
+ }
25
+ /** The invocation's verification inputs, parsed from the command line. */
26
+ export interface StepUpOptions {
27
+ /** Resume this exact pending request instead of issuing a fresh challenge. */
28
+ readonly resumeReference?: string;
29
+ /** Bounded wait, in whole seconds, for the person to verify. */
30
+ readonly waitForHumanSeconds?: number;
31
+ /** No terminal is available to guide a person through verification. */
32
+ readonly unattended: boolean;
33
+ }
34
+ /** A challenged write that completed, and whether verification was needed. */
35
+ export interface VerifiedWrite<A> {
36
+ readonly value: A;
37
+ readonly stepUpCompleted: boolean;
38
+ }
39
+ /**
40
+ * Run a Registry write that may be challenged, carry the challenge through
41
+ * human verification, and retry the write exactly once with the verified
42
+ * request id.
43
+ *
44
+ * The write is never replayed without verification, and a wait that elapses
45
+ * while the request is still valid resolves to `StepUpVerificationPending`
46
+ * rather than a failure of the write.
47
+ */
48
+ export declare const runWithStepUp: <A, E, R>(operation: (stepUpRequestId?: string) => Effect.Effect<A, E, R>, presentation: StepUpPresentation, options: StepUpOptions, registryUrl: string) => Effect.Effect<VerifiedWrite<A>, E | AuthError | StepUpVerificationPending, R | AuthClient | AuthLoginInteraction | AuthLoginPresenter | CredentialStore>;
49
+ //# sourceMappingURL=step-up.d.ts.map
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Step-up verification: the protocol that carries one challenged Registry
3
+ * write through human verification and retries it exactly once.
4
+ *
5
+ * The capability owns challenge detection, request-reference validation,
6
+ * resumption and terminal-status rules, expiry, the bounded wait, and the
7
+ * retry-once contract. The application supplies only presentation and browser
8
+ * launch through the login presenter and interaction ports.
9
+ *
10
+ * @experimental This API is unstable and may change without notice.
11
+ */
12
+ import * as Clock from "effect/Clock";
13
+ import * as DateTime from "effect/DateTime";
14
+ import * as Duration from "effect/Duration";
15
+ import * as Effect from "effect/Effect";
16
+ import * as Option from "effect/Option";
17
+ import * as Result from "effect/Result";
18
+ import { isRegistryClientFailure } from "@agentxm/registry-client";
19
+ import { AuthClient, readStepUpRequest } from "./auth-client.js";
20
+ import { CredentialStore } from "./credential-store.js";
21
+ import { authLoginRequired, RegistryAuthFailed, StepUpRequired, StepUpVerificationPending, } from "./errors.js";
22
+ import { AuthLoginInteraction } from "./login-interaction.js";
23
+ import { AuthLoginPresenter } from "./login-presenter.js";
24
+ import { resolveRequiredToken } from "./token-resolution.js";
25
+ const invalidReference = (detail, recover) => new RegistryAuthFailed({
26
+ category: "validation",
27
+ detail,
28
+ ...(recover === undefined ? {} : { recover }),
29
+ });
30
+ /** Request references may only address the selected Registry's step-up resource. */
31
+ const readRequestReference = (reference, registryUrl) => Effect.try({
32
+ try: () => {
33
+ const url = new URL(reference);
34
+ const registry = new URL(registryUrl);
35
+ const match = /^\/v1\/auth\/step-up\/requests\/(step_[a-z0-9]+)$/.exec(url.pathname);
36
+ if (url.origin !== registry.origin ||
37
+ url.username ||
38
+ url.password ||
39
+ url.search ||
40
+ url.hash ||
41
+ match?.[1] === undefined)
42
+ throw new Error("Invalid step-up reference");
43
+ return match[1];
44
+ },
45
+ catch: () => invalidReference("The step-up request URL must identify a step-up request on the selected Registry.", "Use the requestRef from this Registry's pending-human result."),
46
+ });
47
+ const pendingVerification = (stepUp, registryUrl, timedOut) => new StepUpVerificationPending({
48
+ timedOut,
49
+ action: {
50
+ kind: "open-url",
51
+ purpose: "step-up",
52
+ requestRef: stepUp.statusUrl,
53
+ registryUrl,
54
+ url: stepUp.verificationUrl,
55
+ expiresAt: stepUp.expiresAt,
56
+ intervalSeconds: stepUp.intervalSeconds,
57
+ resume: `Rerun the same command with the same inputs and --step-up-request ${stepUp.statusUrl}. Add --wait-for-human SECONDS for a bounded wait.`,
58
+ },
59
+ });
60
+ /** The challenge a failure carries, whether typed or still on the wire. */
61
+ const challengeOf = (failure) => failure instanceof StepUpRequired
62
+ ? failure.stepUp
63
+ : isRegistryClientFailure(failure)
64
+ ? readStepUpRequest(failure)
65
+ : null;
66
+ /**
67
+ * Run a Registry write that may be challenged, carry the challenge through
68
+ * human verification, and retry the write exactly once with the verified
69
+ * request id.
70
+ *
71
+ * The write is never replayed without verification, and a wait that elapses
72
+ * while the request is still valid resolves to `StepUpVerificationPending`
73
+ * rather than a failure of the write.
74
+ */
75
+ export const runWithStepUp = (operation, presentation, options, registryUrl) => Effect.gen(function* () {
76
+ const authClient = yield* AuthClient;
77
+ const interaction = yield* AuthLoginInteraction;
78
+ const presenter = yield* AuthLoginPresenter;
79
+ const waitSeconds = options.waitForHumanSeconds;
80
+ if (waitSeconds !== undefined && (!Number.isSafeInteger(waitSeconds) || waitSeconds <= 0)) {
81
+ return yield* invalidReference("--wait-for-human must be a positive number of seconds.");
82
+ }
83
+ const resumedId = options.resumeReference === undefined
84
+ ? undefined
85
+ : yield* readRequestReference(options.resumeReference, registryUrl);
86
+ const initial = resumedId === undefined
87
+ ? yield* presenter.withProgress({ _tag: "RunningVerifiedWrite", operation: presentation.operationLabel }, () => Effect.result(operation()))
88
+ : undefined;
89
+ if (initial !== undefined && Result.isSuccess(initial)) {
90
+ return { value: initial.success, stepUpCompleted: false };
91
+ }
92
+ const challenge = initial !== undefined && Result.isFailure(initial) ? challengeOf(initial.failure) : null;
93
+ if (initial !== undefined && Result.isFailure(initial) && challenge === null) {
94
+ return yield* Effect.fail(initial.failure);
95
+ }
96
+ const token = yield* resolveRequiredToken(registryUrl, {
97
+ missingTokenError: authLoginRequired("Not authenticated"),
98
+ });
99
+ const resumed = resumedId === undefined
100
+ ? undefined
101
+ : yield* authClient.getStepUpRequest(token.token, resumedId);
102
+ if (resumed !== undefined && resumed.status !== "pending" && resumed.status !== "verified") {
103
+ return yield* new RegistryAuthFailed({
104
+ category: resumed.status === "expired"
105
+ ? "auth_expired"
106
+ : resumed.status === "cancelled"
107
+ ? "auth_denied"
108
+ : "conflict",
109
+ detail: `The step-up request is ${resumed.status}. It cannot be resumed.`,
110
+ recover: "Review the result of the previous request before explicitly starting a new command without --step-up-request.",
111
+ });
112
+ }
113
+ const stepUp = challenge ??
114
+ (resumed !== undefined && resumedId !== undefined
115
+ ? {
116
+ requestId: resumedId,
117
+ statusUrl: new URL(`/v1/auth/step-up/requests/${resumedId}`, registryUrl).href,
118
+ verificationUrl: new URL(`/step-up/${resumedId}`, authClient.getAuthorizationIssuer())
119
+ .href,
120
+ expiresAt: DateTime.formatIso(resumed.expires_at),
121
+ intervalSeconds: 2,
122
+ action: presentation.operationLabel,
123
+ target: presentation.operationLabel,
124
+ }
125
+ : null);
126
+ if (stepUp === null) {
127
+ return yield* new RegistryAuthFailed({
128
+ category: "auth",
129
+ detail: "The Registry did not return a verification request.",
130
+ });
131
+ }
132
+ const challengeId = yield* readRequestReference(stepUp.statusUrl, registryUrl);
133
+ if (challengeId !== stepUp.requestId) {
134
+ return yield* invalidReference("The Registry returned mismatched step-up references.");
135
+ }
136
+ const now = yield* Clock.currentTimeMillis;
137
+ const remaining = Date.parse(stepUp.expiresAt) - now;
138
+ if (!Number.isFinite(remaining) || remaining <= 0) {
139
+ return yield* new RegistryAuthFailed({
140
+ category: "auth_expired",
141
+ detail: "The step-up request has expired.",
142
+ });
143
+ }
144
+ if (resumed?.status !== "verified") {
145
+ if (options.unattended && waitSeconds === undefined) {
146
+ return yield* pendingVerification(stepUp, registryUrl, false);
147
+ }
148
+ const opened = options.unattended
149
+ ? false
150
+ : yield* interaction.openBrowser(stepUp.verificationUrl);
151
+ yield* presenter.presentStepUpChallenge({
152
+ action: stepUp.action,
153
+ target: stepUp.target,
154
+ verificationUrl: stepUp.verificationUrl,
155
+ expiresAt: stepUp.expiresAt,
156
+ browserOpened: opened,
157
+ });
158
+ const waited = yield* presenter.withProgress({ _tag: "WaitingForHumanVerification", operation: presentation.waitingLabel }, () => authClient
159
+ .waitForStepUpRequest(token.token, stepUp.statusUrl, stepUp.intervalSeconds)
160
+ .pipe(Effect.timeoutOption(Duration.millis(Math.min(remaining, waitSeconds === undefined ? remaining : waitSeconds * 1000)))));
161
+ if (Option.isNone(waited)) {
162
+ if ((yield* Clock.currentTimeMillis) >= Date.parse(stepUp.expiresAt)) {
163
+ return yield* new RegistryAuthFailed({
164
+ category: "auth_expired",
165
+ detail: "The step-up request has expired.",
166
+ });
167
+ }
168
+ return yield* pendingVerification(stepUp, registryUrl, true);
169
+ }
170
+ }
171
+ const value = yield* presenter.withProgress({ _tag: "RetryingVerifiedWrite", operation: presentation.operationLabel }, () => operation(stepUp.requestId));
172
+ return { value, stepUpCompleted: true };
173
+ });
174
+ //# sourceMappingURL=step-up.js.map
@@ -11,4 +11,5 @@ export { DeviceLoginInteractionTest, type DeviceLoginInteractionTestState, } fro
11
11
  export { AuthLoginInteractionTest, type AuthLoginInteractionTestState, } from "./login-interaction.js";
12
12
  export { AuthLoginPresenterTest, type AuthLoginPresenterTestState } from "./login-presenter.js";
13
13
  export { PendingDeviceLoginStoreTest } from "./pending-device-login-store.js";
14
+ export { PendingPublishAuthorizationStoreTest } from "./pending-publish-authorization-store.js";
14
15
  //# sourceMappingURL=testing.d.ts.map
@@ -11,4 +11,5 @@ export { DeviceLoginInteractionTest, } from "./device-login.js";
11
11
  export { AuthLoginInteractionTest, } from "./login-interaction.js";
12
12
  export { AuthLoginPresenterTest } from "./login-presenter.js";
13
13
  export { PendingDeviceLoginStoreTest } from "./pending-device-login-store.js";
14
+ export { PendingPublishAuthorizationStoreTest } from "./pending-publish-authorization-store.js";
14
15
  //# sourceMappingURL=testing.js.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Granular access-token policy: the expiry grammar and its bounds, the
3
+ * permission payload the Registry accepts, and the human-verification
4
+ * requirement on every token write.
5
+ *
6
+ * @experimental This API is unstable and may change without notice.
7
+ */
8
+ import * as Effect from "effect/Effect";
9
+ import * as Option from "effect/Option";
10
+ import { AuthClient, type CreatedTokenResponse, type TokenPermissionsRequest } from "./auth-client.js";
11
+ import { CredentialStore } from "./credential-store.js";
12
+ import { RegistryAuthFailed } from "./errors.js";
13
+ import { AuthLoginPresenter } from "./login-presenter.js";
14
+ import { type StepUpOptions } from "./step-up.js";
15
+ /** A token may live no less than an hour and no more than a year. */
16
+ export declare const MIN_TOKEN_LIFETIME_SECONDS = 3600;
17
+ export declare const MAX_TOKEN_LIFETIME_SECONDS = 31536000;
18
+ /** The authority a new token carries. */
19
+ export interface TokenAuthorityRequest {
20
+ readonly owners: ReadonlyArray<string>;
21
+ readonly extensions: ReadonlyArray<string>;
22
+ readonly permission: Option.Option<"read" | "publish" | "admin">;
23
+ readonly orgPermission: Option.Option<"read" | "write" | "admin">;
24
+ readonly cidr: ReadonlyArray<string>;
25
+ readonly bypassMfa: boolean;
26
+ }
27
+ export interface CreateTokenRequest extends TokenAuthorityRequest {
28
+ readonly name: string;
29
+ /** Relative lifetime (`7d`, `30d`, `1y`) or an absolute ISO timestamp. */
30
+ readonly expires: string;
31
+ readonly verification: StepUpOptions;
32
+ }
33
+ export interface CreatedToken {
34
+ readonly token: CreatedTokenResponse;
35
+ readonly stepUpCompleted: boolean;
36
+ }
37
+ /**
38
+ * Read the requested lifetime. Relative forms are exact multiples; an
39
+ * absolute timestamp becomes the distance from now.
40
+ */
41
+ export declare const parseExpiresInSeconds: (raw: string) => Effect.Effect<number, RegistryAuthFailed>;
42
+ /** Enforce the accepted lifetime window. */
43
+ export declare const validateExpiresInSeconds: (expiresIn: number) => Effect.Effect<number, RegistryAuthFailed>;
44
+ /** Only the authority a caller actually asked for reaches the Registry. */
45
+ export declare const tokenPermissions: (request: TokenAuthorityRequest) => TokenPermissionsRequest;
46
+ export declare const createToken: (request: CreateTokenRequest, registryUrl: string) => Effect.Effect<{
47
+ token: CreatedTokenResponse;
48
+ stepUpCompleted: boolean;
49
+ }, import("./errors.js").AuthError, AuthClient | CredentialStore | AuthLoginPresenter | import("./login-interaction.ts").AuthLoginInteraction>;
50
+ export declare const revokeToken: (tokenId: string, verification: StepUpOptions, registryUrl: string) => Effect.Effect<{
51
+ tokenId: string;
52
+ stepUpCompleted: boolean;
53
+ }, import("./errors.js").AuthError, AuthClient | CredentialStore | AuthLoginPresenter | import("./login-interaction.ts").AuthLoginInteraction>;
54
+ export declare const listTokens: (registryUrl: string) => Effect.Effect<import("./auth-client.js").TokenListResponse, import("./errors.js").AuthError, AuthClient | CredentialStore | AuthLoginPresenter>;
55
+ /** Declared for the reader; every member keeps these in `R`. */
56
+ export type TokenRequirements = AuthClient | CredentialStore | AuthLoginPresenter;
57
+ //# sourceMappingURL=tokens.d.ts.map