@k2b/cloud 0.8.0 → 0.9.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -98,7 +98,7 @@
98
98
  "@tailwindcss/typography": "0.5.20",
99
99
  "@k2b/nessi": "0.12.0",
100
100
  "@k2b/ssr": "0.14.0",
101
- "@k2b/ui": "0.4.0",
101
+ "@k2b/ui": "0.4.2",
102
102
  "@k2b/stdlib": "0.25.0",
103
103
  "@k2b/sync": "6.4.0",
104
104
  "@nats-io/transport-node": "3.4.0",
package/scripts/build.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * _ssr/<island>.js hydration bundles (auto-emitted by the SSR plugin)
18
18
  * public/<id>/app.css Tailwind, if the app has src/styles/app.css
19
19
  * public/<id>/... anything from <appDir>/public/
20
+ * assets/... anything from <appDir>/src/assets/ (appAssetPath)
20
21
  *
21
22
  * If the app needs additional artefacts (core's global.css, logo, katex),
22
23
  * it ships a `scripts/build-extras.ts` that this script runs at the end.
@@ -129,6 +130,7 @@ try {
129
130
  __CLOUD_RELEASE__: JSON.stringify(release),
130
131
  __CLOUD_SYNC_VERSION__: JSON.stringify(syncVersion),
131
132
  __CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./pdf-render/worker.js"),
133
+ __CLOUD_APP_ASSETS__: JSON.stringify("./assets/"),
132
134
  },
133
135
  plugins: [(app?.plugin ?? plugin)()],
134
136
  });
@@ -167,6 +169,12 @@ if (existsSync(appPublic)) {
167
169
  await cp(appPublic, resolve(distPublic, appId), { recursive: true });
168
170
  }
169
171
 
