ankka-console 0.10.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 (99) hide show
  1. package/README.md +32 -0
  2. package/dist/auth/oidc.d.ts +64 -0
  3. package/dist/auth/oidc.js +199 -0
  4. package/dist/client/control-plane.d.ts +67 -0
  5. package/dist/client/control-plane.js +208 -0
  6. package/dist/client/errors.d.ts +26 -0
  7. package/dist/client/errors.js +59 -0
  8. package/dist/client/index.d.ts +4 -0
  9. package/dist/client/index.js +3 -0
  10. package/dist/client/schemas.d.ts +365 -0
  11. package/dist/client/schemas.js +221 -0
  12. package/dist/context.d.ts +83 -0
  13. package/dist/context.js +156 -0
  14. package/dist/extensions/render.d.ts +20 -0
  15. package/dist/extensions/render.js +49 -0
  16. package/dist/extensions/types.d.ts +44 -0
  17. package/dist/extensions/types.js +30 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +5 -0
  20. package/dist/middleware.d.ts +14 -0
  21. package/dist/middleware.js +48 -0
  22. package/dist/options.d.ts +45 -0
  23. package/dist/options.js +39 -0
  24. package/dist/routes/auth.callback.d.ts +11 -0
  25. package/dist/routes/auth.callback.js +43 -0
  26. package/dist/routes/auth.sign-in.d.ts +11 -0
  27. package/dist/routes/auth.sign-in.js +29 -0
  28. package/dist/routes/auth.sign-out.d.ts +19 -0
  29. package/dist/routes/auth.sign-out.js +37 -0
  30. package/dist/routes/front.d.ts +41 -0
  31. package/dist/routes/front.js +25 -0
  32. package/dist/routes/logs.d.ts +67 -0
  33. package/dist/routes/logs.js +72 -0
  34. package/dist/routes/members.d.ts +45 -0
  35. package/dist/routes/members.js +59 -0
  36. package/dist/routes/organization-new.d.ts +14 -0
  37. package/dist/routes/organization-new.js +30 -0
  38. package/dist/routes/organization.d.ts +43 -0
  39. package/dist/routes/organization.js +82 -0
  40. package/dist/routes/project-new.d.ts +27 -0
  41. package/dist/routes/project-new.js +27 -0
  42. package/dist/routes/project.d.ts +59 -0
  43. package/dist/routes/project.js +61 -0
  44. package/dist/routes/service-apply.d.ts +44 -0
  45. package/dist/routes/service-apply.js +65 -0
  46. package/dist/routes/service.d.ts +71 -0
  47. package/dist/routes/service.js +73 -0
  48. package/dist/routes/stream.project.d.ts +3 -0
  49. package/dist/routes/stream.project.js +25 -0
  50. package/dist/routes/stream.service.d.ts +9 -0
  51. package/dist/routes/stream.service.js +68 -0
  52. package/dist/routes/tokens.d.ts +47 -0
  53. package/dist/routes/tokens.js +50 -0
  54. package/dist/routes.d.ts +28 -0
  55. package/dist/routes.js +48 -0
  56. package/dist/runtime.d.ts +28 -0
  57. package/dist/runtime.js +77 -0
  58. package/dist/server/create-console-server.d.ts +33 -0
  59. package/dist/server/create-console-server.js +203 -0
  60. package/dist/server/index.d.ts +15 -0
  61. package/dist/server/index.js +13 -0
  62. package/dist/server/transport.d.ts +25 -0
  63. package/dist/server/transport.js +97 -0
  64. package/dist/session/cookie-store.d.ts +51 -0
  65. package/dist/session/cookie-store.js +66 -0
  66. package/dist/session/cookies.d.ts +15 -0
  67. package/dist/session/cookies.js +33 -0
  68. package/dist/session/seal.d.ts +12 -0
  69. package/dist/session/seal.js +38 -0
  70. package/dist/session/token-cache.d.ts +21 -0
  71. package/dist/session/token-cache.js +47 -0
  72. package/dist/session/token-source.d.ts +41 -0
  73. package/dist/session/token-source.js +63 -0
  74. package/dist/stream/log-follow.d.ts +18 -0
  75. package/dist/stream/log-follow.js +40 -0
  76. package/dist/stream/registry.d.ts +6 -0
  77. package/dist/stream/registry.js +34 -0
  78. package/dist/stream/sse.d.ts +14 -0
  79. package/dist/stream/sse.js +87 -0
  80. package/dist/styles.css +481 -0
  81. package/dist/testing/fake-control-plane.d.ts +161 -0
  82. package/dist/testing/fake-control-plane.js +715 -0
  83. package/dist/testing/fake-issuer.d.ts +66 -0
  84. package/dist/testing/fake-issuer.js +303 -0
  85. package/dist/testing/index.d.ts +5 -0
  86. package/dist/testing/index.js +3 -0
  87. package/dist/testing/scenarios.d.ts +6 -0
  88. package/dist/testing/scenarios.js +44 -0
  89. package/dist/ui/console.d.ts +60 -0
  90. package/dist/ui/console.js +73 -0
  91. package/dist/ui/errors.d.ts +6 -0
  92. package/dist/ui/errors.js +30 -0
  93. package/dist/ui/refused.d.ts +6 -0
  94. package/dist/ui/refused.js +12 -0
  95. package/dist/ui/status.d.ts +5 -0
  96. package/dist/ui/status.js +23 -0
  97. package/dist/ui/use-stream.d.ts +59 -0
  98. package/dist/ui/use-stream.js +67 -0
  99. package/package.json +79 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * What every package route receives about the request it is serving: a control plane client that
