@tacet-ink/journal-core 0.1.5 → 0.2.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.
- package/README.md +123 -14
- package/package.json +7 -4
- package/scripts/verify-core-crypto.ts +1437 -10
- package/src/client/argon2.ts +201 -40
- package/src/client/note-crypto.ts +175 -41
- package/src/client/pinlock.ts +13 -8
- package/src/index.ts +18 -1
- package/src/server/auth.ts +58 -19
- package/src/server/cors.ts +4 -0
- package/src/server/ladder.ts +71 -0
- package/src/server/ratelimit.ts +50 -34
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ladder.ts — 密語正規化 v3 世代 ladder 表(v0.2.0 批卡② t_ce216a63;server 內部表)。
|
|
3
|
+
*
|
|
4
|
+
* (a+)方案(主 session 收案裁定):遷移時舊 ph2 值入 server 內部表(ph2_ladder),
|
|
5
|
+
* users 表零 schema 變更、ph2 欄 UNIQUE 恆動──client 舊 bundle 的單欄 v2-first 查表
|
|
6
|
+
* 在遷移後仍對既有帳戶命中(查 users.ph2 現值=v2 形),行為不變。
|
|
7
|
+
*
|
|
8
|
+
* 語意面:
|
|
9
|
+
* - ladder hit(現值未命中 → ladder 命中)=不建幽靈、回舊帳(fork 語意面 ph2Kind='legacy')。
|
|
10
|
+
* v3 帳戶:鹽與包裹已旋轉成 v3 形;fork 語意「本 login 的舊形憑證 unwrap 必失敗;
|
|
11
|
+
* client 以 ladder 缺鹽面誠實告知升級」對應 v0.2.0 前的單欄舊 bundle(帶 ph1_v3 的
|
|
12
|
+
* 新 client 恰命中 v3 現值、前綴自描述走 v3 腿)。
|
|
13
|
+
* - ladder miss(兩面皆查無)=才建幽靈(fork createUser,v2 先行)。
|
|
14
|
+
* - 幽靈帳戶走完遷移後以同舊值 upsert(ON CONFLICT(ph2)=值主權威覆蓋,最新持有者勝)。
|
|
15
|
+
* - 表列隨帳戶刪除清理(FK ON DELETE CASCADE,fork delete-account 面零新語句);
|
|
16
|
+
* users 列是真值面,ladder 表列可重建(v2 形重新遷移即重落)——表列毀損不傷帳戶面。
|
|
17
|
+
* - 幽靈 GC(wrapped_key IS NULL 判準)不收 ladder 命中面(帳戶列仍在=判準不變)。
|
|
18
|
+
*
|
|
19
|
+
* 零知識照舊:ladder 只存 ph2 hash 與 account_id,零 PII、零密語材料。
|
|
20
|
+
*
|
|
21
|
+
* 職責分工(fork 對位面):表名/migration 由 fork migrations 承載(0011-ph2-ladder.sql,
|
|
22
|
+
* PK=ph2 值主權威形);本層提供「以舊 ph2 查列」與「遷移時入表」原語,查表接線
|
|
23
|
+
* (現值 miss → ladder → 建幽靈)在 fork route(tacet loginRoute,v2-first 裁定)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Env } from './env.ts';
|
|
27
|
+
|
|
28
|
+
/** ladder 表名(migration 同名;單一真相,fork migration 與本原語同字面)。 */
|
|
29
|
+
export const PH2_LADDER_TABLE = 'ph2_ladder';
|
|
30
|
+
|
|
31
|
+
/** ladder 行 kinds:'legacy'(SHA-256 快雜湊世代)與 'v2'(v2 形前身,card② 遷移的正常前身)。
|
|
32
|
+
* 「帳戶現在用什麼形」的真相在 users.ph2 班表行(ph2_ladder 只承載舊值);kind 欄
|
|
33
|
+
* 純粹是「這個舊值當年是什麼形」的語意記錄。 */
|
|
34
|
+
export type Ph2LadderKind = 'legacy' | 'v2';
|
|
35
|
+
export const PH2_LADDER_KIND_LEGACY = 'legacy';
|
|
36
|
+
export const PH2_LADDER_KIND_V2 = 'v2';
|
|
37
|
+
|
|
38
|
+
/** ladder 行形(fork schema 對位面)。 */
|
|
39
|
+
export interface Ph2LadderRow {
|
|
40
|
+
accountId: string;
|
|
41
|
+
ph2Kind: string;
|
|
42
|
+
createdAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 以「舊 ph2」查 ladder(fork store 對位面)。null = ladder miss。 */
|
|
46
|
+
export function makePh2LadderStore(env: Env): {
|
|
47
|
+
findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null>;
|
|
48
|
+
insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void>;
|
|
49
|
+
} {
|
|
50
|
+
return {
|
|
51
|
+
async findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null> {
|
|
52
|
+
const row = await env.DB.prepare(
|
|
53
|
+
`SELECT account_id, ph2_kind, upgraded_at FROM ${PH2_LADDER_TABLE} WHERE ph2 = ?`,
|
|
54
|
+
).bind(oldPh2).first<{ account_id: string; ph2_kind: string; upgraded_at: number }>();
|
|
55
|
+
if (!row) return null;
|
|
56
|
+
return { accountId: row.account_id, ph2Kind: row.ph2_kind, createdAt: row.upgraded_at };
|
|
57
|
+
},
|
|
58
|
+
async insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void> {
|
|
59
|
+
// upsert ON CONFLICT(ph2)(值主權威;run 590 定形):同一舊值恰一列,最新持有者勝——
|
|
60
|
+
// A 遷移落 (V→A) 後 V 值幽靈帳戶後建並遷移=覆蓋為 (V→B)。無 DELETE 語句:行留存
|
|
61
|
+
// 續供幽靈守衛(行隨帳戶刪除由 FK CASCADE 清);fork 批面另備 ladderDelete 給
|
|
62
|
+
// delete-account 清場面。
|
|
63
|
+
// fail-open(遷移率聚合面):插表失敗只讓下一輪 login 重試同一 upsert
|
|
64
|
+
//(v3 查表 miss 再走 ladder miss → 建 v2 幽靈的行為面)──遷移本體不因本表故障回退。
|
|
65
|
+
await env.DB.prepare(
|
|
66
|
+
`INSERT INTO ${PH2_LADDER_TABLE} (ph2, account_id, ph2_kind, upgraded_at) VALUES (?, ?, ?, ?)
|
|
67
|
+
ON CONFLICT(ph2) DO UPDATE SET account_id = excluded.account_id, ph2_kind = excluded.ph2_kind, upgraded_at = excluded.upgraded_at`,
|
|
68
|
+
).bind(oldPh2, accountId, oldKind, Date.now()).run();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/server/ratelimit.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ratelimit.ts — per-IP
|
|
2
|
+
* ratelimit.ts — per-IP fixed-window 限流(D1 計數,跨 isolate 有效;單句 UPSERT…RETURNING)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* 設計(2026-10-04 t_b91ed07f,外部審查 MAJOR:原 4 往返條件寫入鏈 1 往返收口):
|
|
5
|
+
* - 限流語意是 fixed window:以 window_start 為窗錨,窗過期整窗重置。
|
|
6
|
+
* (原檔頭「滑動視窗」措辭錯誤——c7c8701 只修 README 未修本檔;本卡一併收口。)
|
|
7
|
+
* - 單條 UPSERT…RETURNING 原子完成「計數+窗重置+讀回放行判準」:
|
|
8
|
+
* 同 IP 併發首發不再有 INSERT/輸家競態(同語句同鍵衝突時第二發原子吃到
|
|
9
|
+
* count+1;D1 實測 RETURNING 逐發回真值——同 IP 併發首發不 undercount)。
|
|
10
|
+
* - fail-open 裁定不變(2026-09-06):限流儲存故障時放行(保護面不可反噬主功能),
|
|
11
|
+
* 只擋明確超額。RETURNING 異形回應(results 空/alias 斷裂)同向 fail-open
|
|
12
|
+
* 並 console.error 照實回報(r2 MINOR-3:異形不反向成 deny)。
|
|
9
13
|
*
|
|
10
|
-
*
|
|
14
|
+
* 放行判準:RETURNING 的 count <= max(窗口內超額後的每一發都回實際 count,
|
|
15
|
+
* 由呼叫端以 count <= max 拒絕——計數不封頂,超額仍逐發累計)。
|
|
16
|
+
*
|
|
17
|
+
* 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
|
|
18
|
+
* 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
|
|
11
19
|
* CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
|
|
20
|
+
* 可選 GC(未內建):定期 DELETE WHERE window_start < {now} - windowMs。
|
|
12
21
|
*/
|
|
13
22
|
|
|
14
|
-
import type { Env } from './env';
|
|
23
|
+
import type { Env } from './env.ts';
|
|
15
24
|
|
|
16
25
|
export interface RateWindow {
|
|
17
26
|
table: string;
|
|
@@ -19,35 +28,42 @@ export interface RateWindow {
|
|
|
19
28
|
max: number;
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 表名只允許 \w 字元(字母/數字/底線):表名直接拼進 SQL 字串,禁注入符號。
|
|
33
|
+
* 違例 throw——配置錯誤在開發期炸開,不靜默。
|
|
34
|
+
*/
|
|
35
|
+
function assertSafeTable(table: string): void {
|
|
36
|
+
if (!/^[\w]+$/.test(table)) throw new Error(`ERR_RATE_TABLE_NAME: ${table}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @internal(0.1.6 公開面走 index.ts checkRate)。UPSERT…RETURNING 單句({table} 佔位由呼叫端替換;D1 實測 RETURNING 列經 results 回帶)。 */
|
|
40
|
+
export const SQL_RATE_BUMP = `INSERT INTO {table} (ip, window_start, count) VALUES (?1, ?2, 1)
|
|
41
|
+
ON CONFLICT(ip) DO UPDATE SET
|
|
42
|
+
count = CASE WHEN window_start <= ?3 THEN 1 ELSE count + 1 END,
|
|
43
|
+
window_start = CASE WHEN window_start <= ?3 THEN ?2 ELSE window_start END
|
|
44
|
+
RETURNING count AS n`;
|
|
45
|
+
|
|
46
|
+
/** @internal(0.1.6 公開面走 index.ts checkRate)。放行判準單一真相:窗口內超額後照實回實際 count,呼叫端以 count <= max 拒絕。 */
|
|
47
|
+
export function isRateAllowed(count: number, max: number): boolean {
|
|
48
|
+
return count <= max;
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
async function bumpRate(env: Env, w: RateWindow, ip: string): Promise<boolean> {
|
|
52
|
+
assertSafeTable(w.table);
|
|
23
53
|
const now = Date.now();
|
|
24
54
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
).bind(ip, now).run();
|
|
38
|
-
if ((ins.meta?.changes ?? 0) > 0) return true;
|
|
39
|
-
const loser = await env.DB.prepare(
|
|
40
|
-
`UPDATE ${w.table} SET count = count + 1
|
|
41
|
-
WHERE ip = ? AND window_start > ? AND count < ?`
|
|
42
|
-
).bind(ip, now - w.windowMs, w.max).run();
|
|
43
|
-
if ((loser.meta?.changes ?? 0) > 0) return true;
|
|
44
|
-
// 列已存在且未超額:窗口過期 → 重置;窗口內但剛才 count>=max 沒吃到 → 拒絕
|
|
45
|
-
const reset = await env.DB.prepare(
|
|
46
|
-
`UPDATE ${w.table} SET window_start = ?, count = 1
|
|
47
|
-
WHERE ip = ? AND window_start <= ?`
|
|
48
|
-
).bind(now, ip, now - w.windowMs).run();
|
|
49
|
-
if ((reset.meta?.changes ?? 0) > 0) return true;
|
|
50
|
-
return false;
|
|
55
|
+
const { results } = await env.DB.prepare(
|
|
56
|
+
SQL_RATE_BUMP.replaceAll('{table}', w.table)
|
|
57
|
+
).bind(ip, now, now - w.windowMs).run();
|
|
58
|
+
const first = results[0] as { n?: unknown } | undefined;
|
|
59
|
+
const n = first?.n;
|
|
60
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
61
|
+
// 異形回應(results 空/alias 斷裂)與儲存故障同向 fail-open(2026-09-06 裁定:
|
|
62
|
+
// 缺陷偏向放行,不反向成 deny);真 D1 不產生(探針實證)——守衛照實回報。
|
|
63
|
+
console.error(`[ratelimit] ${w.table} unexpected RETURNING shape:`, first);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return isRateAllowed(n, w.max);
|
|
51
67
|
} catch (e) {
|
|
52
68
|
// fail-open 裁定(2026-09-06):儲存故障時放行,只擋明確超額
|
|
53
69
|
console.error(`[ratelimit] ${w.table} update failed:`, e);
|