172
+ // Server-side assets the bundle reads through appAssetPath().
173
+ const appAssets = resolve(appDir, "src/assets");
174
+ if (existsSync(appAssets)) {
175
+ await cp(appAssets, resolve(dist, "assets"), { recursive: true });
176
+ }
177
+
170
178
  if (appId !== "core" && app) {
171
179
  await writeAppFavicon({
172
180
  publicDir: distPublic,
@@ -9,7 +9,13 @@ export const LoginSchema = z.object({
9
9
  });
10
10
 
11
11
  export const EmailLoginSchema = z.object({
12
- email: z.email(),
12
+ email: z
13
+ .string()
14
+ .trim()
15
+ .min(1)
16
+ .max(254)
17
+ .refine((value) => !value.includes("@") || z.email().safeParse(value).success, "Invalid email address")
18
+ .describe("Email address or username of the account"),
13
19
  category: z.enum(["guest", "login"]).optional(),
14
20
  acceptedAgb: z.literal(true).optional(),
15
21
  redirectTo: z.string().max(2048).optional(),
package/src/api/auth.ts CHANGED
@@ -188,7 +188,8 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
188
188
  describeRoute({
189
189
  tags: ["Auth"],
190
190
  summary: "Request magic link login",
191
- description: "Request a magic link token via email for local account sign-in.",
191
+ description:
192
+ "Request a magic link token for local account sign-in. The `email` field accepts the email address or the username of the account; the link is always sent to the account's email address.",
192
193
  responses: {
193
194
  200: jsonResponse(MessageResponseSchema, "Request accepted"),
194
195
  400: jsonResponse(ErrorResponseSchema, "Email sign-in not available"),
@@ -203,7 +204,7 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
203
204
  return c.json({ message: requestResult.message }, requestResult.status);
204
205
  }
205
206
 
206
- log.info("Magic link requested", { email });
207
+ log.info("Magic link requested", { identifier: email });
207
208
  return c.json({
208
209
  message: "If this email can sign in with a login code, a code has been sent.",
209
210
  });
@@ -0,0 +1,31 @@
1
+ import { resolve, sep } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { env } from "../config/env";
4
+
5
+ /** Set by `scripts/build.ts`; the bundle lives next to `dist/assets/`. */
6
+ declare const __CLOUD_APP_ASSETS__: string | undefined;
7
+
8
+ // `resolve` normalizes both forms: a file URL keeps its trailing slash, which
9
+ // would otherwise break the containment check below (`/app/assets//...`).
10
+ const assetsRoot = (): string =>
11
+ resolve(
12
+ typeof __CLOUD_APP_ASSETS__ === "string"
13
+ ? fileURLToPath(new URL(__CLOUD_APP_ASSETS__, import.meta.url))
14
+ : resolve(env.APP_DIR ?? process.cwd(), "src/assets"),
15
+ );
16
+
17
+ /**
18
+ * Absolute path of a server-side application asset.
19
+ *
20
+ * Applications keep files their server reads at runtime (document templates,
21
+ * seed data, binaries) under `src/assets/`. The production build copies that
22
+ * directory to `dist/assets/`, so the same call works from the source tree
23
+ * (`APP_DIR`, or the working directory, plus `src/assets`) and from the
24
+ * bundled image. The path must stay inside the assets directory.
25
+ */
26
+ export function appAssetPath(...segments: string[]): string {
27
+ const root = assetsRoot();
28
+ const path = resolve(root, ...segments);
29
+ if (path !== root && !path.startsWith(root + sep)) throw new Error(`Asset path escapes src/assets: ${segments.join("/")}`);
30
+ return path;
31
+ }
@@ -3,6 +3,7 @@ export type { ApiErrorBody, ApiErrorResponse, ApiErrorStatus } from "./api";
3
3
  export { api, respond, respondMessage } from "./api";
4
4
  export type { CreateApiClientConfig } from "./api-client";
5
5
  export { api as apiClient } from "./api-client";
6
+ export { appAssetPath } from "./app-assets";
6
7
  export type { AppContext } from "./app-context";
7
8
  export { defineHelp, defineHelpCollection, type HelpCollection, type HelpDefinition, type HelpDefinitionDocument } from "./help";
8
9
  export { DEFAULT_LOCALE, getLocale, LOCALE_COOKIE, LOCALE_HEADER, locale, preferredLocale, resolveLocale } from "./locale";
@@ -457,7 +457,6 @@ export const accountLifecycle = {
457
457
  });
458
458
  await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
459
459
  });
460
- await session.revokeAllForUser(userId);
461
460
  summary.changed += 1;
462
461
  } catch (error) {
463
462
  summary.failed += 1;
@@ -507,7 +506,6 @@ export const accountLifecycle = {
507
506
  });
508
507
  await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
509
508
  });
510
- await session.revokeAllForUser(userId);
511
509
  summary.changed += 1;
512
510
  } catch (error) {
513
511
  summary.failed += 1;
@@ -50,11 +50,23 @@ const sendIpaEmailLoginHint = async (
50
50
  if (result.status === "error") log.error("FreeIPA login hint delivery failed", { notificationId: result.id });
51
51
  };
52
52
 
53
+ // A username resolves to the stored email of exactly one account; anything
54
+ // else (unknown, ambiguous casing, no email) behaves like an unknown email.
55
+ const resolveEmailForUsername = async (username: string): Promise<string | null> => {
56
+ const rows = await sql<{ mail: string | null }[]>`
57
+ SELECT mail FROM auth.users WHERE lower(uid) = lower(${username}) LIMIT 2
58
+ `;
59
+ const mail = rows.length === 1 ? rows[0]?.mail : null;
60
+ return mail ? normalizeEmail(mail) : null;
61
+ };
62
+
53
63
  export const request = async (
54
64
  params: { email: string; redirectTo?: string; locale?: string; category?: "guest" | "login" },
55
65
  notificationSender: AuthNotificationSender,
56
66
  ): Promise<{ ok: true } | { ok: false; status: 400; message: string }> => {
57
- const email = normalizeEmail(params.email);
67
+ const identifier = params.email.trim();
68
+ const email = identifier.includes("@") ? normalizeEmail(identifier) : await resolveEmailForUsername(identifier);
69
+ if (!email) return { ok: true };
58
70
  const hasIpaUser = await hasIpaAccountForEmail(email);
59
71
  const userRows = hasIpaUser
60
72
  ? []
@@ -847,7 +847,7 @@ export const deleteUser = async (params: {
847
847
  });
848
848
  throw dbError;
849
849
  }
850
- await session.revokeAllForUser(id);
850
+ // Deleting the row invalidates every session; no epoch bump is possible or needed.
851
851
 
852
852
  return { ok: true, data: undefined };
853
853
  };
@@ -8,7 +8,6 @@ import { writeLocalAccount } from "../../accounts/posix";
8
8
  import type { AuditActor } from "../../audit";