3
+ * acts as the signed-in person, where the package is mounted, and how to send someone to sign in.
4
+ */
5
+ import { createContext, data, redirect } from "react-router";
6
+ import { ControlPlaneClient } from "./client/control-plane.js";
7
+ import { ControlPlaneError, ControlPlaneUnreachable, SignInRequired } from "./client/errors.js";
8
+ import { IdentityProviderUnavailable } from "./auth/oidc.js";
9
+ export const consoleContext = createContext();
10
+ /** The context the console's middleware put on this request; a clear error if the host did not install it. */
11
+ export function useConsoleContext(context) {
12
+ try {
13
+ return context.get(consoleContext);
14
+ }
15
+ catch {
16
+ throw new Error("the console's middleware is not installed: add consoleMiddleware(options) to the root route's `middleware`");
17
+ }
18
+ }
19
+ function decodeClaims(token) {
20
+ try {
21
+ return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export function principalOf(token) {
28
+ if (!token)
29
+ return null;
30
+ const claims = decodeClaims(token);
31
+ if (!claims || typeof claims.sub !== "string")
32
+ return null;
33
+ const roles = claims.realm_access?.roles ?? [];
34
+ return {
35
+ subject: claims.sub,
36
+ name: typeof claims.name === "string" ? claims.name : undefined,
37
+ email: typeof claims.email === "string" ? claims.email : undefined,
38
+ platformAdmin: roles.includes("platform-admin"),
39
+ };
40
+ }
41
+ /** Only a path within the console is ever returned to after sign-in; anything else lands on the front page. */
42
+ export function safeReturnTo(runtime, value) {
43
+ if (!value || !value.startsWith(runtime.mount) || value.startsWith("//") || value.includes("\\"))
44
+ return runtime.mount;
45
+ try {
46
+ const url = new URL(value, "http://console.invalid");
47
+ if (url.origin !== "http://console.invalid")
48
+ return runtime.mount;
49
+ return url.pathname + url.search;
50
+ }
51
+ catch {
52
+ return runtime.mount;
53
+ }
54
+ }
55
+ export function createConsoleContext(runtime, request) {
56
+ const headers = new Headers();
57
+ let token;
58
+ let known = null;
59
+ const accessToken = (refresh = false) => {
60
+ if (refresh || !token) {
61
+ token = runtime.tokens.accessToken(request, headers, { refresh });
62
+ token.then((t) => (known = t)).catch(() => undefined);
63
+ }
64
+ return token;
65
+ };
66
+ const href = (path = "") => runtime.mount + path.replace(/^\/+/, "");
67
+ return {
68
+ runtime,
69
+ request,
70
+ headers,
71
+ mount: runtime.mount,
72
+ extensions: runtime.options.extensions ?? {},
73
+ client: new ControlPlaneClient({
74
+ baseUrl: runtime.options.controlPlane.url,
75
+ transport: runtime.controlPlaneFetch,
76
+ bearer: ({ refresh }) => accessToken(refresh),
77
+ }),
78
+ href,
79
+ signInUrl(returnTo) {
80
+ const url = new URL(request.url);
81
+ const back = returnTo ?? url.pathname.replace(/\.data$/, "") + url.search;
82
+ return `${href("auth/sign-in")}?returnTo=${encodeURIComponent(back)}`;
83
+ },
84
+ accessToken: () => accessToken(false),
85
+ async principal() {
86
+ return principalOf(await accessToken(false));
87
+ },
88
+ knownPrincipal: () => principalOf(known),
89
+ };
90
+ }
91
+ /**
92
+ * Runs a loader's or action's work, turning what the person should see into responses: sign in
93
+ * again, access lost, a refusal, something unavailable. Anything else is a bug and propagates.
94
+ */
95
+ export async function guard(ctx, work) {
96
+ try {
97
+ return await work();
98
+ }
99
+ catch (e) {
100
+ throw failureResponse(ctx, e);
101
+ }
102
+ }
103
+ export function failureResponse(ctx, e) {
104
+ // The middleware adds any changed session to the redirect, as to every response.
105
+ if (e instanceof SignInRequired)
106
+ return redirect(ctx.signInUrl());
107
+ if (e instanceof IdentityProviderUnavailable) {
108
+ return data({ kind: "unavailable", what: "identity provider", reason: e.message }, { status: 503, headers: { "retry-after": "5" } });
109
+ }
110
+ if (e instanceof ControlPlaneUnreachable) {
111
+ return data({ kind: "unavailable", what: "control plane", reason: e.message }, { status: 503, headers: { "retry-after": "5" } });
112
+ }
113
+ if (e instanceof ControlPlaneError) {
114
+ if (e.status === 404)
115
+ return data({ kind: "lost", reason: e.reason }, { status: 404 });
116
+ if (e.retryable)
117
+ return data({ kind: "unavailable", what: "control plane", reason: e.reason }, { status: e.status });
118
+ return data({ kind: "refused", status: e.status, reason: e.reason }, { status: e.status });
119
+ }
120
+ return e;
121
+ }
122
+ /**
123
+ * Runs an action's work. A refusal the person can act on — a `400`, `403` or `409` — comes back as
124
+ * data for the page, with the form's values, so the form is shown again with the reason beside it.
125
+ * Everything else is what `guard` makes of it.
126
+ */
127
+ export async function act(ctx, intent, form, work) {
128
+ try {
129
+ return await work();
130
+ }
131
+ catch (e) {
132
+ if (e instanceof ControlPlaneError && (e.status === 400 || e.status === 403 || e.status === 409)) {
133
+ const values = {};
134
+ for (const [k, v] of form.entries())
135
+ if (typeof v === "string" && k !== "password")
136
+ values[k] = v;
137
+ return data({ intent, status: e.status, reason: e.reason, problems: e.problems, values }, { status: e.status });
138
+ }
139
+ throw failureResponse(ctx, e);
140
+ }
141
+ }
142
+ export const text = (form, name) => {
143
+ const v = form.get(name);
144
+ return typeof v === "string" ? v.trim() : "";
145
+ };
146
+ /**
147
+ * The page data every package page returns. It is also what makes a page require a session: nobody
148
+ * signed in is `SignInRequired`, which `guard` turns into the trip to sign in, even for a page whose
149
+ * loader asks the control plane nothing.
150
+ */
151
+ export async function pageData(ctx) {
152
+ const principal = await ctx.principal();
153
+ if (!principal)
154
+ throw new SignInRequired();
155
+ return { mount: ctx.mount, principal, hidden: ctx.extensions.hidden ?? [] };
156
+ }
@@ -0,0 +1,20 @@
1
+ import type { ConsoleContext } from "../context.ts";
2
+ import type { Operation, PanelEntities, PanelKind } from "./types.ts";
3
+ export type LoadedPanels = Record<string, {
4
+ ok: true;
5
+ data: unknown;
6
+ } | {
7
+ ok: false;
8
+ error: string;
9
+ }>;
10
+ /** Runs every registered panel's `load` for this entity; used by the package's own loaders. */
11
+ export declare function loadPanels<K extends PanelKind>(ctx: ConsoleContext, kind: K, entity: PanelEntities[K]): Promise<LoadedPanels>;
12
+ export declare function Panels<K extends PanelKind>({ kind, entity, loaded }: {
13
+ kind: K;
14
+ entity: PanelEntities[K];
15
+ loaded?: LoadedPanels;
16
+ }): import("react").JSX.Element | null;
17
+ export declare function HostActions({ operation, entity }: {
18
+ operation: Operation;
19
+ entity?: unknown;
20
+ }): import("react").JSX.Element;
@@ -0,0 +1,49 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Where a host's extensions appear. Panels are loaded on the server with the page (a failure is
4
+ * that panel's, not the page's) and rendered inside their own error boundary; actions sit beside the
5
+ * operation they were declared for.
6
+ */
7
+ import { Component } from "react";
8
+ import { useConsole } from "../ui/console.js";
9
+ /** Runs every registered panel's `load` for this entity; used by the package's own loaders. */
10
+ export async function loadPanels(ctx, kind, entity) {
11
+ const panels = (ctx.extensions.panels?.[kind] ?? []);
12
+ const settled = await Promise.allSettled(panels.map((p) => (p.load ? p.load({ request: ctx.request, accessToken: () => ctx.accessToken() }, entity) : Promise.resolve(undefined))));
13
+ const out = {};
14
+ panels.forEach((p, i) => {
15
+ const r = settled[i];
16
+ out[p.id] = r.status === "fulfilled" ? { ok: true, data: r.value } : { ok: false, error: r.reason instanceof Error ? r.reason.message : String(r.reason) };
17
+ });
18
+ return out;
19
+ }
20
+ class PanelBoundary extends Component {
21
+ state = { error: null };
22
+ static getDerivedStateFromError(error) {
23
+ return { error: error instanceof Error ? error.message : String(error) };
24
+ }
25
+ componentDidCatch(_error, _info) {
26
+ // Contained: the page around the panel keeps working.
27
+ }
28
+ render() {
29
+ if (this.state.error !== null) {
30
+ return _jsxs("p", { className: "ac-panel-failed", children: [this.props.title, " could not be shown: ", this.state.error] });
31
+ }
32
+ return this.props.children;
33
+ }
34
+ }
35
+ export function Panels({ kind, entity, loaded }) {
36
+ const { extensions } = useConsole();
37
+ const panels = (extensions.panels?.[kind] ?? []);
38
+ if (panels.length === 0)
39
+ return null;
40
+ return (_jsx(_Fragment, { children: panels.map((p) => {
41
+ const result = loaded?.[p.id];
42
+ return (_jsxs("section", { className: "ac-panel", "aria-labelledby": `panel-${p.id}`, "data-panel": p.id, children: [_jsx("h2", { id: `panel-${p.id}`, children: p.title }), result && !result.ok ? (_jsxs("p", { className: "ac-panel-failed", children: [p.title, " could not be loaded: ", result.error] })) : (_jsx(PanelBoundary, { title: p.title, children: _jsx(p.Component, { entity: entity, data: result?.ok ? result.data : undefined }) }))] }, p.id));
43
+ }) }));
44
+ }
45
+ export function HostActions({ operation, entity }) {
46
+ const { extensions } = useConsole();
47
+ const actions = extensions.actions?.[operation] ?? [];
48
+ return (_jsx(_Fragment, { children: actions.map((a) => (_jsx("a", { className: "ac-button ac-button-quiet", href: a.href(entity), "data-action": a.id, children: a.label }, a.id))) }));
49
+ }
@@ -0,0 +1,44 @@
1
+ import type { ComponentType } from "react";
2
+ import type { OrganizationSummary, ProjectDetail, ServiceStatus } from "../client/schemas.ts";
3
+ /** Every operation the console offers, by the name a host uses to add beside it or hide it. */
4
+ export declare const operations: readonly ["organization.create", "organization.rename", "organization.delete", "organization.disable", "organization.enable", "organization.quota.set", "organization.quota.clear", "member.invite", "member.role", "member.remove", "invitation.withdraw", "member.repair", "token.create", "token.revoke", "project.create", "project.rename", "project.delete", "registry.set", "registry.clear", "service.apply", "service.pause", "service.resume", "service.restart", "service.expose", "service.unexpose", "service.delete", "service.logs"];
5
+ export type Operation = (typeof operations)[number];
6
+ export interface PanelEntities {
7
+ organization: OrganizationSummary;
8
+ project: ProjectDetail;
9
+ service: ServiceStatus;
10
+ }
11
+ export type PanelKind = keyof PanelEntities;
12
+ /** What a panel's `load` receives: the request and a bearer for calls of the host's own. */
13
+ export interface PanelLoadContext {
14
+ request: Request;
15
+ accessToken(): Promise<string | null>;
16
+ }
17
+ /**
18
+ * A section a host adds to an organization's, project's or service's page. `load` runs on the
19
+ * server with the page's own data; a failure in it, or in `Component`, is shown in the panel's place
20
+ * and leaves the page intact.
21
+ */
22
+ export interface Panel<E = unknown> {
23
+ id: string;
24
+ title: string;
25
+ load?: (context: PanelLoadContext, entity: E) => Promise<unknown>;
26
+ Component: ComponentType<{
27
+ entity: E;
28
+ data: unknown;
29
+ }>;
30
+ }
31
+ /** A control a host adds beside one of the console's operations: a link of the host's own. */
32
+ export interface Action<E = unknown> {
33
+ id: string;
34
+ label: string;
35
+ href: (entity: E | undefined) => string;
36
+ }
37
+ export interface ConsoleExtensions {
38
+ panels?: {
39
+ [K in PanelKind]?: Panel<PanelEntities[K]>[];
40
+ };
41
+ actions?: Partial<Record<Operation, Action<never>[]>>;
42
+ /** Operations whose control is not shown. The control plane's own rule still stands if one is posted anyway. */
43
+ hidden?: Operation[];
44
+ }
@@ -0,0 +1,30 @@
1
+ /** Every operation the console offers, by the name a host uses to add beside it or hide it. */
2
+ export const operations = [
3
+ "organization.create",
4
+ "organization.rename",
5
+ "organization.delete",
6
+ "organization.disable",
7
+ "organization.enable",
8
+ "organization.quota.set",
9
+ "organization.quota.clear",
10
+ "member.invite",
11
+ "member.role",
12
+ "member.remove",
13
+ "invitation.withdraw",
14
+ "member.repair",
15
+ "token.create",
16
+ "token.revoke",
17
+ "project.create",
18
+ "project.rename",
19
+ "project.delete",
20
+ "registry.set",
21
+ "registry.clear",
22
+ "service.apply",
23
+ "service.pause",
24
+ "service.resume",
25
+ "service.restart",
26
+ "service.expose",
27
+ "service.unexpose",
28
+ "service.delete",
29
+ "service.logs",
30
+ ];
@@ -0,0 +1,5 @@
1
+ export { ConsoleProvider, useConsole, ConsoleLink, ConsoleForm } from "./ui/console.tsx";
2
+ export type { UseConsole } from "./ui/console.tsx";
3
+ export type { Principal } from "./context.ts";
4
+ export { operations } from "./extensions/types.ts";
5
+ export type { Action, ConsoleExtensions, Operation, Panel, PanelKind, PanelLoadContext } from "./extensions/types.ts";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // The browser-safe entry point: what a host's layout and its own components use. Everything that
2
+ // runs only on the server — the routes table, the middleware, sessions, the server itself — is
3
+ // `ankka-console/server`, so a host's browser bundle never resolves a Node module.
4
+ export { ConsoleProvider, useConsole, ConsoleLink, ConsoleForm } from "./ui/console.js";
5
+ export { operations } from "./extensions/types.js";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The console's middleware, installed on the host's root route. It builds the request's context,
3
+ * refuses a state-changing request from another site, forbids caching of anything a signed-in person
4
+ * sees, writes back a session that changed while the request was served, and logs one line.
5
+ */
6
+ import type { MiddlewareFunction } from "react-router";
7
+ import type { ConsoleOptions } from "./options.ts";
8
+ /**
9
+ * Whether a state-changing request came from the console's own pages. `SameSite=Lax` already keeps
10
+ * the session cookie off a cross-site POST; this is the second lock, and the one that holds for a
11
+ * host whose sessions are not cookies.
12
+ */
13
+ export declare function crossSite(request: Request, publicOrigin: string): boolean;
14
+ export declare function consoleMiddleware(options: ConsoleOptions | (() => ConsoleOptions)): MiddlewareFunction<Response>;
@@ -0,0 +1,48 @@
1
+ import { consoleContext, createConsoleContext } from "./context.js";
2
+ import { createRuntime } from "./runtime.js";
3
+ const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
4
+ /**
5
+ * Whether a state-changing request came from the console's own pages. `SameSite=Lax` already keeps
6
+ * the session cookie off a cross-site POST; this is the second lock, and the one that holds for a
7
+ * host whose sessions are not cookies.
8
+ */
9
+ export function crossSite(request, publicOrigin) {
10
+ if (SAFE.has(request.method))
11
+ return false;
12
+ if (request.headers.get("sec-fetch-site") === "cross-site")
13
+ return true;
14
+ const origin = request.headers.get("origin");
15
+ if (!origin || origin === "null")
16
+ return origin === "null";
17
+ return origin !== new URL(publicOrigin).origin && origin !== new URL(request.url).origin;
18
+ }
19
+ export function consoleMiddleware(options) {
20
+ let runtime;
21
+ const get = () => (runtime ??= createRuntime(typeof options === "function" ? options() : options));
22
+ return async ({ request, context }, next) => {
23
+ const rt = get();
24
+ const started = performance.now();
25
+ const path = new URL(request.url).pathname;
26
+ if (crossSite(request, rt.options.publicOrigin)) {
27
+ rt.log({ at: new Date().toISOString(), method: request.method, path, status: 403, reason: "cross-site" });
28
+ return new Response("cross-site request refused", { status: 403 });
29
+ }
30
+ const ctx = createConsoleContext(rt, request);
31
+ context.set(consoleContext, ctx);
32
+ const response = await next();
33
+ for (const cookie of ctx.headers.getSetCookie())
34
+ response.headers.append("set-cookie", cookie);
35
+ // Nothing a signed-in person was shown may be kept by any cache, including the browser's back
36
+ // button after sign-out.
37
+ response.headers.set("cache-control", "no-store");
38
+ rt.log({
39
+ at: new Date().toISOString(),
40
+ method: request.method,
41
+ path,
42
+ status: response.status,
43
+ durationMs: Math.round(performance.now() - started),
44
+ subject: ctx.knownPrincipal()?.subject,
45
+ });
46
+ return response;
47
+ };
48
+ }
@@ -0,0 +1,45 @@
1
+ import type { SessionStore } from "./session/cookie-store.ts";
2
+ import type { TokenSource } from "./session/token-source.ts";
3
+ import type { ConsoleExtensions } from "./extensions/types.ts";
4
+ import type { TlsFiles } from "./server/transport.ts";
5
+ /** Everything the console needs from its host. `consoleOptionsFromEnv` builds it from the environment. */
6
+ export interface ConsoleOptions {
7
+ controlPlane: {
8
+ url: string;
9
+ /** The console's certificate, presented to the control plane, and the authority to verify it by. */
10
+ tls?: TlsFiles;
11
+ };
12
+ auth: {
13
+ clientId: string;
14
+ clientSecret: string;
15
+ /** Where server-side calls to the identity provider go when its external name does not resolve here. */
16
+ backchannelUrl?: string;
17
+ /** The authority the backchannel is verified by. */
18
+ ca?: string;
19
+ /** Permits a plain-HTTP issuer: the development Keycloak. */
20
+ allowInsecure?: boolean;
21
+ /** The issuer; read from the control plane's `GET /auth` when absent, so the two cannot disagree. */
22
+ issuer?: string;
23
+ };
24
+ /** The console's own origin as a browser sees it, e.g. `https://console.example.com`. */
25
+ publicOrigin: string;
26
+ /** The path the package's routes are mounted under; `/` unless the host prefixed them. */
27
+ mount?: string;
28
+ /** Seals the default session store and the one-time values; required unless both are supplied. */
29
+ sessionSecret?: string;
30
+ session?: SessionStore;
31
+ /** A host that signs people in itself supplies this; the package's `/auth` routes are then not mounted. */
32
+ tokens?: TokenSource;
33
+ /** Server-side halves of the host's extensions: the panels' `load` functions. */
34
+ extensions?: ConsoleExtensions;
35
+ /** One structured line per request. Defaults to stdout. Never given a cookie, header or token. */
36
+ log?: (line: Record<string, unknown>) => void;
37
+ }
38
+ type Env = Record<string, string | undefined>;
39
+ /**
40
+ * The options, from the variables the installation's manifests set (see `platform/console.md`).
41
+ * `ANKKA_CONSOLE_TLS_DIR` is what makes this a cluster console: HTTPS on the public origin, a
42
+ * `__Host-` cookie, and the certificate in it presented to the control plane.
43
+ */
44
+ export declare function consoleOptionsFromEnv(env?: Env, extensions?: ConsoleExtensions): ConsoleOptions;
45
+ export {};
@@ -0,0 +1,39 @@
1
+ function required(env, name) {
2
+ const value = env[name]?.trim();
3
+ if (!value)
4
+ throw new Error(`${name} is not set`);
5
+ return value;
6
+ }
7
+ /**
8
+ * The options, from the variables the installation's manifests set (see `platform/console.md`).
9
+ * `ANKKA_CONSOLE_TLS_DIR` is what makes this a cluster console: HTTPS on the public origin, a
10
+ * `__Host-` cookie, and the certificate in it presented to the control plane.
11
+ */
12
+ export function consoleOptionsFromEnv(env = process.env, extensions) {
13
+ const tlsDir = env.ANKKA_CONSOLE_TLS_DIR?.trim();
14
+ // In a cluster the console's own address is not guessable, and a wrong one is a sign-in the
15
+ // identity provider refuses to return from; only a laptop's console defaults to localhost.
16
+ const authority = tlsDir
17
+ ? required(env, "ANKKA_CONSOLE_AUTHORITY")
18
+ : env.ANKKA_CONSOLE_AUTHORITY?.trim() || `localhost:${env.ANKKA_CONSOLE_PORT?.trim() || "3000"}`;
19
+ if (/[A-Z_]{6,}/.test(authority))
20
+ throw new Error(`ANKKA_CONSOLE_AUTHORITY is '${authority}', an unfilled placeholder`);
21
+ return {
22
+ controlPlane: {
23
+ url: env.ANKKA_CONSOLE_CONTROL_PLANE_URL?.trim() || "http://localhost:9000",
24
+ tls: tlsDir ? { ca: `${tlsDir}/ca.crt`, cert: `${tlsDir}/tls.crt`, key: `${tlsDir}/tls.key` } : undefined,
25
+ },
26
+ auth: {
27
+ clientId: env.ANKKA_CONSOLE_CLIENT_ID?.trim() || "ankka-console",
28
+ clientSecret: required(env, "ANKKA_CONSOLE_CLIENT_SECRET"),
29
+ backchannelUrl: env.ANKKA_CONSOLE_AUTH_BACKCHANNEL_URL?.trim() || undefined,
30
+ ca: env.ANKKA_CONSOLE_AUTH_CA?.trim() || undefined,
31
+ allowInsecure: env.ANKKA_CONSOLE_ALLOW_INSECURE_ISSUER === "true",
32
+ issuer: env.ANKKA_CONSOLE_AUTH_ISSUER?.trim() || undefined,
33
+ },
34
+ publicOrigin: `${tlsDir ? "https" : "http"}://${authority}`,
35
+ mount: env.ANKKA_CONSOLE_MOUNT?.trim() || "/",
36
+ sessionSecret: required(env, "ANKKA_CONSOLE_SESSION_SECRET"),
37
+ extensions,
38
+ };
39
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Finishes a sign-in. The callback is accepted only with the state this browser was given, the
3
+ * identity token is checked for issuer, audience, nonce, signature and expiry, and only the refresh
4
+ * token is kept — sealed in a cookie script cannot read.
5
+ */
6
+ import { type LoaderFunctionArgs, type MetaFunction } from "react-router";
7
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
8
+ export declare const meta: MetaFunction;
9
+ export declare function loader({ request, context }: LoaderFunctionArgs): Promise<Response>;
10
+ export default function Callback(): null;
11
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Finishes a sign-in. The callback is accepted only with the state this browser was given, the
3
+ * identity token is checked for issuer, audience, nonce, signature and expiry, and only the refresh
4
+ * token is kept — sealed in a cookie script cannot read.
5
+ */
6
+ import { data, redirect } from "react-router";
7
+ import { failureResponse, useConsoleContext } from "../context.js";
8
+ import { IdentityProviderUnavailable } from "../auth/oidc.js";
9
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
10
+ export const meta = () => [{ title: "Signing in · ankka" }];
11
+ const startAgain = (reason) => data({ kind: "refused", status: 400, reason }, { status: 400 });
12
+ export async function loader({ request, context }) {
13
+ const ctx = useConsoleContext(context);
14
+ const rt = ctx.runtime;
15
+ if (!rt.issuer || !rt.sessionTokens)
16
+ throw new Response("This console signs people in elsewhere.", { status: 404 });
17
+ const pending = await rt.pendingLogin.take(request, ctx.headers);
18
+ if (!pending)
19
+ throw startAgain("This sign-in was not started here, or took longer than ten minutes. Sign in again.");
20
+ const incoming = new URL(request.url);
21
+ if (incoming.searchParams.get("error")) {
22
+ throw startAgain(`The identity provider did not sign you in: ${incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error")}.`);
23
+ }
24
+ // The address the identity provider was told to return to, whatever this request's own URL looks
25
+ // like behind the gateway.
26
+ const callback = new URL(ctx.href("auth/callback") + incoming.search, rt.options.publicOrigin);
27
+ try {
28
+ const issuer = await rt.issuer();
29
+ const tokens = await issuer.finishSignIn(callback, pending);
30
+ await rt.store.write({ refreshToken: tokens.refreshToken, issuedAt: Math.floor(Date.now() / 1000) }, ctx.headers);
31
+ rt.sessionTokens.remember(tokens);
32
+ return redirect(pending.returnTo);
33
+ }
34
+ catch (e) {
35
+ if (e instanceof IdentityProviderUnavailable)
36
+ throw failureResponse(ctx, e);
37
+ throw startAgain("This sign-in could not be completed. Sign in again.");
38
+ }
39
+ }
40
+ export default function Callback() {
41
+ return null;
42
+ }
43
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Starts a sign-in: a PKCE verifier, a state and a nonce sealed into a short-lived cookie, and the
3
+ * browser sent to the identity provider. The page itself is only ever seen when the identity
4
+ * provider cannot be reached.
5
+ */
6
+ import { type LoaderFunctionArgs, type MetaFunction } from "react-router";
7
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
8
+ export declare const meta: MetaFunction;
9
+ export declare function loader({ request, context }: LoaderFunctionArgs): Promise<Response>;
10
+ export default function SignIn(): null;
11
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Starts a sign-in: a PKCE verifier, a state and a nonce sealed into a short-lived cookie, and the
3
+ * browser sent to the identity provider. The page itself is only ever seen when the identity
4
+ * provider cannot be reached.
5
+ */
6
+ import { redirect } from "react-router";
7
+ import { failureResponse, safeReturnTo, useConsoleContext } from "../context.js";
8
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
9
+ export const meta = () => [{ title: "Sign in · ankka" }];
10
+ export async function loader({ request, context }) {
11
+ const ctx = useConsoleContext(context);
12
+ const rt = ctx.runtime;
13
+ if (!rt.issuer)
14
+ throw new Response("This console signs people in elsewhere.", { status: 404 });
15
+ const returnTo = safeReturnTo(rt, new URL(request.url).searchParams.get("returnTo"));
16
+ try {
17
+ const issuer = await rt.issuer();
18
+ const began = await issuer.beginSignIn(new URL(ctx.href("auth/callback"), rt.options.publicOrigin).toString());
19
+ await rt.pendingLogin.write({ state: began.state, nonce: began.nonce, verifier: began.verifier, returnTo }, ctx.headers);
20
+ return redirect(began.url.toString());
21
+ }
22
+ catch (e) {
23
+ throw failureResponse(ctx, e);
24
+ }
25
+ }
26
+ export default function SignIn() {
27
+ return null;
28
+ }
29
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Signs out: the session cookie is cleared, this instance forgets the access token, and the
3
+ * identity provider's session is ended server to server, so the next visit asks for a sign-in rather
4
+ * than passing silently through single sign-on. Only a POST signs anyone out.
5
+ */
6
+ import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "react-router";
7
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
8
+ export declare const meta: MetaFunction;
9
+ export declare function loader({ request, context }: LoaderFunctionArgs): Promise<{
10
+ console: import("../context.ts").ConsolePageData | {
11
+ mount: string;
12
+ principal: null;
13
+ hidden: never[];
14
+ };
15
+ signedIn: boolean;
16
+ }>;
17
+ export declare function action({ request, context }: ActionFunctionArgs): Promise<Response>;
18
+ export default function SignOut(): import("react").JSX.Element;
19
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,37 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Signs out: the session cookie is cleared, this instance forgets the access token, and the
4
+ * identity provider's session is ended server to server, so the next visit asks for a sign-in rather
5
+ * than passing silently through single sign-on. Only a POST signs anyone out.
6
+ */
7
+ import { redirect, useLoaderData } from "react-router";
8
+ import { pageData, useConsoleContext } from "../context.js";
9
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
10
+ import { ConsoleForm, ConsoleLink, Submit } from "../ui/console.js";
11
+ export const meta = () => [{ title: "Sign out · ankka" }];
12
+ export async function loader({ request, context }) {
13
+ const ctx = useConsoleContext(context);
14
+ const signedIn = (await ctx.runtime.store.read(request)) !== null;
15
+ return { console: await pageData(ctx).catch(() => ({ mount: ctx.mount, principal: null, hidden: [] })), signedIn };
16
+ }
17
+ export async function action({ request, context }) {
18
+ const ctx = useConsoleContext(context);
19
+ const rt = ctx.runtime;
20
+ const session = await rt.store.read(request);
21
+ await rt.store.clear(ctx.headers);
22
+ if (session) {
23
+ rt.sessionTokens?.forget(session.refreshToken);
24
+ // A session that cannot be ended now ends on its own at the realm's idle timeout; the browser is
25
+ // signed out of the console either way.
26
+ await rt
27
+ .issuer?.()
28
+ .then((issuer) => issuer.endSession(session.refreshToken))
29
+ .catch((e) => rt.log({ at: new Date().toISOString(), event: "sign-out", error: e instanceof Error ? e.message : String(e) }));
30
+ }
31
+ return redirect(ctx.href("auth/sign-out"));
32
+ }
33
+ export default function SignOut() {
34
+ const { signedIn } = useLoaderData();
35
+ return (_jsx("section", { className: "ac-page", children: signedIn ? (_jsxs(_Fragment, { children: [_jsx("h1", { children: "Sign out" }), _jsx(ConsoleForm, { to: "auth/sign-out", children: _jsx(Submit, { children: "Sign out" }) })] })) : (_jsxs(_Fragment, { children: [_jsx("h1", { children: "You are signed out" }), _jsx("p", { children: "Your session in this console and in the identity provider has ended." }), _jsx("p", { children: _jsx(ConsoleLink, { to: "auth/sign-in", className: "ac-button", children: "Sign in" }) })] })) }));
36
+ }
37
+ export const ErrorBoundary = ConsoleErrorBoundary;