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
8 changes: 7 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,14 +465,20 @@ crsl-libはMonasのために設計されたCIDネイティブなDAG CRDTライ

| フィールド | 規則 | 理由 |
|---|---|---|
| コンテンツ本体(ciphertext) | LWW — ただし**親から本体を変えたheadの中で**timestamp最大 | 最後の書き込みが正。policyだけを進めたhead(revoke等)は本体を親からコピーしているだけで「書いて」いないので、並行する本物の書き込みに勝ってはならない |
| コンテンツ本体(ciphertext) | `(body_updated_at, data)` の辞書順 max | 明示的な本文更新でのみ順序を進め、policy-only 更新と Merge は本文と順序をそのまま引き継ぐ。head 自体の timestamp や直近の親との差分では選ばない |
| `access_policy.min_valid_issued_at` | 全headの**max** | 失効境界は単調にしか進まない。timestampで選ぶと、境界を知らないノードが受理した並行writeが境界を巻き戻す |
| `access_policy.owner` / `content_id` | 不変(genesisで確定) | — |

payload全体をtimestampで丸ごと選ぶ(純粋なLWW)と、revokeと並行するwriteの一方が必ず消える — writeがtimestampで勝てばrevokeが消え、revokeが勝てば正当なwriteが消える。どちらも「競合していないフィールドの変更が、競合したフィールドの勝敗に巻き込まれる」のが原因で、フィールド別に畳めば両方残る。マージポリシーはプロセスに焼かれておりデータとともには流れないため、**同じContent Networkの全メンバーが同じ規則を持つ**必要がある。

このマージが決めるのは「Mergeノードに何を入れるか」であり、「そのheadを受理してよかったか」ではない。失効境界を知らないメンバーが旧Tokenで受理したwriteは、最新のwriteであれば本体として残る(境界は残るので以後は書けない)。それを弾くにはwriteが自分のTokenを持ち歩き、マージ時に畳んだ境界に対して検証する必要がある — ワイヤ形式の変更を伴うため別issueで追跡する。

`body_updated_at` は本文と同じ payload に保存する論理的な更新順序であり、別 DAG ではない。本文更新ではローカルの単調 timestamp と観測済みの順序 + 1 の大きい方を採る。policy-only 更新、再マージ、再起動で順序を失わず、同値時は ciphertext の辞書順で決定する。観測・マージ・payload の生成・commit は同じ repository lock 内で行う。

同期 export は operation と DAG ノードを payload・parents・genesis・metadata で対応付け、実ノードの timestamp を送る。履歴の位置対応は使用しない。`since_version` はそのノードと祖先を既知とみなし、兄弟枝を省かず親から順に送る。曖昧な対応は推測せずエラーにする。

保存・wire 形式の変更: `body_updated_at` は必須で、旧形式を 0 等へ暗黙補完しない。現行デモは顧客利用前のため、全 state-node を同時更新し、新しいストアから開始してコンテンツを再作成する必要がある。既存ストアを維持する場合の移行は未実装。データ削除やデプロイは本変更では行わない。

コンテンツ本体の意味的なマージ(同じフィールド内での両立)は現時点で未実装であり、研究課題として位置づけられている。

### 将来のCRDT拡張
Expand Down
26 changes: 26 additions & 0 deletions example-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,32 @@ send permissive CORS headers.

## Tests

### Isolated UI regressions (no backend required)

```bash
npm ci --ignore-scripts
npx playwright install chromium # once, if not already installed
npm run test:regression
npm run build
```

This suite starts its own Vite on `127.0.0.1:5198` (fails if occupied). It runs
actual App/store/flow/API-adapter paths with HTTP fixtures, intercepts all
backend requests, rejects unknown endpoints and blocks external origins. No
hosted nodes or account keys are needed or modified. Results/traces go to
`/tmp/monas-ui-regression-results`. It covers recipient import → edit → reopen,
owner preview/head checks, revocation reach reporting, and legacy identities.
These are UI regressions, not cryptographic or distributed-protocol tests.

