@cosmicdrift/kumiko-bundled-features 0.146.1 → 0.146.2

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": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.146.1",
3
+ "version": "0.146.2",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -111,11 +111,11 @@
111
111
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
112
112
  },
113
113
  "dependencies": {
114
- "@cosmicdrift/kumiko-dispatcher-live": "0.146.1",
115
- "@cosmicdrift/kumiko-framework": "0.146.1",
116
- "@cosmicdrift/kumiko-headless": "0.146.1",
117
- "@cosmicdrift/kumiko-renderer": "0.146.1",
118
- "@cosmicdrift/kumiko-renderer-web": "0.146.1",
114
+ "@cosmicdrift/kumiko-dispatcher-live": "0.146.2",
115
+ "@cosmicdrift/kumiko-framework": "0.146.2",
116
+ "@cosmicdrift/kumiko-headless": "0.146.2",
117
+ "@cosmicdrift/kumiko-renderer": "0.146.2",
118
+ "@cosmicdrift/kumiko-renderer-web": "0.146.2",
119
119
  "@mollie/api-client": "^4.5.0",
120
120
  "imapflow": "^1.3.3",
121
121
  "mailparser": "^3.9.8",
@@ -1,8 +1,8 @@
1
1
  import {
2
+ buildSessionRoles,
2
3
  createSystemUser,
3
4
  defineWriteHandler,
4
5
  type SessionUser,
5
- stripForbiddenMembershipRoles,
6
6
  type TenantId,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
@@ -169,11 +169,10 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
169
169
  // membership. Dedupe via Set damit eine Rolle die in beiden Quellen
170
170
  // steht nicht doppelt im Session-Roles landet.
171
171
  const globalRoles = parseRoles(found.roles ?? null);
172
- // Strip reserved roles from the membership portion only (globalRoles keeps
173
- // SystemAdmin) — read-time backstop against a rebuild-resurrected role.
174
- const mergedRoles = Array.from(
175
- new Set([...globalRoles, ...stripForbiddenMembershipRoles(chosen.roles)]),
176
- );
172
+ // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
173
+ // only (globalRoles keeps SystemAdmin) — read-time backstop against a
174
+ // rebuild-resurrected role.
175
+ const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
177
176
  const baseSession: SessionUser = {
178
177
  id: found.id,
179
178
  tenantId: chosen.tenantId,
@@ -1,4 +1,4 @@
1
- import { access, defineWriteHandler, pipeline } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { access, defineWriteHandler, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { z } from "zod";
3
3
 
4
4
  // Logout — JWT is stateless, so server-side we only return OK. A future
@@ -8,5 +8,7 @@ export const logoutWrite = defineWriteHandler({
8
8
  name: "logout",
9
9
  schema: z.object({}),
10
10
  access: { roles: access.authenticated },
11
- perform: pipeline(({ r }) => [r.step.return({ isSuccess: true, data: { kind: "logged-out" } })]),
11
+ perform: stepsPipeline(({ r }) => [
12
+ r.step.return({ isSuccess: true, data: { kind: "logged-out" } }),
13
+ ]),
12
14
  });
@@ -1,13 +1,14 @@
1
1
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import { stripForbiddenMembershipRoles } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { buildSessionRoles } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import { tenantMembershipsTable } from "../tenant";
6
6
  import { userTable } from "../user";
7
7
 
8
8
  // Live role resolution for a (userId, tenantId), mirroring login.write.ts:
9
9
  // global roles (users.roles) ∪ tenant-membership roles (forbidden roles
10
- // stripped). Resolved fresh on every PAT request — a snapshot baked at mint
10
+ // stripped via stripForbiddenMembershipRoles, see engine/membership-roles).
11
+ // Resolved fresh on every PAT request — a snapshot baked at mint
11
12
  // time would keep a since-revoked admin role for the token's whole (months-long)
12
13
  // life. Returns null when the user has no membership in that tenant: removed
13
14
  // from the tenant → the PAT stops authenticating there.
@@ -24,6 +25,5 @@ export async function resolvePatRoles(
24
25
  if (!membership) return null;
25
26
  const userRow = await fetchOne<{ roles: string | null }>(db, userTable, { id: userId });
26
27
  const globalRoles = parseRoles(userRow?.roles ?? null);
27
- const membershipRoles = stripForbiddenMembershipRoles(parseRoles(membership.roles));
28
- return [...new Set([...globalRoles, ...membershipRoles])];
28
+ return buildSessionRoles(globalRoles, parseRoles(membership.roles));
29
29
  }
@@ -129,6 +129,18 @@ describe("seo :: GET /sitemap.xml", () => {
129
129
  expect(res.headers.get("etag")).toBeTruthy();
130
130
  });
131
131
 
132
+ test("x-forwarded-proto: https → every merged URL uses https, not the raw http hop", async () => {
133
+ const res = await stack.app.request("http://a.example.com/sitemap.xml", {
134
+ headers: { "x-forwarded-proto": "https" },
135
+ });
136
+ expect(res.status).toBe(200);
137
+ const xml = await res.text();
138
+ expect(xml).toContain("<loc>https://a.example.com/</loc>");
139
+ expect(xml).toContain("<loc>https://a.example.com/legal/impressum</loc>");
140
+ expect(xml).toContain("<loc>https://a.example.com/p/about</loc>");
141
+ expect(xml).not.toContain("http://a.example.com");
142
+ });
143
+
132
144
  test("host without a managed-pages tenant → callback + legal-pages entries only", async () => {
133
145
  const res = await stack.app.request("http://unknown.example.com/sitemap.xml");
134
146
  expect(res.status).toBe(200);
@@ -147,12 +147,21 @@ async function gatherEntries(
147
147
  return entries;
148
148
  }
149
149
 
150
+ // TLS-terminating reverse proxies (ingress-nginx et al.) forward the
151
+ // original request over plain HTTP internally, so `c.req.url`'s scheme
152
+ // reflects the proxy hop, not what the client actually used — trusting it
153
+ // downgraded every legal-pages/managed-pages URL in sitemap.xml/llms.txt to
154
+ // http:// in production. `x-forwarded-proto` carries the real scheme;
155
+ // fall back to the raw URL only when the header is absent (plain local dev).
150
156
  function requestHost(c: { req: { header: (name: string) => string | undefined; url: string } }): {
151
157
  origin: string;
152
158
  host: string;
153
159
  } {
154
160
  const url = new URL(c.req.url);
155
- return { origin: url.origin, host: c.req.header("host") ?? url.host };
161
+ const host = c.req.header("host") ?? url.host;
162
+ const forwardedProto = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim();
163
+ const protocol = forwardedProto || url.protocol.replace(":", "");
164
+ return { origin: `${protocol}://${host}`, host };
156
165
  }
157
166
 
158
167
  // seo — site-discovery routes (sitemap.xml/llms.txt/robots.txt) plus the