1
0
forked from baron/baron-sso

fix: align local Ory cookie domain rendering

This commit is contained in:
2026-05-15 18:20:49 +09:00
parent 14fb155cd9
commit d4090b7d8d
11 changed files with 793 additions and 4 deletions

View File

@@ -56,6 +56,7 @@ up: up-all
up-all: ensure-networks render-ory-config
@echo "Starting ALL stacks (infra + ory + app)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
# --- 개별 스택 실행 ---
up-infra: ensure-networks
@@ -65,6 +66,7 @@ up-infra: ensure-networks
up-ory: ensure-networks render-ory-config
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
up-app: ensure-networks render-ory-config
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
@@ -114,7 +116,8 @@ ensure-ory: ensure-networks render-ory-config
echo "Starting missing Ory stack containers in daemon mode..."; \
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
else \
echo "Ory stack is already running."; \
echo "Ory stack is already running. Restarting Kratos to apply rendered dev config..."; \
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \
fi
up-dev: ensure-infra ensure-ory

View File

@@ -15,7 +15,7 @@ serve:
session:
cookie:
domain: hmac.kr
domain: ${KRATOS_SESSION_COOKIE_DOMAIN}
same_site: Lax
path: /

View File

@@ -19,7 +19,7 @@ serve:
session:
cookie:
domain: hmac.kr
domain: ${KRATOS_SESSION_COOKIE_DOMAIN}
same_site: Lax
path: /

View File

@@ -9,6 +9,9 @@
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"build:org-context-chart": "npm run build:org-context-chart:full && npm run build:org-context-chart:min",
"build:org-context-chart:full": "vite build --config vite.org-context-chart.config.ts",
"build:org-context-chart:min": "ORG_CONTEXT_CHART_MINIFY=true vite build --config vite.org-context-chart.config.ts",
"lint": "biome check .",
"preview": "vite preview",
"test": "playwright test",

View File

