Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"description": "Source-of-truth constants shared between client (src, admin) and server packages. / クライアント (src, admin) とサーバ (server/api 等) で共有する定数モジュール。",
"exports": {
".": "./src/index.ts",
"./tagCharacterClass": "./src/tagCharacterClass.ts"
"./tagCharacterClass": "./src/tagCharacterClass.ts",
"./freeEmailDomains": "./src/freeEmailDomains.ts"
},
"scripts": {
"test": "vitest run"
Expand Down
55 changes: 55 additions & 0 deletions packages/shared/src/freeEmailDomains.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* `normalizeDomainInput` の最低限のスペックテスト。トリミング・小文字化・先頭 `@` の
* 除去・空入力/形式不正/フリーメール拒否を固定する。
*
* Lock down the minimum contract for `normalizeDomainInput`: trimming,
* lower-casing, leading `@` stripping, and rejection of empty/malformed/
* free-webmail inputs.
*/
import { describe, it, expect } from "vitest";

import { normalizeDomainInput } from "./freeEmailDomains.js";

describe("normalizeDomainInput", () => {
it("trims, lower-cases, and accepts a plain domain", () => {
const result = normalizeDomainInput(" Example.COM ");
expect(result).toEqual({ ok: true, domain: "example.com" });
});

it("strips a single leading @ from email-style input", () => {
const result = normalizeDomainInput("@example.com");
expect(result).toEqual({ ok: true, domain: "example.com" });
});

it("flags empty strings as empty", () => {
expect(normalizeDomainInput("")).toEqual({ ok: false, error: { kind: "empty" } });
expect(normalizeDomainInput(" ")).toEqual({ ok: false, error: { kind: "empty" } });
expect(normalizeDomainInput(undefined)).toEqual({ ok: false, error: { kind: "empty" } });
});

it("rejects malformed domains", () => {
expect(normalizeDomainInput("not-a-domain")).toEqual({
ok: false,
error: { kind: "invalid_format" },
});
expect(normalizeDomainInput("example.")).toEqual({
ok: false,
error: { kind: "invalid_format" },
});
});

it("rejects free webmail providers (gmail, outlook, yahoo, …)", () => {
expect(normalizeDomainInput("gmail.com")).toEqual({
ok: false,
error: { kind: "free_email", domain: "gmail.com" },
});
expect(normalizeDomainInput("@yahoo.co.jp")).toEqual({
ok: false,
error: { kind: "free_email", domain: "yahoo.co.jp" },
});
expect(normalizeDomainInput("OUTLOOK.com")).toEqual({
ok: false,
error: { kind: "free_email", domain: "outlook.com" },
});
});
});
149 changes: 149 additions & 0 deletions packages/shared/src/freeEmailDomains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* `note_domain_access` (#663) のドメイン入力で参照する、フリーメール (free-webmail)
* プロバイダの拒否リストとドメイン文字列の正規化ユーティリティ。
*
* ドメイン招待は社内 OSS / WG など閉じた組織のメールドメインを対象とした機能なので、
* 誰でも取得できる Gmail / Outlook / Yahoo 等のドメインを許可してしまうと事実上
* `unlisted` と区別がつかなくなる。本ファイルが「真実の値」となり、`server/api`
* 側が同じ値を二重定義し、`src/lib/freeEmailDomainsSync.test.ts` が CI で同期を担保する。
*
* Source-of-truth deny-list of free-webmail providers + domain-input
* validation helpers used by the `note_domain_access` flow (#663). Domain
* invitations are intended for closed organisational domains, so allowing
* "anyone can register" providers (Gmail, Outlook, Yahoo, …) would collapse
* the feature into `unlisted`. This file owns the canonical values; the
* `server/api` package duplicates them in its own copy because it lives
* outside the workspace, and `src/lib/freeEmailDomainsSync.test.ts` keeps
* the two in sync via a CI drift detector.
*
* 同期義務 / Sync obligation:
* - 本ファイルを編集したら `server/api/src/lib/freeEmailDomains.ts` も同じ値で更新する。
* ドリフト検知テストが失敗したらどちらか片側しか更新していないので、もう片方を揃える。
* - When this file changes, also update
* `server/api/src/lib/freeEmailDomains.ts`. If the drift test fails, only
* one side was edited — sync the other.
*/

/**
* 拒否対象の無料メールドメイン(小文字・`@` なし)。
* 追加する際は小文字で、かつ意味的に「個人が誰でも取得できる」サービスに限定する。
*
* Free-webmail domains we block for domain-scoped access rules. Entries must
* be lower-case and only cover providers where anyone can register an address.
*/
export const FREE_EMAIL_DOMAINS: ReadonlySet<string> = new Set([
// Google
"gmail.com",
"googlemail.com",
// Microsoft
"outlook.com",
"outlook.jp",
"hotmail.com",
"hotmail.co.jp",
"live.com",
"live.jp",
"msn.com",
// Yahoo
"yahoo.com",
"yahoo.co.jp",
"ymail.com",
// Apple
"icloud.com",
"me.com",
"mac.com",
// Other major free webmail
"aol.com",
"proton.me",
"protonmail.com",
"pm.me",
"gmx.com",
"gmx.net",
"mail.com",
"zoho.com",
"yandex.com",
"yandex.ru",
// Japanese carriers / ISP free tiers
"docomo.ne.jp",
"ezweb.ne.jp",
"softbank.ne.jp",
"i.softbank.jp",
"ybb.ne.jp",
"nifty.com",
"so-net.ne.jp",
"biglobe.ne.jp",
"ocn.ne.jp",
// Disposable / throwaway (representative)
"mailinator.com",
"guerrillamail.com",
"10minutemail.com",
"tempmail.com",
"trashmail.com",
]);

/**
* `DomainValidationError` は {@link normalizeDomainInput} が返すエラーを分類する判別共用体。
* 呼び出し側は HTTP ステータスやユーザー向けメッセージにマップする。
*
* Discriminated error kinds returned by {@link normalizeDomainInput}. Callers
* map them to HTTP status codes or user-facing messages.
*/
export type DomainValidationError =
| { kind: "empty" }
| { kind: "invalid_format" }
| { kind: "free_email"; domain: string };

/**
* ドメイン入力を正規化した結果。成功時は小文字・`@` 無しのドメイン文字列、
* 失敗時は {@link DomainValidationError}。
*
* Result of normalising a domain input: either a lower-cased, `@`-less domain
* string or a {@link DomainValidationError}.
*/
export type DomainValidationResult =
| { ok: true; domain: string }
| { ok: false; error: DomainValidationError };

/**
* RFC 1035 ベースのラフなドメイン検証。IDN / punycode は将来対応。
* `example.com` / `a.b-c.example.jp` など、ラベルは英数字とハイフン、各ラベル
* 1..63 文字、全体 1..253 文字、TLD はアルファベットで 2 文字以上。
*
* Lightweight RFC 1035 domain check (IDN / punycode left for a later pass).
* Each label is alphanumeric plus hyphen, 1..63 chars; the whole name must be
* 1..253 chars; the TLD must be alphabetic and at least 2 characters.
*/
export const DOMAIN_REGEX = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;

/**
* ユーザー/API 入力のドメイン文字列を正規化する。
*
* - 前後空白を除去し小文字化
* - 先頭 `@` を 1 つだけ除去(`@example.com` 形式の入力を救済)
* - 空文字・形式不正・フリーメールドメインを拒否
*
* Normalise a raw domain input:
* - trim & lower-case
* - strip a single leading `@`
* - reject empty strings, malformed domains, and free-webmail providers.
*
* @param raw - ユーザーから受け取った生の文字列 / Raw user input
*/
export function normalizeDomainInput(raw: unknown): DomainValidationResult {
if (typeof raw !== "string") {
return { ok: false, error: { kind: "empty" } };
}
let value = raw.trim().toLowerCase();
if (value.startsWith("@")) {
value = value.slice(1);
}
if (value.length === 0) {
return { ok: false, error: { kind: "empty" } };
}
if (!DOMAIN_REGEX.test(value)) {
return { ok: false, error: { kind: "invalid_format" } };
}
if (FREE_EMAIL_DOMAINS.has(value)) {
return { ok: false, error: { kind: "free_email", domain: value } };
}
return { ok: true, domain: value };
}
7 changes: 7 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@
* Node-only APIs so the package stays universally importable.
*/
export { TAG_NAME_CHAR_CLASS } from "./tagCharacterClass.js";
export {
DOMAIN_REGEX,
FREE_EMAIL_DOMAINS,
normalizeDomainInput,
type DomainValidationError,
type DomainValidationResult,
} from "./freeEmailDomains.js";
15 changes: 15 additions & 0 deletions server/api/src/lib/freeEmailDomains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
* ハードコードされた拒否リストで代表的なものだけ弾き、将来は DNS TXT による
* 所有権検証 (`verifiedAt`) に進化させる前提の最低限のガード。
*
* 同期義務 / Sync obligation:
* - `packages/shared/src/freeEmailDomains.ts` が真実の値。`server/api` は
* ワークスペース外なので `@zedi/shared` を直接 import できず、本ファイルで
* 同じ値を二重定義している。`src/lib/freeEmailDomainsSync.test.ts` が
* 両者の `FREE_EMAIL_DOMAINS` / `DOMAIN_REGEX` の一致を CI で担保する。
* - 本ファイルを編集したら `packages/shared/src/freeEmailDomains.ts` も
* 同じ値で更新すること。ドリフト検知テストが失敗したら片側しか更新して
* いないので、もう片方を揃える。
*
* Deny-list of free-webmail providers, plus helpers to normalise and validate
* domain inputs for `note_domain_access` (issue #663).
*
Expand All @@ -16,6 +25,12 @@
* collapse this feature into `unlisted`, since anyone can mint an address at
* those hosts. This module hard-codes the most common ones as a v1 safeguard;
* a future v2 will add DNS-TXT ownership verification via `verifiedAt`.
*
* Sync obligation: the canonical copy lives in
* `packages/shared/src/freeEmailDomains.ts`. `server/api` cannot import
* `@zedi/shared` because it lives outside the workspace, so this file
* duplicates the values; `src/lib/freeEmailDomainsSync.test.ts` enforces
* equality between the two sides in CI. Update both files together.
*/

/**
Expand Down
63 changes: 63 additions & 0 deletions src/hooks/useDomainAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* React Query hooks for the note domain-access flow (epic #657 / issue #663).
* ノートのドメイン招待 (note_domain_access) フローの React Query フック。
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { createApiClient } from "@/lib/api";
import type { CreateDomainAccessBody, DomainAccessRow } from "@/lib/api/types";

/**
* Query key factory for domain-access queries.
* ドメイン招待系クエリのキー工場。
*/
export const domainAccessKeys = {
all: ["domain-access"] as const,
listByNote: (noteId: string) => [...domainAccessKeys.all, "note", noteId] as const,
};

/**
* List domain-access rules for a note (owner / editor).
* ノートのドメインルール一覧を取得する(owner / editor)。
*/
export function useDomainAccessForNote(noteId: string, enabled = true) {
const api = createApiClient();
return useQuery<DomainAccessRow[]>({
queryKey: domainAccessKeys.listByNote(noteId),
queryFn: () => api.listDomainAccess(noteId),
enabled: enabled && !!noteId,
});
}

/**
* Create a new domain-access rule (owner only). Free-email providers are
* rejected by the server with HTTP 400.
* ドメインルールを追加する(オーナーのみ)。フリーメール (gmail.com 等) は
* サーバーが 400 で拒否する。
*/
export function useCreateDomainAccess(noteId: string) {
const api = createApiClient();
const qc = useQueryClient();
return useMutation<DomainAccessRow, Error, CreateDomainAccessBody>({
mutationFn: (body) => api.createDomainAccess(noteId, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: domainAccessKeys.listByNote(noteId) });
},
});
}

/**
* Delete an existing domain-access rule (owner only). The effect is immediate;
* any user who was relying on this rule loses access on their next request.
* ドメインルールを削除する(オーナーのみ)。削除は即座に反映され、その
* ドメインに依存していたアクセスは次回リクエストから失効する。
*/
export function useDeleteDomainAccess(noteId: string) {
const api = createApiClient();
const qc = useQueryClient();
return useMutation<{ removed: true; id: string }, Error, { accessId: string }>({
mutationFn: ({ accessId }) => api.deleteDomainAccess(noteId, accessId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: domainAccessKeys.listByNote(noteId) });
},
});
}
25 changes: 24 additions & 1 deletion src/i18n/locales/en/notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,28 @@
"shareVisibilityDescriptionPublic": "Anyone can discover and view this note.",
"shareVisibilityDescriptionRestricted": "Only invited members can view; the link alone does not grant access.",
"shareUnlistedUrlHint": "Copy this link to share with viewers:",
"shareSaveChanges": "Save changes"
"shareSaveChanges": "Save changes",
"domainTabHeading": "Domain access",
"domainTabDescription": "Anyone signed in with an email at one of these domains automatically gets access. Free webmail providers (gmail.com, outlook.com, …) are not accepted.",
"domainTabAddHeading": "Add domain rule",
"domainPlaceholder": "example.com",
"domainTabAdd": "Add",
"domainTabRemove": "Remove",
"domainTabRemoveAria": "Remove domain {{domain}}",
"domainTabRoleViewer": "Viewer",
"domainTabRoleEditor": "Editor",
"domainTabNoRules": "No domain rules yet.",
"domainTabLoading": "Loading domain rules…",
"domainTabLoadFailed": "Failed to load domain rules. Please try again later.",
"domainTabRuleSummary": "Anyone with an email at {{domain}} can join as {{role}}.",
"domainTabUnverifiedBadge": "Unverified",
"domainTabUnverifiedHint": "Domain ownership is not yet verified. DNS-TXT verification will arrive in a future release.",
"domainTabEditorWarning": "This rule grants edit permission to everyone at this domain. Only do this for closed organisations.",
"domainTabCreated": "Domain rule added",
"domainTabCreateFailed": "Failed to add domain rule",
"domainTabCreateFailedFreeEmail": "{{domain}} is a free webmail provider and cannot be used as a domain rule.",
"domainTabCreateFailedInvalid": "The domain format is invalid.",
"domainTabCreateFailedEmpty": "Enter a domain.",
"domainTabRemoved": "Domain rule removed",
"domainTabRemoveFailed": "Failed to remove domain rule"
}
25 changes: 24 additions & 1 deletion src/i18n/locales/ja/notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,28 @@
"shareVisibilityDescriptionPublic": "誰でもこのノートを発見・閲覧できます。",
"shareVisibilityDescriptionRestricted": "招待されたメンバーのみ閲覧できます。URL を知っていてもアクセス権は付与されません。",
"shareUnlistedUrlHint": "このリンクをコピーして共有できます:",
"shareSaveChanges": "変更を保存"
"shareSaveChanges": "変更を保存",
"domainTabHeading": "ドメイン招待",
"domainTabDescription": "指定したドメインのメールでサインインしたユーザーは自動でアクセスできます。フリーメール(gmail.com / outlook.com など)は登録できません。",
"domainTabAddHeading": "ドメインルールを追加",
"domainPlaceholder": "example.com",
"domainTabAdd": "追加",
"domainTabRemove": "削除",
"domainTabRemoveAria": "{{domain}} を削除",
"domainTabRoleViewer": "閲覧者",
"domainTabRoleEditor": "編集者",
"domainTabNoRules": "ドメインルールはまだありません。",
"domainTabLoading": "ドメインルールを読み込み中…",
"domainTabLoadFailed": "ドメインルールを取得できませんでした。しばらくしてから再度お試しください。",
"domainTabRuleSummary": "{{domain}} のメールを持つユーザーは {{role}} として参加できます。",
"domainTabUnverifiedBadge": "未検証",
"domainTabUnverifiedHint": "ドメインの所有権は未検証です。DNS TXT による検証は今後のリリースで追加予定です。",
"domainTabEditorWarning": "このルールはドメイン全員に編集権限を渡します。閉じた組織内でのみ利用してください。",
"domainTabCreated": "ドメインルールを追加しました",
"domainTabCreateFailed": "ドメインルールの追加に失敗しました",
"domainTabCreateFailedFreeEmail": "{{domain}} はフリーメールのため、ドメインルールに登録できません。",
"domainTabCreateFailedInvalid": "ドメインの形式が正しくありません。",
"domainTabCreateFailedEmpty": "ドメインを入力してください。",
"domainTabRemoved": "ドメインルールを削除しました",
"domainTabRemoveFailed": "ドメインルールの削除に失敗しました"
}
Loading
Loading