runline 0.38.0 → 0.39.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.
@@ -78,11 +78,26 @@ function joinedPart(current, source) {
78
78
  }
79
79
  return joined;
80
80
  }
81
+ /** An issued credential as stored flat: stamped with the authority it was issued under. */
82
+ function stamped(issued, expected) {
83
+ return { ...issued, binding: expected };
84
+ }
85
+ /**
86
+ * A flat issued credential without its stamp, when it was issued under
87
+ * `expected`; undefined for one issued under any other selection, which is
88
+ * never used.
89
+ */
90
+ function issuedUnder(stored, expected) {
91
+ if (!stored || typeof stored !== "object")
92
+ return undefined;
93
+ const { binding, ...issued } = stored;
94
+ return binding === expected ? issued : undefined;
95
+ }
81
96
  /**
82
97
  * How an issued credential is stored flat: an OAuth grant as its token
83
- * fields, a role session as `awsSession`, each stamped with the authority
84
- * (`expected`) it was issued under. Only those two are ever renewed and
85
- * written back.
98
+ * fields, a role session as `awsSession`, a cookie jar as `cookieSession`,
99
+ * each stamped with the authority (`expected`) it was issued under. Only
100
+ * those three are ever renewed and written back.
86
101
  */
87
102
  function writeBack(auth, expected) {
88
103
  if (auth.kind === "awsSigV4" && auth.assumeRole) {
@@ -91,9 +106,16 @@ function writeBack(auth, expected) {
91
106
  const session = patch[sessionField];
92
107
  if (!session?.sessionToken)
93
108
  throw new AuthError("invalid_credentials");
94
- return { awsSession: { ...session, binding: expected } };
109
+ return { awsSession: stamped(session, expected) };
95
110
  };
96
111
  }
112
+ if (auth.kind === "cookie")
113
+ return (patch) => {
114
+ const session = patch[auth.field];
115
+ if (!session?.revision)
116
+ throw new AuthError("invalid_credentials");
117
+ return { cookieSession: stamped(session, expected) };
118
+ };
97
119
  if (auth.kind !== "oauth2")
98
120
  throw new AuthError("invalid_definition");
99
121
  return (patch, current) => {
@@ -136,23 +158,17 @@ function localSigner(ctx, declaration) {
136
158
  const key = selection.awsProfile
137
159
  ? await awsProfileKey(selection.awsProfile)
138
160
  : staticSecret(current, selection.localSecret, ["sessionToken"]);
139
- const cached = current.awsSession;
161
+ const session = issuedUnder(current.awsSession, expected);
140
162
  const sessionField = auth.assumeRole?.sessionField;
141
163
  return {
142
164
  ...(key ? { [auth.field]: key } : {}),
143
- ...(sessionField && cached?.binding === expected
144
- ? {
145
- [sessionField]: {
146
- accessKeyId: cached.accessKeyId,
147
- secretAccessKey: cached.secretAccessKey,
148
- sessionToken: cached.sessionToken,
149
- expiresAt: cached.expiresAt,
150
- revision: cached.revision,
151
- },
152
- }
153
- : {}),
165
+ ...(sessionField && session ? { [sessionField]: session } : {}),
154
166
  };
155
167
  }
168
+ if (auth.kind === "cookie") {
169
+ const session = issuedUnder(current.cookieSession, expected);
170
+ return session ? { [auth.field]: session } : {};
171
+ }
156
172
  const compatible = current.authTokenBinding === undefined ||
157
173
  current.authTokenBinding === expected;
158
174
  const tokens = {
@@ -0,0 +1,33 @@
1
+ import type { StoredCookie } from "./types.js";
2
+ /**
3
+ * A cookie jar for one connection, after RFC 6265 §5: what a `Set-Cookie`
4
+ * stores, replaces or deletes, and which cookies a request carries. Pure:
5
+ * the transport decides which responses reach it (only its targets') and
6
+ * persists what it answers.
7
+ */
8
+ /** The most cookies a jar keeps; the oldest go first beyond it. */
9
+ export declare const MAX_COOKIES = 64;
10
+ /** A cookie name: an RFC 7230 token. */
11
+ export declare function isCookieName(name: unknown): name is string;
12
+ /**
13
+ * One `Set-Cookie` from a response to `url`: the cookie it stores, with
14
+ * `expired` when it deletes (Max-Age ≤ 0, or an Expires in the past).
15
+ * Undefined for one a jar ignores: no name, a value or name it cannot
16
+ * send back, an oversized one, or a Domain the host does not match.
17
+ */
18
+ export declare function parseSetCookie(header: string, url: URL, now: number): {
19
+ cookie: StoredCookie;
20
+ expired: boolean;
21
+ } | undefined;
22
+ /** The cookies still alive at `now`. */
23
+ export declare function liveCookies(cookies: readonly StoredCookie[], now: number): StoredCookie[];
24
+ /**
25
+ * The jar after a response to `url` set `headers`: each cookie replaces
26
+ * the one of the same name, domain and path, a deletion removes it, and
27
+ * expired cookies are dropped. At most `MAX_COOKIES`, newest kept.
28
+ */
29
+ export declare function applySetCookies(cookies: readonly StoredCookie[], headers: readonly string[], url: URL, now: number): StoredCookie[];
30
+ /** The Cookie header for a request to `url`, or undefined when none match. */
31
+ export declare function cookieHeader(cookies: readonly StoredCookie[], url: URL, now: number): string | undefined;
32
+ /** The value of cookie `name` a request to `url` would carry. */
33
+ export declare function cookieFor(cookies: readonly StoredCookie[], name: string, url: URL, now: number): string | undefined;
@@ -0,0 +1,151 @@
1
+ import { isIP } from "node:net";
2
+ /**
3
+ * A cookie jar for one connection, after RFC 6265 §5: what a `Set-Cookie`
4
+ * stores, replaces or deletes, and which cookies a request carries. Pure:
5
+ * the transport decides which responses reach it (only its targets') and
6
+ * persists what it answers.
7
+ */
8
+ /** The most cookies a jar keeps; the oldest go first beyond it. */
9
+ export const MAX_COOKIES = 64;
10
+ /** The largest name plus value a cookie may have (RFC 6265 §6.1's floor). */
11
+ const MAX_COOKIE_BYTES = 4096;
12
+ const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
13
+ /** A cookie name: an RFC 7230 token. */
14
+ export function isCookieName(name) {
15
+ return typeof name === "string" && TOKEN.test(name);
16
+ }
17
+ /** A value safe to send back in a Cookie header: printable ASCII without `;`. */
18
+ function cookieValue(value) {
19
+ return /^[\x20-\x3a\x3c-\x7e]*$/.test(value);
20
+ }
21
+ function domainMatch(host, domain) {
22
+ return host === domain || (host.endsWith(`.${domain}`) && !isIP(host));
23
+ }
24
+ /** RFC 6265 §5.1.4: the directory of the request path. */
25
+ function defaultPath(pathname) {
26
+ if (!pathname.startsWith("/"))
27
+ return "/";
28
+ const last = pathname.lastIndexOf("/");
29
+ return last <= 0 ? "/" : pathname.slice(0, last);
30
+ }
31
+ function pathMatch(requestPath, cookiePath) {
32
+ return (requestPath === cookiePath ||
33
+ (requestPath.startsWith(cookiePath) &&
34
+ (cookiePath.endsWith("/") || requestPath[cookiePath.length] === "/")));
35
+ }
36
+ /**
37
+ * One `Set-Cookie` from a response to `url`: the cookie it stores, with
38
+ * `expired` when it deletes (Max-Age ≤ 0, or an Expires in the past).
39
+ * Undefined for one a jar ignores: no name, a value or name it cannot
40
+ * send back, an oversized one, or a Domain the host does not match.
41
+ */
42
+ export function parseSetCookie(header, url, now) {
43
+ const [pair, ...attributes] = header.split(";");
44
+ const equals = pair.indexOf("=");
45
+ if (equals < 0)
46
+ return undefined;
47
+ const name = pair.slice(0, equals).trim();
48
+ const value = pair.slice(equals + 1).trim();
49
+ if (!isCookieName(name) ||
50
+ !cookieValue(value) ||
51
+ name.length + value.length > MAX_COOKIE_BYTES)
52
+ return undefined;
53
+ const host = url.hostname.toLowerCase();
54
+ let domain;
55
+ let path;
56
+ let maxAge;
57
+ let expires;
58
+ let secure = false;
59
+ let httpOnly = false;
60
+ for (const attribute of attributes) {
61
+ const at = attribute.indexOf("=");
62
+ const key = (at < 0 ? attribute : attribute.slice(0, at))
63
+ .trim()
64
+ .toLowerCase();
65
+ const raw = at < 0 ? "" : attribute.slice(at + 1).trim();
66
+ if (key === "max-age" && /^-?\d+$/.test(raw))
67
+ maxAge = Number(raw);
68
+ else if (key === "expires") {
69
+ const parsed = Date.parse(raw);
70
+ if (Number.isFinite(parsed))
71
+ expires = parsed;
72
+ }
73
+ else if (key === "domain" && raw) {
74
+ domain = raw.replace(/^\./, "").toLowerCase();
75
+ }
76
+ else if (key === "path")
77
+ path = raw.startsWith("/") ? raw : undefined;
78
+ else if (key === "secure")
79
+ secure = true;
80
+ else if (key === "httponly")
81
+ httpOnly = true;
82
+ }
83
+ // A Domain the host does not match is refused, and so is a bare
84
+ // top-level label. A wider Domain still reaches only the method's own
85
+ // targets: the jar signs nothing else.
86
+ if (domain !== undefined &&
87
+ (!domainMatch(host, domain) || (domain !== host && !domain.includes("."))))
88
+ return undefined;
89
+ const until = maxAge !== undefined ? now + maxAge * 1000 : expires;
90
+ const expiresAt = until === undefined
91
+ ? undefined
92
+ : Math.min(Math.max(0, until), Number.MAX_SAFE_INTEGER);
93
+ const cookie = {
94
+ name,
95
+ value,
96
+ domain: domain ?? host,
97
+ hostOnly: domain === undefined,
98
+ path: path ?? defaultPath(url.pathname),
99
+ secure,
100
+ httpOnly,
101
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
102
+ };
103
+ return { cookie, expired: expiresAt !== undefined && expiresAt <= now };
104
+ }
105
+ /** The cookies still alive at `now`. */
106
+ export function liveCookies(cookies, now) {
107
+ return cookies.filter((cookie) => cookie.expiresAt === undefined || cookie.expiresAt > now);
108
+ }
109
+ /**
110
+ * The jar after a response to `url` set `headers`: each cookie replaces
111
+ * the one of the same name, domain and path, a deletion removes it, and
112
+ * expired cookies are dropped. At most `MAX_COOKIES`, newest kept.
113
+ */
114
+ export function applySetCookies(cookies, headers, url, now) {
115
+ let jar = liveCookies(cookies, now);
116
+ for (const header of headers) {
117
+ const parsed = parseSetCookie(header, url, now);
118
+ if (!parsed)
119
+ continue;
120
+ const { cookie, expired } = parsed;
121
+ jar = jar.filter((kept) => kept.name !== cookie.name ||
122
+ kept.domain !== cookie.domain ||
123
+ kept.path !== cookie.path);
124
+ if (!expired)
125
+ jar.push(cookie);
126
+ }
127
+ return jar.slice(-MAX_COOKIES);
128
+ }
129
+ /** The cookies a request to `url` carries, longest path first. */
130
+ function matching(cookies, url, now) {
131
+ const host = url.hostname.toLowerCase();
132
+ return liveCookies(cookies, now)
133
+ .filter((cookie) => (cookie.hostOnly
134
+ ? host === cookie.domain
135
+ : domainMatch(host, cookie.domain)) &&
136
+ pathMatch(url.pathname, cookie.path) &&
137
+ (!cookie.secure || url.protocol === "https:"))
138
+ .sort((a, b) => b.path.length - a.path.length);
139
+ }
140
+ /** The Cookie header for a request to `url`, or undefined when none match. */
141
+ export function cookieHeader(cookies, url, now) {
142
+ const sent = matching(cookies, url, now);
143
+ return sent.length
144
+ ? sent.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ")
145
+ : undefined;
146
+ }
147
+ /** The value of cookie `name` a request to `url` would carry. */
148
+ export function cookieFor(cookies, name, url, now) {
149
+ return matching(cookies, url, now).find((cookie) => cookie.name === name)
150
+ ?.value;
151
+ }
@@ -4,6 +4,17 @@ export interface ResourceOptions {
4
4
  timeoutMs: number;
5
5
  maxResponseBytes: number;
6
6
  resumableUpload?: boolean;
7
+ /**
8
+ * The 3xx that come back as they are, Location and all, judged by their
9
+ * Location; nothing is followed. Without it, or when it refuses, a 3xx
10
+ * fails as transport_failed, bar a resumable upload's 308.
11
+ */
12
+ redirect?: (location: string | null) => boolean;
13
+ /**
14
+ * Receives the response's `Set-Cookie` values before they are stripped:
15
+ * the only way a cookie leaves this function, and never to a caller.
16
+ */
17
+ setCookie?: (values: string[]) => void;
7
18
  }
8
19
  /** Internal send primitive. Callers validate destination policy before reaching it. */
9
20
  export declare function sendResource(url: string, method: HttpMethod, headers: Headers, body: Buffer | undefined, options: ResourceOptions): Promise<Response>;
@@ -14,7 +14,7 @@ export async function sendResource(url, method, headers, body, options) {
14
14
  method,
15
15
  headers,
16
16
  body: body === undefined ? undefined : Buffer.from(body),
17
- redirect: "error",
17
+ redirect: options.redirect ? "manual" : "error",
18
18
  credentials: "omit",
19
19
  signal: controller.signal,
20
20
  });
@@ -22,6 +22,7 @@ export async function sendResource(url, method, headers, body, options) {
22
22
  response.redirected ||
23
23
  (response.status >= 300 &&
24
24
  response.status < 400 &&
25
+ !options.redirect?.(response.headers.get("location")) &&
25
26
  !(options.resumableUpload &&
26
27
  method === "PUT" &&
27
28
  headers.has("content-range") &&
@@ -56,6 +57,7 @@ export async function sendResource(url, method, headers, body, options) {
56
57
  controller.signal.removeEventListener("abort", cancel);
57
58
  reader?.releaseLock();
58
59
  }
60
+ options.setCookie?.(response.headers.getSetCookie());
59
61
  const responseHeaders = new Headers(response.headers);
60
62
  // Fetch supplies decoded bytes, not the original wire representation.
61
63
  responseHeaders.delete("content-encoding");
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The plugin side of the credential broker, public to every plugin —
3
3
  * bundled or custom: one declaration factory for static keys, one stored
4
- * grant shape for OAuth methods, one way to turn public config into an
5
- * HTTPS target, one request path.
4
+ * shape each for OAuth and cookie methods, one way to turn public config
5
+ * into an HTTPS target, one request path.
6
6
  */
7
7
  import * as t from "typebox";
8
8
  import type { ActionContext } from "../plugin/types.js";
@@ -24,6 +24,25 @@ export declare const grantSchema: t.TObject<{
24
24
  revision: t.TString;
25
25
  }>>;
26
26
  }>;
27
+ /**
28
+ * The stored shape of a cookie method: one optional cookie jar in the
29
+ * `session` field, absent until the first login.
30
+ */
31
+ export declare const cookieSessionSchema: t.TObject<{
32
+ session: t.TOptional<t.TObject<{
33
+ cookies: t.TArray<t.TObject<{
34
+ name: t.TString;
35
+ value: t.TString;
36
+ domain: t.TString;
37
+ hostOnly: t.TBoolean;
38
+ path: t.TString;
39
+ secure: t.TBoolean;
40
+ httpOnly: t.TBoolean;
41
+ expiresAt: t.TOptional<t.TInteger>;
42
+ }>>;
43
+ revision: t.TString;
44
+ }>>;
45
+ }>;
27
46
  /**
28
47
  * A flat config field, a fixed value such as Freshdesk's "X" password, or
29
48
  * the two joined in order, such as Zendesk's `{email}/token` username.
@@ -1,18 +1,23 @@
1
1
  /**
2
2
  * The plugin side of the credential broker, public to every plugin —
3
3
  * bundled or custom: one declaration factory for static keys, one stored
4
- * grant shape for OAuth methods, one way to turn public config into an
5
- * HTTPS target, one request path.
4
+ * shape each for OAuth and cookie methods, one way to turn public config
5
+ * into an HTTPS target, one request path.
6
6
  */
7
7
  import * as t from "typebox";
8
8
  import { AuthError } from "../auth/errors.js";
9
9
  import { brokerFor } from "./broker.js";
10
- import { OAuthGrantSchema, staticSecretSchema } from "./registry.js";
10
+ import { CookieSessionSchema, OAuthGrantSchema, staticSecretSchema, } from "./registry.js";
11
11
  /**
12
12
  * The stored shape of an OAuth method: one optional revisioned grant in the
13
13
  * `grant` field, absent until the first issuance.
14
14
  */
15
15
  export const grantSchema = t.Object({ grant: t.Optional(OAuthGrantSchema) }, { additionalProperties: false });
16
+ /**
17
+ * The stored shape of a cookie method: one optional cookie jar in the
18
+ * `session` field, absent until the first login.
19
+ */
20
+ export const cookieSessionSchema = t.Object({ session: t.Optional(CookieSessionSchema) }, { additionalProperties: false });
16
21
  /** The parts and placements a shorthand, or the general form, declares. */
17
22
  function placementsOf(auth) {
18
23
  if (auth.kind === "static") {
@@ -119,6 +119,8 @@ export function injectedHeaders(auth, target) {
119
119
  return ["authorization"];
120
120
  if (auth.kind === "awsSigV4")
121
121
  return [...SIGV4_HEADERS];
122
+ if (auth.kind === "cookie")
123
+ return auth.csrf ? [headerName(auth.csrf.header)] : [];
122
124
  return placementsFor(auth, target).flatMap((placement) => placement.in === "header" ||
123
125
  placement.in === "jwt" ||
124
126
  placement.in === "querySignature"
@@ -43,6 +43,31 @@ export declare const AwsSessionSchema: t.TObject<{
43
43
  expiresAt: t.TInteger;
44
44
  revision: t.TString;
45
45
  }>;
46
+ /** Stored shape of one cookie in a session's jar. */
47
+ export declare const StoredCookieSchema: t.TObject<{
48
+ name: t.TString;
49
+ value: t.TString;
50
+ domain: t.TString;
51
+ hostOnly: t.TBoolean;
52
+ path: t.TString;
53
+ secure: t.TBoolean;
54
+ httpOnly: t.TBoolean;
55
+ expiresAt: t.TOptional<t.TInteger>;
56
+ }>;
57
+ /** Stored shape of a cookie session: its jar and the login it came from. */
58
+ export declare const CookieSessionSchema: t.TObject<{
59
+ cookies: t.TArray<t.TObject<{
60
+ name: t.TString;
61
+ value: t.TString;
62
+ domain: t.TString;
63
+ hostOnly: t.TBoolean;
64
+ path: t.TString;
65
+ secure: t.TBoolean;
66
+ httpOnly: t.TBoolean;
67
+ expiresAt: t.TOptional<t.TInteger>;
68
+ }>>;
69
+ revision: t.TString;
70
+ }>;
46
71
  /** No implicit defaults, provider inference, executable expressions, or replacement. */
47
72
  export declare class CredentialRegistry {
48
73
  private readonly types;
@@ -4,6 +4,7 @@ import { AuthError } from "../auth/errors.js";
4
4
  import { validateOAuth2ExchangePolicy } from "../auth/oauth2.js";
5
5
  import { oauthEndpoint, providerHeaders, providerParameters, } from "../auth/token.js";
6
6
  import { isAwsScope } from "./aws.js";
7
+ import { isCookieName, MAX_COOKIES } from "./cookies.js";
7
8
  import { bounded, HTTP_METHODS, headerName, injectedHeaders, injectedParams, injectedPointers, placementsFor, refuseCredentialParams, resourceUrl, TARGET_RESPONSE_LIMIT_BYTES, TARGET_TIMEOUT_LIMIT_MS, TRANSPORT_HEADERS, targetBase, targetOf, } from "./policy.js";
8
9
  /** Normalized grant storage; application registration remains host-owned. */
9
10
  export const OAuthTokensSchema = t.Object({
@@ -44,6 +45,22 @@ export const AwsSessionSchema = t.Object({
44
45
  expiresAt: t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
45
46
  revision: t.String({ minLength: 1 }),
46
47
  }, { additionalProperties: false });
48
+ /** Stored shape of one cookie in a session's jar. */
49
+ export const StoredCookieSchema = t.Object({
50
+ name: t.String({ minLength: 1 }),
51
+ value: t.String(),
52
+ domain: t.String({ minLength: 1 }),
53
+ hostOnly: t.Boolean(),
54
+ path: t.String({ minLength: 1 }),
55
+ secure: t.Boolean(),
56
+ httpOnly: t.Boolean(),
57
+ expiresAt: t.Optional(t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })),
58
+ }, { additionalProperties: false });
59
+ /** Stored shape of a cookie session: its jar and the login it came from. */
60
+ export const CookieSessionSchema = t.Object({
61
+ cookies: t.Array(StoredCookieSchema, { maxItems: MAX_COOKIES }),
62
+ revision: t.String({ minLength: 1 }),
63
+ }, { additionalProperties: false });
47
64
  /** Whether two strict object schemas store the same fields, alike required. */
48
65
  function sameShape(actual, expected) {
49
66
  const shape = actual;
@@ -77,6 +94,70 @@ function identifier(value) {
77
94
  if (typeof value !== "string" || !/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(value))
78
95
  throw new AuthError("invalid_definition");
79
96
  }
97
+ /** A login form's field name, brackets allowed (Rails' `user[email]`). */
98
+ function formField(value) {
99
+ if (typeof value !== "string" || !/^[A-Za-z_][\w.[\]-]{0,99}$/.test(value))
100
+ throw new AuthError("invalid_definition");
101
+ return value;
102
+ }
103
+ /** The longest body marker a rejection may declare. */
104
+ const MAX_MARKER_LENGTH = 128;
105
+ /**
106
+ * A cookie method stores an optional jar in its field, logs in on one of
107
+ * its own targets with a POST (and a GET, with a preflight), and declares
108
+ * at least one rejection signal; every path is checked as a request path
109
+ * beneath the login target is.
110
+ */
111
+ function validateCookie(auth, method) {
112
+ const schema = method.schema;
113
+ if (!sameShape(schema.properties?.[auth.field], CookieSessionSchema) ||
114
+ schema.required?.includes(auth.field))
115
+ throw new AuthError("invalid_definition");
116
+ const { login, rejection, csrf } = auth;
117
+ const target = login && targetOf(method, login.target);
118
+ if (!target ||
119
+ target.socket ||
120
+ !target.methods.includes("POST") ||
121
+ (login.preflight !== undefined && !target.methods.includes("GET")) ||
122
+ !["form", "json"].includes(login.encoding))
123
+ throw new AuthError("invalid_definition");
124
+ resourceUrl(target, login.path);
125
+ if (login.preflight !== undefined)
126
+ resourceUrl(target, login.preflight);
127
+ const names = [
128
+ formField(login.fields?.username ?? "username"),
129
+ formField(login.fields?.password ?? "password"),
130
+ ];
131
+ for (const [name, value] of Object.entries(login.parameters ?? {})) {
132
+ names.push(formField(name));
133
+ if (typeof value !== "string" || !/^[\x20-\x7e]{0,1024}$/.test(value))
134
+ throw new AuthError("invalid_definition");
135
+ }
136
+ if (new Set(names).size !== names.length)
137
+ throw new AuthError("invalid_definition");
138
+ const { statuses, loginPaths, marker } = rejection ?? {};
139
+ if (!rejection ||
140
+ (statuses !== undefined &&
141
+ (!Array.isArray(statuses) ||
142
+ !statuses.length ||
143
+ statuses.some((status) => !Number.isInteger(status) || status < 400 || status > 499))) ||
144
+ (loginPaths !== undefined &&
145
+ (!Array.isArray(loginPaths) || !loginPaths.length)) ||
146
+ (marker !== undefined &&
147
+ (typeof marker !== "string" ||
148
+ marker.length > MAX_MARKER_LENGTH ||
149
+ !/^[\x20-\x7e]+$/.test(marker))) ||
150
+ (statuses === undefined && loginPaths === undefined && marker === undefined))
151
+ throw new AuthError("invalid_definition");
152
+ for (const path of loginPaths ?? [])
153
+ resourceUrl(target, path);
154
+ if (csrf !== undefined) {
155
+ if (!isCookieName(csrf.cookie))
156
+ throw new AuthError("invalid_definition");
157
+ if (TRANSPORT_HEADERS.includes(headerName(csrf.header)))
158
+ throw new AuthError("invalid_definition");
159
+ }
160
+ }
80
161
  /**
81
162
  * A static secret's parts and placements agree with each other, with the
82
163
  * stored shape and with the method's targets: the stored field holds
@@ -201,6 +282,8 @@ function validateMethod(method) {
201
282
  validateStatic(auth, schema.properties[auth.field], Object.keys(method.targets ?? {}));
202
283
  else if (auth.kind === "awsSigV4")
203
284
  validateAwsSigV4(auth, schema);
285
+ else if (auth.kind === "cookie")
286
+ validateCookie(auth, method);
204
287
  else if (auth.kind === "oauth2") {
205
288
  identifier(auth.definition.id);
206
289
  identifier(auth.definition.provider);
@@ -304,6 +387,7 @@ function validateMethod(method) {
304
387
  (target.socket !== true ||
305
388
  auth.kind === "oauth2" ||
306
389
  auth.kind === "awsSigV4" ||
390
+ auth.kind === "cookie" ||
307
391
  target.allowedHeaders !== undefined ||
308
392
  target.idempotency !== undefined ||
309
393
  target.resumableUpload !== undefined ||
@@ -77,6 +77,30 @@ export declare class CredentialTransport {
77
77
  */
78
78
  private commitIssued;
79
79
  private renew;
80
+ /**
81
+ * A cookie session logged in under the connection's update ownership, so
82
+ * parallel calls on an expired session log in once: one another caller
83
+ * committed meanwhile is kept. With `rejected`, the jar sent at
84
+ * `observedRevision` was refused and only a newer login is kept.
85
+ */
86
+ private loginSession;
87
+ /**
88
+ * The declared login: a preflight GET when declared, then the owner's
89
+ * POST, both carrying the jar and absorbing what they set. The jar is
90
+ * merged into, never replaced, so a device-trust cookie survives. A
91
+ * login refused by status is `provider_rejected` with it; one the app
92
+ * answers with a rejection signal, or that leaves no cookie, is
93
+ * `reconnect_required`. The password goes in this one request only.
94
+ */
95
+ private logIn;
96
+ /**
97
+ * What a response of the session sent at `revision` set, merged into the
98
+ * stored jar: a rotated, added or deleted cookie. Only while the jar is
99
+ * still that login's, so a stale response never undoes a newer login.
100
+ * Best effort: a store that fails leaves the response standing, and the
101
+ * session is renewed when it is next rejected.
102
+ */
103
+ private absorb;
80
104
  /** A role session for the binding's `awsRole`, assumed with the stored key. */
81
105
  private renewAwsSession;
82
106
  /** New tokens by the method's declared renewal, from what the binding supplies. */
@@ -3,9 +3,10 @@ import { Check } from "typebox/value";
3
3
  import { AuthError } from "../auth/errors.js";
4
4
  import { acquireOAuth2ClientToken, acquireOAuth2JwtToken, acquireOAuth2PasswordToken, refreshOAuth2Token, } from "../auth/oauth2.js";
5
5
  import { assumeAwsRole, awsAccessKey } from "./aws.js";
6
+ import { applySetCookies, cookieFor, cookieHeader, liveCookies, } from "./cookies.js";
6
7
  import { sendResource } from "./http.js";
7
8
  import { bodyFields, bodyFormat, bounded, headerName, injectedFields, injectedHeaders, injectedParams, injectedPointers, placementsFor, pointerSlot, refuseCredentialParams, resourceUrl, secret, TARGET_RESPONSE_LIMIT_BYTES, TARGET_TIMEOUT_LIMIT_MS, TRANSPORT_HEADERS, targetBase, targetOf, } from "./policy.js";
8
- import { OAuthGrantSchema, validateCredential, } from "./registry.js";
9
+ import { CookieSessionSchema, OAuthGrantSchema, validateCredential, } from "./registry.js";
9
10
  import { canonicalQuery, signSigV4 } from "./sigv4.js";
10
11
  function boundSnapshot(binding, method, snapshot) {
11
12
  if (snapshot.name !== binding.identity.name ||
@@ -29,6 +30,67 @@ function grantFrom(method, config) {
29
30
  throw new AuthError("unsupported_operation");
30
31
  return result;
31
32
  }
33
+ function cookieSessionFrom(auth, config) {
34
+ const session = config[auth.field];
35
+ if (session === undefined)
36
+ return undefined;
37
+ if (!Check(CookieSessionSchema, session))
38
+ throw new AuthError("invalid_credentials");
39
+ return session;
40
+ }
41
+ /** Methods that change state, on which a CSRF header is sent. */
42
+ function unsafe(method) {
43
+ return method !== "GET" && method !== "HEAD";
44
+ }
45
+ /** Signs with the jar's cookies for the request's URL, and the CSRF header on an unsafe one. */
46
+ function cookieSigner(auth, cookies) {
47
+ return ({ method, headers, url }) => {
48
+ const now = Date.now();
49
+ const cookie = cookieHeader(cookies, url, now);
50
+ if (cookie)
51
+ headers.set("cookie", cookie);
52
+ if (auth.csrf && unsafe(method)) {
53
+ const token = cookieFor(cookies, auth.csrf.cookie, url, now);
54
+ if (token)
55
+ headers.set(headerName(auth.csrf.header), token);
56
+ }
57
+ };
58
+ }
59
+ /** Whether a Location, read against the request's `url`, falls beneath a declared login path. */
60
+ function toLogin(auth, loginTarget, location, url) {
61
+ if (!location || !auth.rejection.loginPaths)
62
+ return false;
63
+ let next;
64
+ try {
65
+ next = new URL(location, url);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ return auth.rejection.loginPaths.some((path) => {
71
+ const login = resourceUrl(loginTarget, path);
72
+ return (next.origin === login.origin && next.pathname.startsWith(login.pathname));
73
+ });
74
+ }
75
+ /**
76
+ * How a response shows a cookie session was rejected, by the method's
77
+ * declared signals: its status, a redirect to a login path, or a body
78
+ * marker on an HTML answer. Null when it shows none.
79
+ */
80
+ async function sessionRejection(auth, loginTarget, response, url) {
81
+ const { statuses, marker } = auth.rejection;
82
+ if (statuses?.includes(response.status))
83
+ return "status";
84
+ if (toLogin(auth, loginTarget, response.headers.get("location"), url))
85
+ return "redirect";
86
+ if (marker &&
87
+ response.status >= 200 &&
88
+ response.status < 300 &&
89
+ (response.headers.get("content-type") ?? "").includes("text/html") &&
90
+ (await response.clone().text()).includes(marker))
91
+ return "marker";
92
+ return null;
93
+ }
32
94
  function pinBinding(selection) {
33
95
  return {
34
96
  ...selection,
@@ -313,6 +375,118 @@ export class CredentialTransport {
313
375
  },
314
376
  });
315
377
  }
378
+ /**
379
+ * A cookie session logged in under the connection's update ownership, so
380
+ * parallel calls on an expired session log in once: one another caller
381
+ * committed meanwhile is kept. With `rejected`, the jar sent at
382
+ * `observedRevision` was refused and only a newer login is kept.
383
+ */
384
+ loginSession(binding, method, observedRevision, rejected = false) {
385
+ const auth = method.authentication;
386
+ if (auth.kind !== "cookie")
387
+ throw new AuthError("invalid_definition");
388
+ return this.commitIssued(binding, method, {
389
+ field: auth.field,
390
+ read: (config) => cookieSessionFrom(auth, config),
391
+ usable: (previous) => !!previous &&
392
+ liveCookies(previous.cookies, Date.now()).length > 0 &&
393
+ (!rejected || previous.revision !== observedRevision),
394
+ issue: (previous) => this.logIn(auth, method, binding, previous),
395
+ });
396
+ }
397
+ /**
398
+ * The declared login: a preflight GET when declared, then the owner's
399
+ * POST, both carrying the jar and absorbing what they set. The jar is
400
+ * merged into, never replaced, so a device-trust cookie survives. A
401
+ * login refused by status is `provider_rejected` with it; one the app
402
+ * answers with a rejection signal, or that leaves no cookie, is
403
+ * `reconnect_required`. The password goes in this one request only.
404
+ */
405
+ async logIn(auth, method, binding, previous) {
406
+ const owner = binding.resourceOwner;
407
+ if (!owner?.username || !owner.password)
408
+ throw new AuthError("invalid_credentials");
409
+ const { login } = auth;
410
+ const target = targetOf(method, login.target);
411
+ if (!target)
412
+ throw new AuthError("invalid_definition");
413
+ let cookies = liveCookies(previous?.cookies ?? [], Date.now());
414
+ const exchange = async (path, verb, body) => {
415
+ const url = resourceUrl(target, path);
416
+ const headers = new Headers({ accept: "*/*" });
417
+ if (body)
418
+ headers.set("content-type", body.type);
419
+ const sign = cookieSigner(auth, cookies);
420
+ sign({ method: verb, headers, url, base: targetBase(target).pathname });
421
+ let set = [];
422
+ const response = await sendResource(url.toString(), verb, headers, body?.bytes, {
423
+ fetch: this.options.fetch,
424
+ timeoutMs: this.options.timeoutMs,
425
+ maxResponseBytes: this.options.maxResponseBytes,
426
+ redirect: () => true,
427
+ setCookie: (values) => {
428
+ set = values;
429
+ },
430
+ });
431
+ cookies = applySetCookies(cookies, set, url, Date.now());
432
+ return { response, url };
433
+ };
434
+ if (login.preflight !== undefined) {
435
+ const { response } = await exchange(login.preflight, "GET");
436
+ if (response.status >= 400)
437
+ throw new AuthError("provider_rejected", response.status);
438
+ }
439
+ const fields = {
440
+ ...login.parameters,
441
+ [login.fields?.username ?? "username"]: owner.username,
442
+ [login.fields?.password ?? "password"]: owner.password,
443
+ };
444
+ const body = login.encoding === "json"
445
+ ? {
446
+ type: "application/json",
447
+ bytes: Buffer.from(JSON.stringify(fields)),
448
+ }
449
+ : {
450
+ type: "application/x-www-form-urlencoded",
451
+ bytes: Buffer.from(new URLSearchParams(fields).toString()),
452
+ };
453
+ const { response, url } = await exchange(login.path, "POST", body);
454
+ if (response.status >= 400)
455
+ throw new AuthError("provider_rejected", response.status);
456
+ if ((await sessionRejection(auth, target, response, url)) ||
457
+ !liveCookies(cookies, Date.now()).length)
458
+ throw new AuthError("reconnect_required");
459
+ return { cookies, revision: randomUUID() };
460
+ }
461
+ /**
462
+ * What a response of the session sent at `revision` set, merged into the
463
+ * stored jar: a rotated, added or deleted cookie. Only while the jar is
464
+ * still that login's, so a stale response never undoes a newer login.
465
+ * Best effort: a store that fails leaves the response standing, and the
466
+ * session is renewed when it is next rejected.
467
+ */
468
+ async absorb(binding, method, revision, values, url) {
469
+ const auth = method.authentication;
470
+ if (auth.kind !== "cookie" || !values.length)
471
+ return;
472
+ try {
473
+ await binding.connection.update(async (current) => {
474
+ validateCredential(method, current);
475
+ const stored = cookieSessionFrom(auth, current);
476
+ if (stored?.revision !== revision)
477
+ return undefined;
478
+ return {
479
+ [auth.field]: {
480
+ cookies: applySetCookies(stored.cookies, values, url, Date.now()),
481
+ revision,
482
+ },
483
+ };
484
+ });
485
+ }
486
+ catch {
487
+ // The response stands; the jar catches up at the next rejection.
488
+ }
489
+ }
316
490
  /** A role session for the binding's `awsRole`, assumed with the stored key. */
317
491
  renewAwsSession(binding, method, observedRevision) {
318
492
  const auth = method.authentication;
@@ -442,9 +616,17 @@ export class CredentialTransport {
442
616
  }
443
617
  const replay = input.retry !== "never" &&
444
618
  (verb === "GET" || verb === "HEAD" || input.idempotencyKey !== undefined);
619
+ // A rejected credential is dead whether or not this request may be sent
620
+ // again, so it is renewed either way and the next call signs with the
621
+ // renewal. Only a replayable request is sent again, exactly once; any
622
+ // other is answered with its rejection.
623
+ const afterRejection = async (rejection, renew, resend) => {
624
+ const renewed = await renew();
625
+ return replay ? resend(renewed) : rejection;
626
+ };
445
627
  const base = targetBase(target).pathname;
446
- let { sign, grant } = await this.authorize(binding, method, input.target);
447
- const send = async (signer) => {
628
+ const { sign, grant, session } = await this.authorize(binding, method, input.target);
629
+ const send = async (signer, cookies) => {
448
630
  const outgoing = {
449
631
  method: verb,
450
632
  headers: new Headers(headers),
@@ -458,17 +640,43 @@ export class CredentialTransport {
458
640
  timeoutMs: Math.min(target.timeoutMs ?? this.options.timeoutMs, this.options.maxTargetTimeoutMs),
459
641
  maxResponseBytes: Math.min(target.maxResponseBytes ?? this.options.maxResponseBytes, this.options.maxTargetResponseBytes),
460
642
  resumableUpload: target.resumableUpload,
643
+ ...cookies,
461
644
  });
462
645
  };
646
+ if (auth.kind === "cookie" && session) {
647
+ const loginTarget = targetOf(method, auth.login.target);
648
+ if (!loginTarget)
649
+ throw new AuthError("invalid_definition");
650
+ const attempt = async (current) => {
651
+ let set = [];
652
+ const response = await send(cookieSigner(auth, current.cookies), {
653
+ redirect: (location) => toLogin(auth, loginTarget, location, url),
654
+ setCookie: (values) => {
655
+ set = values;
656
+ },
657
+ });
658
+ await this.absorb(binding, method, current.revision, set, url);
659
+ const rejection = await sessionRejection(auth, loginTarget, response, url);
660
+ // A rejection by redirect or marker reads as the 401 it means; one
661
+ // by status keeps the app's own answer.
662
+ return {
663
+ rejection,
664
+ response: rejection === "redirect" || rejection === "marker"
665
+ ? new Response(null, { status: 401 })
666
+ : response,
667
+ };
668
+ };
669
+ const first = await attempt(session);
670
+ if (!first.rejection)
671
+ return first.response;
672
+ return afterRejection(first.response, () => this.loginSession(binding, method, session.revision, true), async (renewed) => (await attempt(renewed)).response);
673
+ }
463
674
  const first = await send(sign);
464
675
  if (auth.kind !== "oauth2" ||
465
676
  !grant ||
466
- !replay ||
467
677
  first.status !== (auth.rejectionStatus ?? 401))
468
678
  return first;
469
- // Exactly one replay, only after durable renewal (or a newer committed revision).
470
- grant = await this.renew(binding, method, grant.revision, true);
471
- return send(bearer(secret(grant.tokens.accessToken)));
679
+ return afterRejection(first, () => this.renew(binding, method, grant.revision, true), (renewed) => send(bearer(secret(renewed.tokens.accessToken))));
472
680
  }
473
681
  /**
474
682
  * A socket target's URL, signed by its query placements, for a host's
@@ -521,6 +729,13 @@ export class CredentialTransport {
521
729
  const auth = method.authentication;
522
730
  if (auth.kind === "none")
523
731
  return { sign: () => { } };
732
+ if (auth.kind === "cookie") {
733
+ const stored = cookieSessionFrom(auth, config);
734
+ const session = stored && liveCookies(stored.cookies, Date.now()).length
735
+ ? stored
736
+ : await this.loginSession(binding, method, stored?.revision);
737
+ return { sign: cookieSigner(auth, session.cookies), session };
738
+ }
524
739
  if (auth.kind === "awsSigV4") {
525
740
  const scope = targetOf(method, target)?.aws;
526
741
  if (!scope)
@@ -139,7 +139,8 @@ export type SecretPlacement = ({
139
139
  * How a request is signed. `field` names the one top-level config field
140
140
  * holding the secret: for `static`, a record of the named `parts`
141
141
  * (`staticSecretSchema(parts)`), each sent through its placements; for
142
- * `oauth2`, a revisioned OAuthGrant; for `awsSigV4`, an AWS access key.
142
+ * `oauth2`, a revisioned OAuthGrant; for `awsSigV4`, an AWS access key;
143
+ * for `cookie`, a revisioned `CookieSession` jar.
143
144
  * `none` signs nothing: the method of a connection that holds no optional
144
145
  * credential, still pinned to its targets.
145
146
  */
@@ -190,7 +191,79 @@ export type CredentialAuthentication = {
190
191
  assumeRole?: {
191
192
  sessionField: string;
192
193
  };
194
+ } | {
195
+ /**
196
+ * A web app's cookie session. The transport logs in with the
197
+ * binding's `resourceOwner` as `login` declares, keeps every cookie
198
+ * the method's targets set in a jar in `field` (`CookieSessionSchema`),
199
+ * sends each only where RFC 6265 matches it, and logs in again when a
200
+ * response shows the session was rejected. The plugin never sees the
201
+ * password, the jar, or a `Set-Cookie`.
202
+ */
203
+ kind: "cookie";
204
+ field: string;
205
+ login: CookieLogin;
206
+ rejection: CookieRejection;
207
+ /**
208
+ * Cookie-based CSRF (Django's `csrftoken`, `XSRF-TOKEN`): the named
209
+ * cookie's value is copied into `header` on every unsafe request, the
210
+ * login included. The header is reserved: callers never set it.
211
+ */
212
+ csrf?: {
213
+ cookie: string;
214
+ header: string;
215
+ };
193
216
  };
217
+ /**
218
+ * How the transport logs in: an optional preflight GET (a CSRF cookie, a
219
+ * session to log into), then one POST of the owner's username and password
220
+ * under the app's own field names, beside fixed `parameters`. Both go to
221
+ * `target` and carry the jar, so a device-trust cookie rides along.
222
+ */
223
+ export interface CookieLogin {
224
+ target: string;
225
+ /** Relative to the target's base, as a request path is. */
226
+ path: string;
227
+ encoding: "form" | "json";
228
+ /** The app's names for the owner's fields; `username` and `password` when absent. */
229
+ fields?: {
230
+ username?: string;
231
+ password?: string;
232
+ };
233
+ /** Fixed extra fields, such as `remember: "1"`. Never a secret. */
234
+ parameters?: Record<string, string>;
235
+ /** A path on `target` to GET before the POST. */
236
+ preflight?: string;
237
+ }
238
+ /**
239
+ * What a rejected session looks like, at least one of: a status; a 3xx
240
+ * whose Location falls under one of `loginPaths` (relative to the login
241
+ * target, as `login.path` is); or a 2xx HTML answer containing `marker`.
242
+ */
243
+ export interface CookieRejection {
244
+ /** 4xx statuses, such as 401, 403, Laravel's 419 or IIS's 440. */
245
+ statuses?: number[];
246
+ loginPaths?: string[];
247
+ marker?: string;
248
+ }
249
+ /** One cookie in a session's jar, as RFC 6265 stores it. */
250
+ export interface StoredCookie {
251
+ name: string;
252
+ value: string;
253
+ /** Lowercased: the exact host when `hostOnly`, else the Domain attribute. */
254
+ domain: string;
255
+ hostOnly: boolean;
256
+ path: string;
257
+ secure: boolean;
258
+ httpOnly: boolean;
259
+ /** Milliseconds since epoch; absent for a session cookie, kept until replaced. */
260
+ expiresAt?: number;
261
+ }
262
+ /** A cookie jar as stored. `revision` changes on every login, never on a rotated cookie. */
263
+ export interface CookieSession {
264
+ cookies: StoredCookie[];
265
+ revision: string;
266
+ }
194
267
  export interface CredentialProbe {
195
268
  target: string;
196
269
  /** Fixed relative path, including any fixed query. No expressions or callbacks. */
package/dist/index.d.ts CHANGED
@@ -18,11 +18,11 @@ export { awsCredential, awsFailed, awsJson, awsList, awsQuery, awsTarget, } from
18
18
  export { brokerFor } from "./credentials/broker.js";
19
19
  export { downloadResource, type PresignedRequestOptions, presignedRequest, } from "./credentials/http.js";
20
20
  export type { CredentialCall, EmptyAnswer, ErrorEnvelope, StaticCredentialSpec, } from "./credentials/plugin.js";
21
- export { answerFailed, configChoice, configFlag, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
22
- export { AwsAccessKeySchema, AwsSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
21
+ export { answerFailed, configChoice, configFlag, cookieSessionSchema, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
22
+ export { AwsAccessKeySchema, AwsSessionSchema, CookieSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, StoredCookieSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
23
23
  export type { AuthenticatedRequest, CredentialBroker, CredentialBrokerCall, CredentialTransportOptions, SocketRequest, } from "./credentials/transport.js";
24
24
  export { CredentialTransport } from "./credentials/transport.js";
25
- export type { AwsAccessKey, AwsRole, AwsScope, AwsSession, CredentialAuthentication, CredentialBinding, CredentialDeclaration, CredentialMethod, CredentialProbe, CredentialProbeResult, CredentialSelection, CredentialTarget, CredentialType, HttpMethod, LocalSecretPart, LocalSecretSource, OAuthGrant, SecretPlacement, } from "./credentials/types.js";
25
+ export type { AwsAccessKey, AwsRole, AwsScope, AwsSession, CookieLogin, CookieRejection, CookieSession, CredentialAuthentication, CredentialBinding, CredentialDeclaration, CredentialMethod, CredentialProbe, CredentialProbeResult, CredentialSelection, CredentialTarget, CredentialType, HttpMethod, LocalSecretPart, LocalSecretSource, OAuthGrant, SecretPlacement, StoredCookie, } from "./credentials/types.js";
26
26
  export type { ActionDefinition, PluginFunction, RunlinePluginAPI, } from "./plugin/api.js";
27
27
  export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
28
28
  export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
package/dist/index.js CHANGED
@@ -11,8 +11,8 @@ export { awsEndpoint, awsRegion, parseAwsXml } from "./credentials/aws.js";
11
11
  export { awsCredential, awsFailed, awsJson, awsList, awsQuery, awsTarget, } from "./credentials/aws-plugin.js";
12
12
  export { brokerFor } from "./credentials/broker.js";
13
13
  export { downloadResource, presignedRequest, } from "./credentials/http.js";
14
- export { answerFailed, configChoice, configFlag, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
15
- export { AwsAccessKeySchema, AwsSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
14
+ export { answerFailed, configChoice, configFlag, cookieSessionSchema, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
15
+ export { AwsAccessKeySchema, AwsSessionSchema, CookieSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, StoredCookieSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
16
16
  export { CredentialTransport } from "./credentials/transport.js";
17
17
  export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
18
18
  export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",