@@ -0,0 +1,491 @@
export type OrgContextMember = {
id?: string;
email: string;
name: string;
phone?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
isOwner?: boolean;
isLeader?: boolean;
isPrimary?: boolean;
};
export type OrgContextTenant = {
id: string;
type: string;
name: string;
slug: string;
parentId?: string | null;
status: string;
description: string;
domains: string[];
memberCount: number;
visibility: string;
orgUnitType?: string;
createdAt: string;
updatedAt: string;
members: OrgContextMember[];
};
export type OrgContextTreeNode = OrgContextTenant & {
children: OrgContextTreeNode[];
};
export type OrgContextResponse = {
schemaVersion: "baron.org-context.v1";
issuedAt: string;
scope: {
tenantId: string;
tenantSlug: string;
};
tree: OrgContextTreeNode;
tenants: OrgContextTenant[];
};
export type OrgChartNode = OrgContextTenant & {
children: OrgChartNode[];
depth: number;
path: string[];
};
export type OrgChartMember = OrgContextMember & {
tenantIds: string[];
};
export type OrgChartModel = {
root: OrgChartNode;
nodes: OrgChartNode[];
tenantsById: Map<string, OrgChartNode>;
tenantsBySlug: Map<string, OrgChartNode>;
membersByEmail: Map<string, OrgChartMember>;
response: OrgContextResponse;
};
export type OrgContextClientOptions = {
baseUrl: string;
credentials?: {
keyId: string;
keySecret: string;
};
fetch?: typeof fetch;
headers?: Record<string, string>;
};
export type FetchOrgContextOptions = {
tenantSlug?: string;
includeUsers?: boolean;
includeUserIds?: boolean;
};
export type OrgPickerSelection = {
id: string;
name: string;
type: "tenant" | "user";
};
export type OrgPickerOptions = {
mode?: "single" | "multiple";
selectable?: "tenant" | "user" | "both";
includeDescendants?: boolean;
onChange?: (selection: OrgPickerSelection[]) => void;
};
export type OrgPickerController = {
destroy: () => void;
getSelection: () => OrgPickerSelection[];
};
const API_PATH = "/api/v1/integrations/org-context";
export function createOrgContextClient(options: OrgContextClientOptions) {
const fetcher = options.fetch ?? globalThis.fetch;
if (!fetcher) {
throw new Error("A fetch implementation is required.");
}
return {
async fetchOrgContext(
query: FetchOrgContextOptions = {},
): Promise<OrgContextResponse> {
const url = new URL(API_PATH, normalizeBaseUrl(options.baseUrl));
appendQuery(url, "tenantSlug", query.tenantSlug);
appendQuery(url, "includeUsers", query.includeUsers);
appendQuery(url, "includeUserIds", query.includeUserIds);
const headers: Record<string, string> = {
Accept: "application/json",
...options.headers,
};
if (options.credentials) {
headers["X-Baron-Key-ID"] = options.credentials.keyId;
headers["X-Baron-Key-Secret"] = options.credentials.keySecret;
}
const response = await fetcher(url.toString(), { headers });
if (!response.ok) {
throw new Error(`Org context request failed with ${response.status}`);
}
const payload = (await response.json()) as OrgContextResponse;
if (payload.schemaVersion !== "baron.org-context.v1") {
throw new Error(
`Unsupported org context schema: ${payload.schemaVersion}`,
);
}
return payload;
},
};
}
export function buildOrgChartModel(
response: OrgContextResponse,
): OrgChartModel {
if (response.schemaVersion !== "baron.org-context.v1") {
throw new Error(
`Unsupported org context schema: ${response.schemaVersion}`,
);
}
const nodes: OrgChartNode[] = [];
const tenantsById = new Map<string, OrgChartNode>();
const tenantsBySlug = new Map<string, OrgChartNode>();
const membersByEmail = new Map<string, OrgChartMember>();
const visit = (
node: OrgContextTreeNode,
depth: number,
ancestorPath: string[],
): OrgChartNode => {
const path = [...ancestorPath, node.id];
const chartNode: OrgChartNode = {
...node,
children: [],
depth,
path,
};
nodes.push(chartNode);
tenantsById.set(chartNode.id, chartNode);
tenantsBySlug.set(chartNode.slug, chartNode);
for (const member of chartNode.members) {
const key = member.email.toLowerCase();
const existing = membersByEmail.get(key);
if (existing) {
existing.tenantIds.push(chartNode.id);
} else {
membersByEmail.set(key, {
...member,
tenantIds: [chartNode.id],
});
}
}
chartNode.children = node.children.map((child) =>
visit(child, depth + 1, path),
);
return chartNode;
};
return {
root: visit(response.tree, 0, []),
nodes,
tenantsById,
tenantsBySlug,
membersByEmail,
response,
};
}
export function renderOrgChart(
container: HTMLElement,
model: OrgChartModel,
): { destroy: () => void } {
container.replaceChildren();
container.classList.add("baron-org-chart");
const root = document.createElement("div");
root.className = "baron-org-chart__tree";
root.append(renderChartNode(model.root));
container.append(root);
return {
destroy() {
container.replaceChildren();
container.classList.remove("baron-org-chart");
},
};
}
export function renderOrgPicker(
container: HTMLElement,
model: OrgChartModel,
options: OrgPickerOptions = {},
): OrgPickerController {
const mode = options.mode ?? "single";
const selectable = options.selectable ?? "tenant";
const includeDescendants = options.includeDescendants ?? false;
const selected = new Map<string, OrgPickerSelection>();
const emitChange = () => {
const selection = Array.from(selected.values());
options.onChange?.(selection);
container.dispatchEvent(
new CustomEvent("baron-org-picker-change", {
bubbles: true,
detail: { selection },
}),
);
};
const toggleSelection = (
selection: OrgPickerSelection,
checked: boolean,
descendants: OrgPickerSelection[],
) => {
if (mode === "single") {
selected.clear();
if (checked) {
selected.set(selectionKey(selection), selection);
}
emitChange();
rerender();
return;
}
const targets =
includeDescendants && selection.type === "tenant"
? [selection, ...descendants]
: [selection];
for (const target of targets) {
if (checked) {
selected.set(selectionKey(target), target);
} else {
selected.delete(selectionKey(target));
}
}
emitChange();
rerender();
};
const renderPickerNode = (node: OrgChartNode): HTMLElement => {
const item = document.createElement("li");
item.className = "baron-org-picker__item";
const row = document.createElement("label");
row.className = "baron-org-picker__row";
row.style.paddingLeft = `${node.depth * 16}px`;
const tenantSelection: OrgPickerSelection = {
id: node.id,
name: node.name,
type: "tenant",
};
if (selectable === "tenant" || selectable === "both") {
row.append(
createPickerInput({
mode,
selection: tenantSelection,
selected,
onToggle: (checked) =>
toggleSelection(
tenantSelection,
checked,
collectDescendantSelections(node, selectable),
),
}),
);
}
row.append(createLabelText(node.name, node.type));
item.append(row);
if (selectable === "user" || selectable === "both") {
for (const member of node.members) {
item.append(
renderMemberPickerRow(member, node, mode, selected, (value) =>
toggleSelection(value, !selected.has(selectionKey(value)), []),
),
);
}
}
if (node.children.length > 0) {
const children = document.createElement("ul");
children.className = "baron-org-picker__children";
for (const child of node.children) {
children.append(renderPickerNode(child));
}
item.append(children);
}
return item;
};
const rerender = () => {
container.replaceChildren();
container.classList.add("baron-org-picker");
const list = document.createElement("ul");
list.className = "baron-org-picker__list";
list.append(renderPickerNode(model.root));
container.append(list);
};
rerender();
return {
destroy() {
container.replaceChildren();
container.classList.remove("baron-org-picker");
selected.clear();
},
getSelection() {
return Array.from(selected.values());
},
};
}
function appendQuery(
url: URL,
key: string,
value: string | boolean | undefined,
) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
}
function renderChartNode(node: OrgChartNode): HTMLElement {
const item = document.createElement("section");
item.className = "baron-org-chart__node";
item.dataset.baronOrgNode = node.id;
const title = document.createElement("h3");
title.className = "baron-org-chart__title";
title.textContent = node.name;
item.append(title);
const meta = document.createElement("p");
meta.className = "baron-org-chart__meta";
meta.textContent = [node.type, node.orgUnitType, node.visibility]
.filter(Boolean)
.join(" · ");
item.append(meta);
if (node.members.length > 0) {
const memberList = document.createElement("ul");
memberList.className = "baron-org-chart__members";
for (const member of node.members) {
const memberItem = document.createElement("li");
memberItem.textContent = formatMember(member);
memberList.append(memberItem);
}
item.append(memberList);
}
if (node.children.length > 0) {
const children = document.createElement("div");
children.className = "baron-org-chart__children";
for (const child of node.children) {
children.append(renderChartNode(child));
}
item.append(children);
}
return item;
}
function renderMemberPickerRow(
member: OrgContextMember,
node: OrgChartNode,
mode: "single" | "multiple",
selected: Map<string, OrgPickerSelection>,
onSelect: (selection: OrgPickerSelection) => void,
) {
const selection: OrgPickerSelection = {
id: member.id || `${node.id}:${member.email}`,
name: member.name,
type: "user",
};
const row = document.createElement("label");
row.className = "baron-org-picker__row baron-org-picker__row--member";
row.style.paddingLeft = `${node.depth * 16 + 24}px`;
row.append(
createPickerInput({
mode,
selection,
selected,
onToggle: () => onSelect(selection),
}),
);
row.append(createLabelText(member.name, member.email));
return row;
}
function createPickerInput({
mode,
selection,
selected,
onToggle,
}: {
mode: "single" | "multiple";
selection: OrgPickerSelection;
selected: Map<string, OrgPickerSelection>;
onToggle: (checked: boolean) => void;
}) {
const input = document.createElement("input");
input.type = mode === "single" ? "radio" : "checkbox";
input.name = "baron-org-picker";
input.value = selectionKey(selection);
input.checked = selected.has(selectionKey(selection));
const handleToggle = () => {
onToggle(mode === "single" ? true : !selected.has(selectionKey(selection)));
};
input.addEventListener("click", handleToggle);
return input;
}
function createLabelText(primary: string, secondary?: string) {
const text = document.createElement("span");
text.className = "baron-org-picker__label";
text.textContent = secondary ? `${primary} (${secondary})` : primary;
return text;
}
function collectDescendantSelections(
node: OrgChartNode,
selectable: "tenant" | "user" | "both",
): OrgPickerSelection[] {
const selections: OrgPickerSelection[] = [];
const visit = (child: OrgChartNode) => {
if (selectable === "tenant" || selectable === "both") {
selections.push({ id: child.id, name: child.name, type: "tenant" });
}
if (selectable === "user" || selectable === "both") {
for (const member of child.members) {
selections.push({
id: member.id || `${child.id}:${member.email}`,
name: member.name,
type: "user",
});
}
}
for (const grandchild of child.children) {
visit(grandchild);
}
};
for (const child of node.children) {
visit(child);
}
return selections;
}
function selectionKey(selection: OrgPickerSelection) {
return `${selection.type}:${selection.id}`;
}
function formatMember(member: OrgContextMember) {
return [member.name, member.position, member.jobTitle]
.filter(Boolean)
.join(" · ");
}