9
9
  import { generateUniqueAbbreviation } from "../../ipa/users";
10
10
  import { isUniqueViolation } from "../../postgres";
11
- import { session } from "../../session";
12
11
  import * as settings from "../../settings";
13
12
 
14
13
  type DbRow = Record<string, unknown>;
@@ -265,8 +264,8 @@ export const remove = async (params: { id: string; actor: { userId: string; uid:
265
264
  });
266
265
  await tx`DELETE FROM auth.users WHERE id = ${params.id}::uuid`;
267
266
  });
268
-
269
- await session.revokeAllForUser(params.id);
267
+ // Deleting the row invalidates every session: session lookups find no user.
268
+ // A separate epoch bump would fail because the row is gone.
270
269
 
271
270
  return { ok: true, data: undefined };
272
271
  };
@@ -26,6 +26,7 @@ import type { LayoutBreadcrumb } from "./layout-runtime";
26
26
  import { platformMessages } from "./platform-messages";
27
27
  import RegisteredHelpDocuments from "./RegisteredHelpDocuments.island";
28
28
  import { sortRailApps } from "./rail-navigation";
29
+ import { requestPath } from "./request-path";
29
30
  import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
30
31
  import TimezoneCookie from "./TimezoneCookie.island";
31
32
 
@@ -257,7 +258,7 @@ export default function Layout(props: LayoutProps) {
257
258
  openAppsLabel={t.openApps}
258
259
  apps={allApps}
259
260
  railSettings={c.get("railPreferences") ?? defaultRailPreferences()}
260
- currentUrl={c.req.raw.url}
261
+ currentPath={requestPath(c)}
261
262
  profileAvatarSrc={profileAvatarSrc}
262
263
  profileName={profileName}
263
264
  searchHelpApps={searchHelpApps}
@@ -19,7 +19,8 @@ type LayoutRailProps = {
19
19
  openAppsLabel: string;
20
20
  apps: RailApp[];
21
21
  railSettings: RailSnapshot;
22
- currentUrl: string;
22
+ /** `pathname + search` of the request; the island resolves it against the browser origin. */
23
+ currentPath: string;
23
24
  profileAvatarSrc?: string;
24
25
  profileName: string;
25
26
  searchHelpApps: GlobalSearchHelpApp[];
@@ -46,7 +47,7 @@ export default function LayoutRail(props: LayoutRailProps) {
46
47
  </a>
47
48
  </div>
48
49
  <nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
49
- <RailApps apps={props.apps} settings={props.railSettings} currentUrl={props.currentUrl} />
50
+ <RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
50
51
  <AppLaunchpad
51
52
  profile={{ name: props.profileName, theme: props.theme }}
52
53
  apps={props.launchpadApps}
@@ -32,11 +32,13 @@ function MobileNavigation(props: {
32
32
  );
33
33
  return (
34
34
  <BottomSheet onDismiss={props.dismiss}>
35
- <BottomSheet.Header
36
- title={workspace()?.label || t().apps}
37
- close={() => void props.dismiss()}
38
- actions={
39
- <Show when={workspace()} fallback={<span class="font-semibold">{t().allApps}</span>}>
35
+ {/* Without an app menu the launchpad gets the whole sheet; the hidden
36
+ heading still names the dialog, and the handle and backdrop dismiss it. */}
37
+ <Show when={workspace()} fallback={<h2 class="sr-only">{t().allApps}</h2>}>
38
+ <BottomSheet.Header
39
+ title={workspace()?.label || t().apps}
40
+ close={() => void props.dismiss()}
41
+ actions={
40
42
  <SegmentedControl
41
43
  class="cloud-mobile-menu__switch"
42
44
  size="md"
@@ -48,9 +50,9 @@ function MobileNavigation(props: {
48
50
  { value: "apps", label: t().allApps },
49
51
  ]}
50
52
  />
51
- </Show>
52
- }
53
- />
53
+ }
54
+ />
55
+ </Show>
54
56
  <BottomSheet.Body>
55
57
  <Show
56
58
  when={workspace() && tab() === "app"}
@@ -5,9 +5,9 @@ import { appAccentStyle } from "./app-appearance";
5
5
  import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
6
6
  import { projectRailNavigation, type RailApp, type RailLink, railLinkActive } from "./rail-navigation";
7
7
 
8
- function RailIcon(props: { link: RailLink; currentUrl: string }) {
8
+ function RailIcon(props: { link: RailLink; currentPath: string }) {
9
9
  let target: HTMLAnchorElement | undefined;
10
- const active = () => railLinkActive(props.link, props.currentUrl);
10
+ const active = () => railLinkActive(props.link, props.currentPath);
11
11
  return (
12
12
  <>
13
13
  <a
@@ -26,14 +26,14 @@ function RailIcon(props: { link: RailLink; currentUrl: string }) {
26
26
  );
27
27
  }
28
28
 
29
- export default function RailApps(props: { apps: RailApp[]; settings: RailSnapshot; currentUrl: string }) {
29
+ export default function RailApps(props: { apps: RailApp[]; settings: RailSnapshot; currentPath: string }) {
30
30
  const locale = useLocale();
31
31
  const [settings, setSettings] = createSignal(props.settings);
32
- const [currentUrl, setCurrentUrl] = createSignal(props.currentUrl);
32
+ const [currentPath, setCurrentPath] = createSignal(props.currentPath);
33
33
  const navigation = createMemo(() => projectRailNavigation(props.apps, settings(), locale()));
34
34
  onMount(() => {
35
35
  const sync = () => setSettings(readRailContext()?.settings ?? props.settings);
36
- const syncLocation = () => setCurrentUrl(window.location.href);
36
+ const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
37
37
  sync();
38
38
  syncLocation();
39
39
  window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
@@ -51,11 +51,11 @@ export default function RailApps(props: { apps: RailApp[]; settings: RailSnapsho
51
51
  style={{ flex: "0 1 auto", "scrollbar-width": "none", "scrollbar-gutter": "auto", "overflow-x": "hidden" }}
52
52
  data-cloud-rail-apps
53
53
  >
54
- <For each={navigation().shortcuts}>{(link) => <RailIcon link={link} currentUrl={currentUrl()} />}</For>
54
+ <For each={navigation().shortcuts}>{(link) => <RailIcon link={link} currentPath={currentPath()} />}</For>
55
55
  <Show when={navigation().shortcuts.length > 0 && navigation().apps.length > 0}>
56
56
  <div role="separator" class="my-1 h-px w-5 shrink-0" style={{ background: "var(--ui-divider)" }} />
57
57
  </Show>
58
- <For each={navigation().apps}>{(link) => <RailIcon link={link} currentUrl={currentUrl()} />}</For>
58
+ <For each={navigation().apps}>{(link) => <RailIcon link={link} currentPath={currentPath()} />}</For>
59
59
  </ScrollArea>
60
60
  );
61
61
  }
package/src/ssr/index.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  type MinimalLayoutPreferencePosition,
10
10
  type MinimalLayoutProps,
11
11
  } from "./MinimalLayout";
12
+ export { requestPath } from "./request-path";
12
13
  export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
13
14
  export type { UrlFilterField } from "./url-filter";
14
15
  export { createUrlFilter, flag, list, oneOf, page, text } from "./url-filter";
@@ -48,8 +48,9 @@ export const projectRailNavigation = (apps: readonly RailApp[], settings: RailSn
48
48
  };
49
49
  };
50
50
 
51
- export const railLinkActive = (link: RailLink, currentUrl: string): boolean => {
52
- const current = new URL(currentUrl, "https://cloud.invalid");
51
+ /** `currentPath` is the route as `pathname + search`; an absolute URL is accepted but never required. */
52
+ export const railLinkActive = (link: RailLink, currentPath: string): boolean => {
53
+ const current = new URL(currentPath, "https://cloud.invalid");
53
54
  const target = new URL(link.match, current);
54
55
  if (target.origin !== current.origin) return false;
55
56
  if (link.exact) return target.pathname === current.pathname && target.search === current.search && target.hash === current.hash;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The route of the current request as `pathname + search`.
3
+ *
4
+ * Behind the gateway `c.req.raw.url` carries the internal upstream origin
5
+ * (`http://app-mail:3000`), never the public one. Islands and links must
6
+ * therefore receive the route as a path and resolve it against
7
+ * `window.location.origin` in the browser.
8
+ */
9
+ export const requestPath = (c: { req: { raw: { url: string } } }): string => {
10
+ const url = new URL(c.req.raw.url);
11
+ return `${url.pathname}${url.search}`;
12
+ };