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,12 @@
1
+ /**
2
+ * Authenticated encryption for values a browser carries but must not read or alter: AES-256-GCM
3
+ * with a random 96-bit nonce, the cookie's name as additional data (so a value sealed for one cookie
4
+ * cannot be replayed as another), base64url on the wire. The key is derived from the installation's
5
+ * secret with HKDF-SHA256, so the secret itself may be any length and is never used directly.
6
+ */
7
+ import { webcrypto } from "node:crypto";
8
+ export type SealKey = webcrypto.CryptoKey;
9
+ export declare function deriveKey(secret: string, info: string): Promise<SealKey>;
10
+ export declare function seal(value: unknown, key: SealKey, aad: string): Promise<string>;
11
+ /** The sealed value, or `null` for anything tampered with, truncated, or sealed under another key or name. */
12
+ export declare function unseal<T = unknown>(text: string, key: SealKey, aad: string): Promise<T | null>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Authenticated encryption for values a browser carries but must not read or alter: AES-256-GCM
3
+ * with a random 96-bit nonce, the cookie's name as additional data (so a value sealed for one cookie
4
+ * cannot be replayed as another), base64url on the wire. The key is derived from the installation's
5
+ * secret with HKDF-SHA256, so the secret itself may be any length and is never used directly.
6
+ */
7
+ import { webcrypto } from "node:crypto";
8
+ const subtle = webcrypto.subtle;
9
+ const encoder = new TextEncoder();
10
+ const decoder = new TextDecoder();
11
+ export async function deriveKey(secret, info) {
12
+ if (secret.length === 0)
13
+ throw new Error("the session secret must not be empty");
14
+ const material = await subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
15
+ return subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(info) }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
16
+ }
17
+ export async function seal(value, key, aad) {
18
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
19
+ const plain = encoder.encode(JSON.stringify(value));
20
+ const cipher = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv, additionalData: encoder.encode(aad) }, key, plain));
21
+ const out = new Uint8Array(iv.length + cipher.length);
22
+ out.set(iv, 0);
23
+ out.set(cipher, iv.length);
24
+ return Buffer.from(out).toString("base64url");
25
+ }
26
+ /** The sealed value, or `null` for anything tampered with, truncated, or sealed under another key or name. */
27
+ export async function unseal(text, key, aad) {
28
+ try {
29
+ const bytes = Buffer.from(text, "base64url");
30
+ if (bytes.length < 12 + 16)
31
+ return null;
32
+ const plain = await subtle.decrypt({ name: "AES-GCM", iv: bytes.subarray(0, 12), additionalData: encoder.encode(aad) }, key, bytes.subarray(12));
33
+ return JSON.parse(decoder.decode(plain));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
@@ -0,0 +1,21 @@
1
+ export interface AccessTokenEntry {
2
+ accessToken: string;
3
+ /** Epoch milliseconds, already less the skew. */
4
+ expiresAt: number;
5
+ /** The newest refresh token for this session: equal to the key's when the provider did not rotate it. */
6
+ refreshToken: string;
7
+ }
8
+ export declare const digest: (refreshToken: string) => string;
9
+ export declare class TokenCache {
10
+ #private;
11
+ constructor(options?: {
12
+ capacity?: number;
13
+ skewSeconds?: number;
14
+ });
15
+ /** The entry for this refresh token if its access token has not expired. */
16
+ get(refreshToken: string, now?: number): AccessTokenEntry | undefined;
17
+ /** Stores an access token that expires in `expiresInSeconds`, under every refresh token given. */
18
+ put(refreshTokens: string[], entry: Omit<AccessTokenEntry, "expiresAt">, expiresInSeconds: number, now?: number): void;
19
+ forget(refreshToken: string): void;
20
+ get size(): number;
21
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Access tokens, in this instance's memory, keyed by a digest of the refresh token that obtained
3
+ * them. Nothing depends on an entry being here: a miss costs one refresh. Bounded, least recently
4
+ * used first out, so a busy instance's memory does not grow with the number of browsers it has seen.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ export const digest = (refreshToken) => createHash("sha256").update(refreshToken).digest("base64url");
8
+ export class TokenCache {
9
+ #entries = new Map();
10
+ #capacity;
11
+ #skewMs;
12
+ constructor(options = {}) {
13
+ this.#capacity = options.capacity ?? 10_000;
14
+ this.#skewMs = (options.skewSeconds ?? 30) * 1000;
15
+ }
16
+ /** The entry for this refresh token if its access token has not expired. */
17
+ get(refreshToken, now = Date.now()) {
18
+ const key = digest(refreshToken);
19
+ const entry = this.#entries.get(key);
20
+ if (!entry)
21
+ return undefined;
22
+ this.#entries.delete(key);
23
+ if (entry.expiresAt <= now)
24
+ return undefined;
25
+ this.#entries.set(key, entry);
26
+ return entry;
27
+ }
28
+ /** Stores an access token that expires in `expiresInSeconds`, under every refresh token given. */
29
+ put(refreshTokens, entry, expiresInSeconds, now = Date.now()) {
30
+ const stored = { ...entry, expiresAt: now + expiresInSeconds * 1000 - this.#skewMs };
31
+ for (const rt of new Set(refreshTokens)) {
32
+ const key = digest(rt);
33
+ this.#entries.delete(key);
34
+ this.#entries.set(key, stored);
35
+ }
36
+ while (this.#entries.size > this.#capacity) {
37
+ const oldest = this.#entries.keys().next().value;
38
+ this.#entries.delete(oldest);
39
+ }
40
+ }
41
+ forget(refreshToken) {
42
+ this.#entries.delete(digest(refreshToken));
43
+ }
44
+ get size() {
45
+ return this.#entries.size;
46
+ }
47
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The signed-in person's access token for this request. The only thing a page's data ever needs
3
+ * from a session, and the seam a host with its own sign-in replaces.
4
+ */
5
+ import type { Tokens } from "../auth/oidc.ts";
6
+ /** The one thing a token source asks of the identity provider. */
7
+ export interface Refresher {
8
+ refresh(refreshToken: string): Promise<Tokens>;
9
+ }
10
+ import type { SessionStore } from "./cookie-store.ts";
11
+ import { type TokenCache } from "./token-cache.ts";
12
+ export interface TokenSource {
13
+ /**
14
+ * The bearer for this request, obtaining a fresh one when `refresh` is set or none is held;
15
+ * `null` means nobody is signed in and the person is to be sent to sign in. `headers` is where a
16
+ * session that changed on the way — a rotated refresh token, a cleared cookie — is written.
17
+ */
18
+ accessToken(request: Request, headers: Headers, options?: {
19
+ refresh?: boolean;
20
+ }): Promise<string | null>;
21
+ }
22
+ /**
23
+ * Tokens from the package's own sign-in: the refresh token from the session store, access tokens
24
+ * cached in this instance's memory, a refresh when there is none. A refresh that rotates the
25
+ * refresh token caches under both and writes the new one back, so a request still carrying the old
26
+ * cookie — on this instance or another — keeps working until the old token's own expiry.
27
+ */
28
+ export declare class SessionTokenSource implements TokenSource {
29
+ #private;
30
+ constructor(options: {
31
+ store: SessionStore;
32
+ cache: TokenCache;
33
+ issuer: Refresher;
34
+ });
35
+ accessToken(request: Request, headers: Headers, options?: {
36
+ refresh?: boolean;
37
+ }): Promise<string | null>;
38
+ /** Remembers a freshly signed-in session's access token, so its first page needs no refresh. */
39
+ remember(tokens: Tokens): void;
40
+ forget(refreshToken: string): void;
41
+ }
@@ -0,0 +1,63 @@
1
+ import { SessionEnded } from "../auth/oidc.js";
2
+ import { digest } from "./token-cache.js";
3
+ /**
4
+ * Tokens from the package's own sign-in: the refresh token from the session store, access tokens
5
+ * cached in this instance's memory, a refresh when there is none. A refresh that rotates the
6
+ * refresh token caches under both and writes the new one back, so a request still carrying the old
7
+ * cookie — on this instance or another — keeps working until the old token's own expiry.
8
+ */
9
+ export class SessionTokenSource {
10
+ #store;
11
+ #cache;
12
+ #issuer;
13
+ #inFlight = new Map();
14
+ constructor(options) {
15
+ this.#store = options.store;
16
+ this.#cache = options.cache;
17
+ this.#issuer = options.issuer;
18
+ }
19
+ async accessToken(request, headers, options = {}) {
20
+ const session = await this.#store.read(request);
21
+ if (!session)
22
+ return null;
23
+ const cached = this.#cache.get(session.refreshToken);
24
+ if (cached && !options.refresh)
25
+ return cached.accessToken;
26
+ // The newest refresh token this instance knows for the session: the cookie may be one rotation behind.
27
+ const latest = cached?.refreshToken ?? session.refreshToken;
28
+ let tokens;
29
+ try {
30
+ tokens = await this.#refreshOnce(latest);
31
+ }
32
+ catch (e) {
33
+ if (e instanceof SessionEnded) {
34
+ this.#cache.forget(session.refreshToken);
35
+ await this.#store.clear(headers);
36
+ return null;
37
+ }
38
+ throw e;
39
+ }
40
+ this.#cache.put([session.refreshToken, latest, tokens.refreshToken], { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }, tokens.expiresIn);
41
+ if (tokens.refreshToken !== session.refreshToken) {
42
+ await this.#store.write({ refreshToken: tokens.refreshToken, issuedAt: Math.floor(Date.now() / 1000) }, headers);
43
+ }
44
+ return tokens.accessToken;
45
+ }
46
+ /** Remembers a freshly signed-in session's access token, so its first page needs no refresh. */
47
+ remember(tokens) {
48
+ this.#cache.put([tokens.refreshToken], { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }, tokens.expiresIn);
49
+ }
50
+ forget(refreshToken) {
51
+ this.#cache.forget(refreshToken);
52
+ }
53
+ /** Two requests from one browser arriving together refresh once, not twice. */
54
+ #refreshOnce(refreshToken) {
55
+ const key = digest(refreshToken);
56
+ const existing = this.#inFlight.get(key);
57
+ if (existing)
58
+ return existing;
59
+ const pending = this.#issuer.refresh(refreshToken).finally(() => this.#inFlight.delete(key));
60
+ this.#inFlight.set(key, pending);
61
+ return pending;
62
+ }
63
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Following a log that can only be re-read, never resumed: the control plane answers an instance's
3
+ * recent output as text, with no cursor and no line identity. Each read covers a window that overlaps
4
+ * the last; the lines already sent are found as the longest run at the end of what was sent that
5
+ * matches the start of the new window, and only what follows it is new.
6
+ *
7
+ * The one thing this cannot see is a line repeated identically inside one overlap: it may be dropped
8
+ * or sent twice. A service that timestamps its own log lines makes following exact.
9
+ */
10
+ export declare class LogFollower {
11
+ #private;
12
+ constructor(keep?: number);
13
+ /** The lines of `window` not yet sent for `instance`, remembered as sent. */
14
+ next(instance: string, window: string[]): string[];
15
+ /** How many lines at the start of `window` are the last lines of `sent`. */
16
+ static overlap(sent: string[], window: string[]): number;
17
+ }
18
+ export declare const lines: (output: string) => string[];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Following a log that can only be re-read, never resumed: the control plane answers an instance's
3
+ * recent output as text, with no cursor and no line identity. Each read covers a window that overlaps
4
+ * the last; the lines already sent are found as the longest run at the end of what was sent that
5
+ * matches the start of the new window, and only what follows it is new.
6
+ *
7
+ * The one thing this cannot see is a line repeated identically inside one overlap: it may be dropped
8
+ * or sent twice. A service that timestamps its own log lines makes following exact.
9
+ */
10
+ export class LogFollower {
11
+ #keep;
12
+ #sent = new Map();
13
+ constructor(keep = 200) {
14
+ this.#keep = keep;
15
+ }
16
+ /** The lines of `window` not yet sent for `instance`, remembered as sent. */
17
+ next(instance, window) {
18
+ const sent = this.#sent.get(instance) ?? [];
19
+ const overlap = LogFollower.overlap(sent, window);
20
+ const fresh = window.slice(overlap);
21
+ this.#sent.set(instance, [...sent, ...fresh].slice(-this.#keep));
22
+ return fresh;
23
+ }
24
+ /** How many lines at the start of `window` are the last lines of `sent`. */
25
+ static overlap(sent, window) {
26
+ for (let n = Math.min(sent.length, window.length); n > 0; n--) {
27
+ let match = true;
28
+ for (let i = 0; i < n; i++) {
29
+ if (sent[sent.length - n + i] !== window[i]) {
30
+ match = false;
31
+ break;
32
+ }
33
+ }
34
+ if (match)
35
+ return n;
36
+ }
37
+ return 0;
38
+ }
39
+ }
40
+ export const lines = (output) => output.split("\n").filter((l, i, all) => l.length > 0 || i < all.length - 1);
@@ -0,0 +1,6 @@
1
+ type Closer = () => void;
2
+ export declare function registerStream(close: Closer): () => void;
3
+ /** Tells every open stream the server is closing; returns how many there were. */
4
+ export declare function closeAllStreams(): number;
5
+ export declare function openStreamCount(): number;
6
+ export {};
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Every open update stream in this process, so shutdown can tell each one to reconnect elsewhere.
3
+ *
4
+ * Kept on `globalThis` under a registered symbol rather than in module state: the host's server and
5
+ * its app bundle each hold their own copy of this package, and both must see the same set.
6
+ */
7
+ const key = Symbol.for("ankka-console.open-streams");
8
+ function registry() {
9
+ const g = globalThis;
10
+ return (g[key] ??= new Set());
11
+ }
12
+ export function registerStream(close) {
13
+ const set = registry();
14
+ set.add(close);
15
+ return () => set.delete(close);
16
+ }
17
+ /** Tells every open stream the server is closing; returns how many there were. */
18
+ export function closeAllStreams() {
19
+ const set = registry();
20
+ const n = set.size;
21
+ for (const close of [...set]) {
22
+ try {
23
+ close();
24
+ }
25
+ catch {
26
+ // A stream whose client already left has nothing to be told.
27
+ }
28
+ }
29
+ set.clear();
30
+ return n;
31
+ }
32
+ export function openStreamCount() {
33
+ return registry().size;
34
+ }
@@ -0,0 +1,14 @@
1
+ export interface EventSink {
2
+ send(event: string, data: unknown): void;
3
+ /** Ends the stream after sending `event`, if given. */
4
+ end(event?: string, data?: unknown): void;
5
+ readonly closed: boolean;
6
+ }
7
+ export interface PollingStreamOptions {
8
+ signal: AbortSignal;
9
+ intervalMs?: number;
10
+ keepaliveMs?: number;
11
+ /** Called at once and then every interval until the stream ends. */
12
+ tick(sink: EventSink): Promise<void>;
13
+ }
14
+ export declare function pollingStream(options: PollingStreamOptions): Response;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A server-sent event stream fed by polling: every interval, `tick` reads what the page shows and
3
+ * sends what changed. Every event's data is one JSON document on one line — raw text would lose a
4
+ * leading space to the protocol and split on a newline. A comment every fifteen seconds keeps
5
+ * proxies from closing a quiet stream. Shutdown tells the browser to reconnect, which lands it on an
6
+ * instance that is staying.
7
+ */
8
+ import { registerStream } from "./registry.js";
9
+ const encoder = new TextEncoder();
10
+ export function pollingStream(options) {
11
+ const interval = options.intervalMs ?? 2_000;
12
+ const keepalive = options.keepaliveMs ?? 15_000;
13
+ let timer;
14
+ let alive;
15
+ let unregister = () => undefined;
16
+ let closed = false;
17
+ const body = new ReadableStream({
18
+ start(controller) {
19
+ const write = (chunk) => {
20
+ if (closed)
21
+ return;
22
+ try {
23
+ controller.enqueue(encoder.encode(chunk));
24
+ }
25
+ catch {
26
+ stop();
27
+ }
28
+ };
29
+ const stop = () => {
30
+ if (closed)
31
+ return;
32
+ closed = true;
33
+ clearTimeout(timer);
34
+ clearInterval(alive);
35
+ unregister();
36
+ try {
37
+ controller.close();
38
+ }
39
+ catch {
40
+ // already closed by the client leaving
41
+ }
42
+ };
43
+ const sink = {
44
+ send: (event, data) => write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
45
+ end: (event, data) => {
46
+ if (event)
47
+ sink.send(event, data ?? {});
48
+ stop();
49
+ },
50
+ get closed() {
51
+ return closed;
52
+ },
53
+ };
54
+ unregister = registerStream(() => sink.end("server-closing", {}));
55
+ options.signal.addEventListener("abort", stop, { once: true });
56
+ alive = setInterval(() => write(": keepalive\n\n"), keepalive);
57
+ write("retry: 2000\n\n");
58
+ const loop = async () => {
59
+ if (closed)
60
+ return;
61
+ try {
62
+ await options.tick(sink);
63
+ }
64
+ catch {
65
+ // A read that failed this time is tried again next time; a stream never dies of one.
66
+ }
67
+ if (!closed)
68
+ timer = setTimeout(loop, interval);
69
+ };
70
+ void loop();
71
+ },
72
+ cancel() {
73
+ closed = true;
74
+ clearTimeout(timer);
75
+ clearInterval(alive);
76
+ unregister();
77
+ },
78
+ });
79
+ return new Response(body, {
80
+ headers: {
81
+ "content-type": "text/event-stream; charset=utf-8",
82
+ "cache-control": "no-store",
83
+ connection: "keep-alive",
84
+ "x-accel-buffering": "no",
85
+ },
86
+ });
87
+ }