View File

@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from "vitest";
import {
type OrgContextResponse,
buildOrgChartModel,
createOrgContextClient,
renderOrgChart,
renderOrgPicker,
} from "./index";
const sampleOrgContext: OrgContextResponse = {
schemaVersion: "baron.org-context.v1",
issuedAt: "2026-05-15T00:00:00Z",
scope: {
tenantId: "root",
tenantSlug: "hanmac-family",
},
tree: {
id: "root",
type: "COMPANY_GROUP",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
description: "",
domains: [],
memberCount: 0,
visibility: "public",
createdAt: "2026-05-15T00:00:00Z",
updatedAt: "2026-05-15T00:00:00Z",
members: [],
children: [
{
id: "company-baron",
type: "COMPANY",
name: "Baron",
slug: "baron",
parentId: "root",
status: "active",
description: "",
domains: ["baron.example"],
memberCount: 1,
visibility: "public",
createdAt: "2026-05-15T00:00:00Z",
updatedAt: "2026-05-15T00:00:00Z",
members: [
{
email: "leader@example.com",
name: "Leader",
grade: "책임",
position: "팀장",
isLeader: true,
isOwner: true,
isPrimary: true,
},
],
children: [
{
id: "team-platform",
type: "USER_GROUP",
name: "Platform",
slug: "platform",
parentId: "company-baron",
status: "active",
description: "",
domains: [],
memberCount: 1,
visibility: "internal",
orgUnitType: "팀",
createdAt: "2026-05-15T00:00:00Z",
updatedAt: "2026-05-15T00:00:00Z",
members: [
{
email: "engineer@example.com",
name: "Engineer",
jobTitle: "Frontend Engineer",
isLeader: false,
isOwner: false,
isPrimary: false,
},
],
children: [],
},
],
},
],
},
tenants: [],
};
describe("org-context chart SDK", () => {
it("builds chart and lookup models from org-context v1", () => {
const model = buildOrgChartModel(sampleOrgContext);
expect(model.root.name).toBe("한맥가족");
expect(model.nodes).toHaveLength(3);
expect(model.tenantsBySlug.get("platform")?.orgUnitType).toBe("팀");
expect(model.membersByEmail.get("engineer@example.com")?.tenantIds).toEqual(
["team-platform"],
);
});
it("fetches org-context through authenticated API headers", async () => {
const fetcher = vi.fn(async () => {
return new Response(JSON.stringify(sampleOrgContext), {
headers: { "content-type": "application/json" },
status: 200,
});
});
const client = createOrgContextClient({
baseUrl: "https://sso.example.com",
credentials: {
keyId: "client-id",
keySecret: "client-secret",
},
fetch: fetcher,
});
await client.fetchOrgContext({
tenantSlug: "baron",
includeUsers: true,
includeUserIds: false,
});
const [url, init] = fetcher.mock.calls[0];
expect(String(url)).toBe(
"https://sso.example.com/api/v1/integrations/org-context?tenantSlug=baron&includeUsers=true&includeUserIds=false",
);
expect(init.headers).toMatchObject({
"X-Baron-Key-ID": "client-id",
"X-Baron-Key-Secret": "client-secret",
});
});
it("renders chart and picker DOM with selection events", () => {
const model = buildOrgChartModel(sampleOrgContext);
const chartContainer = document.createElement("div");
const pickerContainer = document.createElement("div");
const onChange = vi.fn();
renderOrgChart(chartContainer, model);
const picker = renderOrgPicker(pickerContainer, model, {
mode: "multiple",
selectable: "both",
onChange,
});
expect(
chartContainer.querySelectorAll("[data-baron-org-node]"),
).toHaveLength(3);
const platformCheckbox = pickerContainer.querySelector<HTMLInputElement>(
'input[value="tenant:team-platform"]',
);
expect(platformCheckbox).not.toBeNull();
platformCheckbox?.click();
expect(picker.getSelection()).toEqual([
{
id: "team-platform",
name: "Platform",
type: "tenant",
},
]);
expect(onChange).toHaveBeenCalledWith([
{
id: "team-platform",
name: "Platform",
type: "tenant",
},
]);
});
});