Legacy identity migration keeps the **last-created signing account**, matching
`POST /accounts` replacing monas-account's single key. Earlier signing entries
remain available as envelope-decryption keypairs; removing the current account
does not promote them. `activeLabel` from old UI switching cannot change the
backend's key. The account API has no read-current-key endpoint, so a reset or
externally replaced backend key still requires explicit user recovery.

### Real-stack suites

```bash
npm test # UI suite (tests/) — ~22s
npm run test:ui # same, in the Playwright UI runner
Expand Down
1 change: 1 addition & 0 deletions example-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:regression": "playwright test -c playwright.regression.config.ts",
"test:e2e": "playwright test -c playwright.e2e.config.ts"
},
"dependencies": {
Expand Down
18 changes: 18 additions & 0 deletions example-ui/playwright.regression.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineConfig, devices } from "@playwright/test";

// No running gateway/account/state nodes required. Never reuse a live UI server.
export default defineConfig({
testDir: "./tests-regression",
timeout: 30_000,
expect: { timeout: 4_000 },
workers: 1,
reporter: "list",
outputDir: "/tmp/monas-ui-regression-results",
use: { baseURL: "http://127.0.0.1:5198", serviceWorkers: "block", trace: "retain-on-failure" },
webServer: {
command: "npx vite --host 127.0.0.1 --port 5198 --strictPort",
url: "http://127.0.0.1:5198",
reuseExistingServer: false,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
8 changes: 7 additions & 1 deletion example-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,12 @@ export default function App() {
// voided tokens until its next sync. Say so — "revoked" alone would
// overstate what just happened.
const reach = r?.token_invalidation_reach;
if (reach?.relayed) {
if (!reach && (r?.token_invalidated_at != null || entry.syncedToStateNode || entry.remoteContentId)) {
pushToast(
"Token invalidation propagation is unknown — the node returned no reach report. Members that have not synced may still accept writes under old tokens.",
"error",
);
} else if (reach?.relayed) {
pushToast(
"The revoke was relayed to a member node; which members enforce the cutoff yet is not known from here. Writes under the old token may land on members that have not synced.",
"error",
Expand Down Expand Up @@ -806,6 +811,7 @@ export default function App() {
<PreviewModal
entry={liveEntry(modal.entry.id) ?? modal.entry}
contentB64Url={modal.contentB64Url}
displayedVersionId={modal.entry.localContentId}
onCheckHead={checkNetworkHead}
onEdit={(e) => handleEditOpen(e)}
onClose={() => setModal({ type: "none" })}
Expand Down
3 changes: 2 additions & 1 deletion example-ui/src/api/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ export interface RevokeShareOutput {
* keeps accepting writes under the voided tokens until its next sync.
* The revoke does not wait for that (a writer must not be able to block
* it); this is how the UI tells "revoked everywhere" from "revoked, N
* members still to hear". Absent when no state node was involved. */
* members still to hear". Also absent with legacy nodes (unknown reach);
* absence alone does not mean no state node was involved. */
token_invalidation_reach?: TokenInvalidationReach;
}

Expand Down
4 changes: 2 additions & 2 deletions example-ui/src/components/IdentityModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function IdentityModal({ onClose }: { onClose: () => void }) {
signing
</span>
) : (
<span className="badge local" style={{ marginLeft: 4 }} title="Not registered with monas-account — cannot sign or read the state node">
<span className="badge local" style={{ marginLeft: 4 }} title="Not the current monas-account key — cannot sign or read the state node">
keypair only
</span>
)}
Expand Down Expand Up @@ -142,7 +142,7 @@ export function IdentityModal({ onClose }: { onClose: () => void }) {
Other identities
</div>
<div className="hint" style={{ marginBottom: 6 }}>
Keypair-only identities from an earlier version of this UI. They can
Older identities (including replaced signing accounts). They can
still open envelopes addressed to them, but cannot sign or read the
state node. Have files shared to your account instead.
</div>
Expand Down
7 changes: 5 additions & 2 deletions example-ui/src/components/PreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ function classifyVerify(v: VerifyIntegrityOutput): "valid" | "behind" | "no-loca
export function PreviewModal({
entry,
contentB64Url,
displayedVersionId,
onCheckHead,
onEdit,
onClose,
}: {
entry: Entry;
contentB64Url: string;
/** Identity of the rendered body, fixed when opened (not the last write). */
displayedVersionId: string | undefined;
/** Verified read of the head that also records it on the entry — the
* list's sync badge and the status line here read from that record. */
onCheckHead: (entry: Entry) => Promise<ReadFromStateNodeOutput | null>;
Expand Down Expand Up @@ -166,7 +169,7 @@ export function PreviewModal({
// The one-line answer to "am I looking at the newest version?". Derived
// from what the entry recorded at the last head check (open, sweep, or the
// verified read below), so it stays right even while this dialog is idle.
const sync = syncStatusOf(entry);
const sync = syncStatusOf(entry, displayedVersionId);
const syncText = describeSync(sync);
const held = heldVersionId(entry);
const syncBadgeClass =
Expand Down Expand Up @@ -227,7 +230,7 @@ export function PreviewModal({
<>
The Content Network has a <b>newer version</b> than the text above
{received
? " — the owner has edited since sharing"
? " — this preview is the original shared version, not the current network head"
: " — a recipient with write access has edited since your last save"}
. <i>Read from state-node</i> shows it
{canWrite ? (received ? "; Edit contents starts from it." : "; Pull & edit adopts it into your copy.") : "."}
Expand Down
6 changes: 5 additions & 1 deletion example-ui/src/pipeline/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,11 @@ export function revokeFlow(input: {
exec: async (ctx) => {
const r = ctx.revoke as shareApi.RevokeShareOutput;
const reach = r.token_invalidation_reach;
if (!reach) return "No state node involved — nothing to propagate";
if (!reach) {
return r.token_invalidated_at != null || entry.syncedToStateNode || entry.remoteContentId
? "Token invalidation propagation is unknown — the node returned no reach report. Members that have not synced may still accept writes under old tokens."
: "No state node involved — nothing to propagate";
}
if (reach.relayed) {
return (
"The node we contacted relayed the revoke to a member; which members " +
Expand Down
17 changes: 17 additions & 0 deletions example-ui/src/store/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ const store = createStore<IdentityState>("monas.identities.v2", {
activeLabel: null,
});

// Older UIs appended a signing account on every POST /accounts, but that
// endpoint replaces the backend's ONE key. Array order records creation order;
// activeLabel only recorded UI switching and cannot change the backend key.
// There is no account read endpoint to reconcile against. Retain old private
// keys for envelope decryption, but persist their demotion so removing the
// current account never resurrects an overwritten signing key.
const signingAccounts = store.get().identities.filter((i) => i.isSigningAccount);
if (signingAccounts.length > 1) {
const current = signingAccounts[signingAccounts.length - 1];
store.set((prev) => ({
identities: prev.identities.map((i) =>
i.isSigningAccount && i !== current ? { ...i, isSigningAccount: false } : i,
),
activeLabel: current.label,
}));
}

export const useIdentities = () => store.use();

export function getIdentities(): Identity[] {
Expand Down
4 changes: 2 additions & 2 deletions example-ui/src/store/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function heldVersionId(entry: Entry): string | undefined {

const checking = new Set<string>();

export function syncStatusOf(entry: Entry): SyncStatus {
export function syncStatusOf(entry: Entry, comparedVersionId = heldVersionId(entry)): SyncStatus {
if (entry.kind !== "file" || !entry.syncedToStateNode || !entry.remoteContentId) {
return { kind: "local" };
}
Expand All @@ -40,7 +40,7 @@ export function syncStatusOf(entry: Entry): SyncStatus {
return { kind: "unreachable", error: entry.networkCheckError, head: entry.networkHead };
}
if (!entry.networkHead) return { kind: "unchecked" };
return entry.networkHead.localId === heldVersionId(entry)
return entry.networkHead.localId === comparedVersionId
? { kind: "current", head: entry.networkHead }
: { kind: "behind", head: entry.networkHead };
}
Expand Down
Loading
Loading