View File

@@ -22,5 +22,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "vite.org-context-chart.config.ts"]
}

View File

@@ -0,0 +1,25 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
const isMinifiedBuild = process.env.ORG_CONTEXT_CHART_MINIFY === "true";
const fileSuffix = isMinifiedBuild ? ".min" : "";
export default defineConfig({
build: {
emptyOutDir: !isMinifiedBuild,
lib: {
entry: fileURLToPath(
new URL("./src/sdk/org-context-chart/index.ts", import.meta.url),
),
fileName: (format) =>
format === "es"
? `baron-org-context-chart${fileSuffix}.js`
: `baron-org-context-chart${fileSuffix}.umd.cjs`,
formats: ["es", "umd"],
name: "BaronOrgContextChart",
},
minify: isMinifiedBuild,
outDir: "dist/org-context-chart",
sourcemap: true,
},
});

View File

@@ -62,6 +62,58 @@ append_unique_url() {
KRATOS_ALLOWED_RETURN_URLS+=("$candidate")
}
url_host() {
local url="${1:-}"
[[ -n "$url" ]] || return 0
local without_scheme="$url"
if [[ "$without_scheme" == *"://"* ]]; then
without_scheme="${without_scheme#*://}"
fi
without_scheme="${without_scheme%%/*}"
without_scheme="${without_scheme%%\?*}"
without_scheme="${without_scheme%%#*}"
if [[ "$without_scheme" == \[*\]* ]]; then
without_scheme="${without_scheme#[}"
without_scheme="${without_scheme%%]*}"
elif [[ "$without_scheme" == *:* ]]; then
without_scheme="${without_scheme%%:*}"
fi
printf '%s' "$without_scheme"
}
resolve_kratos_session_cookie_domain() {
if [[ -n "${KRATOS_SESSION_COOKIE_DOMAIN:-}" ]]; then
export KRATOS_SESSION_COOKIE_DOMAIN
return 0
fi
local public_host
public_host="$(url_host "${KRATOS_BROWSER_URL:-}")"
if [[ -z "$public_host" ]]; then
public_host="$(url_host "${KRATOS_UI_URL:-}")"
fi
case "$public_host" in
localhost|127.0.0.1|0.0.0.0|*.localhost)
KRATOS_SESSION_COOKIE_DOMAIN="localhost"
;;
*.hmac.kr|hmac.kr)
KRATOS_SESSION_COOKIE_DOMAIN="hmac.kr"
;;
"")
KRATOS_SESSION_COOKIE_DOMAIN="localhost"
;;
*)
KRATOS_SESSION_COOKIE_DOMAIN="$public_host"
;;
esac
export KRATOS_SESSION_COOKIE_DOMAIN
}
build_kratos_allowed_return_urls_yaml() {
KRATOS_ALLOWED_RETURN_URLS=()
if [[ -n "${KRATOS_ALLOWED_RETURN_URLS_JSON:-}" ]]; then
@@ -137,6 +189,7 @@ OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oath
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET
export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
resolve_kratos_session_cookie_domain
build_kratos_allowed_return_urls_yaml
mkdir -p "$OUTPUT_DIR/kratos" "$OUTPUT_DIR/hydra" "$OUTPUT_DIR/keto" "$OUTPUT_DIR/oathkeeper"

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
fail() {
echo "[org-context-chart-package] $*" >&2
exit 1
}
assert_contains() {
local file="$1"
local needle="$2"
grep -Fq "$needle" "$file" || fail "$file must contain: $needle"
}
assert_contains orgfront/package.json "build:org-context-chart:min"
assert_contains orgfront/vite.org-context-chart.config.ts "ORG_CONTEXT_CHART_MINIFY"
assert_contains orgfront/vite.org-context-chart.config.ts ".min"
echo "OK: OrgContext chart package emits explicit minified bundles"

View File

@@ -268,6 +268,18 @@ if ! grep -q '^render-ory-config:' "$repo_root/Makefile"; then
echo "ERROR: Makefile must render Ory config before starting Ory services." >&2
exit 1
fi
if ! awk '/^ensure-ory:/ { in_target=1 } in_target && /^[^[:space:]].*:/ && $0 !~ /^ensure-ory:/ { exit } in_target { print }' "$repo_root/Makefile" | grep -q 'restart kratos'; then
echo "ERROR: make up-dev must restart Kratos when Ory is already running so rendered dev config is applied." >&2
exit 1
fi
if ! awk '/^up-all:/ { in_target=1 } in_target && /^[^[:space:]].*:/ && $0 !~ /^up-all:/ { exit } in_target { print }' "$repo_root/Makefile" | grep -q 'restart kratos'; then
echo "ERROR: make up must restart Kratos after rendering Ory config." >&2
exit 1
fi
if ! awk '/^up-ory:/ { in_target=1 } in_target && /^[^[:space:]].*:/ && $0 !~ /^up-ory:/ { exit } in_target { print }' "$repo_root/Makefile" | grep -q 'restart kratos'; then
echo "ERROR: make up-ory must restart Kratos after rendering Ory config." >&2
exit 1
fi
if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
echo "ERROR: staging code pull must render Ory config before docker compose up." >&2
@@ -285,6 +297,11 @@ if grep -Eq '^[[:space:]]*rm -rf "?\$OUTPUT_DIR"?[[:space:]]*$' "$repo_root/scri
fi
"$repo_root/scripts/render_ory_config.sh" >/dev/null
local_rendered_kratos="$repo_root/config/.generated/ory/kratos/kratos.yml"
if ! awk '/session:/ { in_session=1 } in_session && /domain:/ { print; exit }' "$local_rendered_kratos" | grep -q 'domain: localhost'; then
echo "ERROR: rendered local Kratos config must use localhost as session.cookie.domain for dev runs." >&2
exit 1
fi
stage_render_dir="$(mktemp -d)"
stage_render_env="$(mktemp)"
@@ -310,6 +327,10 @@ if awk '/allowed_return_urls:/ { in_block=1; next } in_block && /^[[:space:]]+me
echo "ERROR: rendered stage Kratos allowed_return_urls must not fall back to localhost." >&2
exit 1
fi
if ! awk '/session:/ { in_session=1 } in_session && /domain:/ { print; exit }' "$stage_rendered_kratos" | grep -q 'domain: hmac.kr'; then
echo "ERROR: rendered stage Kratos config must derive hmac.kr as session.cookie.domain." >&2
exit 1
fi
rm -rf "$stage_render_dir" "$stage_render_env"
for generated_config in \