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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
and `package-lock.json` pins the complete install. No source checkout is used.
The vendored set is the core release
[core-2.79.0-core.h7896a01910a2ea0f](https://github.com/ZenNotes/zennotes/releases/tag/core-2.79.0-core.h7896a01910a2ea0f)
(desktop commit `47d26827`, clean tree). Run `npm run
[core-2.80.0-core.hc5a4589d040cd0da](https://github.com/ZenNotes/zennotes/releases/tag/core-2.80.0-core.hc5a4589d040cd0da)
(desktop commit `31520814`, clean tree). Run `npm run
boundaries:check` to verify archives, installed versions, singleton
editor/React peers, and imports; it refuses an archive built from a dirty
upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out.
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
applicationId "md.zennotes"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 53
versionName "1.1.50"
versionCode 54
versionName "1.1.51"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
34 changes: 17 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
"version": "1.1.50",
"version": "1.1.51",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down Expand Up @@ -43,9 +43,9 @@
"@lezer/highlight": "^1.2.5",
"@replit/codemirror-vim": "^6.3.0",
"@xyflow/react": "^12.12.0",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.79.0-core.h7896a01910a2ea0f.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h28492494a5dfa837.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h28492494a5dfa837.tgz",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.80.0-boundaries.hc07ef9e2bef31a64.tgz",
"codemirror": "^6.0.1",
"dompurify": "^3.4.17",
"function-plot": "^1.26.0",
Expand Down
44 changes: 44 additions & 0 deletions src/bridge/cloud-sync-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,47 @@ it('puts an object again when the store never answered, never when it refused',
}
}
})

it('keeps which fields a 422 refused, so sync sets one path aside and sends the rest (#1004)', async () => {
const refused = 'This path is not eligible for vault sync.'
const requests: string[] = []
const { createCloudSyncClient, cloudSyncPathRefusal, CloudSyncPathRefusedError } = await loadMobileModule([
'./src/bridge/cloud-sync-client.ts', '@zennotes/shared-domain/cloud-sync-refusal'
], {
'@capacitor/core': {
registerPlugin: () => ({}),
CapacitorHttp: { request: async ({ url, method }: { url: string; method: string }) => {
requests.push(`${method} ${new URL(url).pathname}`)
if (url.endsWith('/uploads')) {
return { status: 422, data: { error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, errors: { path: [refused] } } }
}
if (requests.length === 1) {
return { status: 422, data: { error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, errors: { 'mutations.1.path': [refused] } } }
}
return { status: 200, data: { acknowledged: [{ operation_id: 'kept', item_id: 'kept-item', revision: 1, sequence: 3 }], conflicts: [], cursor: 3 } }
} }
}
})
const content = (bytes: number) => ({ encoding: 'base64', data: Buffer.alloc(bytes, 1).toString('base64'), sha256: 'a'.repeat(64), byte_length: bytes, media_type: 'text/markdown' })
const upsert = (operation: string, path: string, bytes = 4) =>
({ type: 'upsert', operation_id: operation, item_id: `${operation}-item`, base_revision: null, path, kind: 'markdown', content: content(bytes) })
const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'refused-path' })

// One request: the transport keeps Cloud's `errors`, so the refusal names the path.
const inline = { mutations: [upsert('kept', 'inbox/Note.md'), upsert('agent', '.zennotes/agent-edits/inbox/Note.md.json')] }
const error = await client.mutate('vault-1', inline).catch((caught: unknown) => caught)
assert.equal(error.status, 422)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.equal(error.message, refused)
assert.deepEqual(error.details, { errors: { 'mutations.1.path': [refused] } })
assert.deepEqual(cloudSyncPathRefusal(error, inline)?.indices, [1])

// Several requests: a large file's upload refused for its path is named by
// its place in the batch, and carries what the inline request applied.
const mixed = { mutations: [upsert('kept', 'inbox/Note.md'), upsert('big', '.zennotes/agent-edits/big.json', 5 * 1024 * 1024 + 1)] }
const split = await client.mutate('vault-1', mixed).catch((caught: unknown) => caught)
assert.ok(split instanceof CloudSyncPathRefusedError)
assert.deepEqual(split.refusal.indices, [1])
assert.deepEqual(split.refusal.applied.acknowledged.map((entry: { operation_id: string }) => entry.operation_id), ['kept'])
assert.deepEqual(cloudSyncPathRefusal(split, mixed)?.indices, [1])
})
12 changes: 11 additions & 1 deletion src/bridge/cloud-sync-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: M
: `ZenNotes Cloud request failed (${response.status}).`),
response.status,
typeof error?.code === 'string' ? error.code : null,
isRecord(error?.details) ? error.details : null,
errorDetails(error?.details, response.data?.errors),
response.headers ?? {}
)
}
Expand Down Expand Up @@ -264,6 +264,16 @@ function unansweredUpload(error: unknown): boolean {
return error instanceof MobileDirectUploadError && error.status === 0 && error.code === 'DIRECT_UPLOAD_FAILED'
}

/** The answer's error details, with a 422's field errors kept under
* `details.errors`: which field Cloud refused (`mutations.3.path`) is only in
* those keys, and sync needs it to set one refused path aside instead of
* stopping the whole run (#1004, core 2.80; desktop's asErrorPayload). */
export function errorDetails(details: unknown, errors: unknown): Record<string, unknown> | null {
const kept = isRecord(details) ? details : null
const fields = isRecord(errors) ? errors : null
return kept || fields ? { ...kept, ...(fields ? { errors: fields } : {}) } : null
}

function firstValidationMessage(errors: unknown): string | null {
if (!errors || typeof errors !== 'object') return null

Expand Down
6 changes: 5 additions & 1 deletion src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'

let appVersion = '1.1.50'
let appVersion = '1.1.51'

export async function loadNativeAppVersion(): Promise<string> {
try {
Expand Down Expand Up @@ -406,6 +406,10 @@ const MOBILE_CAPABILITIES: ZenCapabilities = {
supportsCloudSync: true,
supportsCliInstall: false,
supportsCustomTemplates: true,
// vault.json's template folder (#1024) survives a save from this device
// (vault-fs.ts normalizes it as desktop does) and a folder rename follows
// it. A self-hosted vault answers with its server's own flag instead.
supportsTemplateFolder: true,
// TextMate grammar import is a desktop feature for now: grammars are
// per-app (not per-vault), so nothing desyncs by gating this off. The
// Settings section hides itself behind this flag.
Expand Down
74 changes: 74 additions & 0 deletions src/bridge/mobile-direct-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type Zne1ChunkCipherParams
} from '@zennotes/shared-domain/cloud-e2ee'
import { CloudE2eeLockedError, CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault'
import { CloudSyncPathRefusedError, cloudSyncPathRefusal } from '@zennotes/shared-domain/cloud-sync-refusal'
import {
CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES,
MobileDirectUploadError,
Expand Down Expand Up @@ -197,6 +198,79 @@ describe('mutateWithMobileDirectUploads', () => {
})
})

describe('a path Cloud refuses (#1004)', () => {
const refusedText = 'This path is not eligible for vault sync.'
const validation = (errors: Record<string, string[]>) =>
new MobileDirectUploadError(refusedText, 422, 'VALIDATION_FAILED', { errors })
const small = (operation: string, path: string): CloudSyncUpsertMutation =>
({ ...upsertMutation(4, 'YWJjZA=='), operation_id: operation, item_id: `${operation}-item`, path })
const large = (operation: string, path: string): CloudSyncUpsertMutation => {
const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1
return { ...upsertMutation(byteLength, Buffer.alloc(byteLength, 5).toString('base64')), operation_id: operation, item_id: `${operation}-item`, path }
}
const ack = (mutation: CloudSyncUpsertMutation, sequence: number) =>
({ operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 1, sequence })

it('names an inline mutation by its place in the whole batch, with the direct upload that went before', async () => {
const big = large('big', 'assets/photo.png')
const kept = small('kept', 'inbox/Note.md')
const refused = small('refused', '.zennotes/agent-edits/inbox/Note.md.json')
const body = { mutations: [big, kept, refused] }
const api = fakeApi({
initiateUpload: async () => uploadInstruction(big, 'https://objects.example.test/upload'),
completeUpload: async () => ({
data: { id: 'upload-1', operation_id: big.operation_id, status: 'completed' as const, result: { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 } }
}),
// The inline request carries kept and refused; Cloud names its own index 1.
mutate: async () => { throw validation({ 'mutations.1.path': [refusedText] }) }
})

const error = await mutateWithMobileDirectUploads(api, 'vault-1', body, async () => {}).catch((caught: unknown) => caught)

assert.ok(error instanceof CloudSyncPathRefusedError)
assert.deepEqual(error.refusal.indices, [2])
assert.deepEqual(error.refusal.applied, { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 })
assert.deepEqual(cloudSyncPathRefusal(error, body)?.indices, [2])
})

it('names a direct upload refused for its own path by its place in the batch, with what went before', async () => {
const kept = small('kept', 'inbox/Note.md')
const big = large('big', '.zennotes/agent-edits/assets/photo.png.json')
const body = { mutations: [kept, big] }
let uploaded = false
const api = fakeApi({
mutate: async () => ({ acknowledged: [ack(kept, 3)], conflicts: [], cursor: 3 }),
initiateUpload: async () => { throw validation({ path: [refusedText] }) }
})

const error = await mutateWithMobileDirectUploads(api, 'vault-1', body, async () => { uploaded = true })
.catch((caught: unknown) => caught)

assert.ok(error instanceof CloudSyncPathRefusedError)
assert.deepEqual(error.refusal.indices, [1])
assert.deepEqual(error.refusal.applied, { acknowledged: [ack(kept, 3)], conflicts: [], cursor: 3 })
assert.equal(uploaded, false)
})

it('stops as before when Cloud refuses anything but a path', async () => {
const big = large('big', 'assets/photo.png')
const other = small('other', 'inbox/Note.md')
const original = validation({ 'mutations.0.base_revision': ['The base revision is invalid.'] })
const api = fakeApi({
initiateUpload: async () => uploadInstruction(big, 'https://objects.example.test/upload'),
completeUpload: async () => ({
data: { id: 'upload-1', operation_id: big.operation_id, status: 'completed' as const, result: { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 } }
}),
mutate: async () => { throw original }
})

const error = await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [big, other] }, async () => {})
.catch((caught: unknown) => caught)

assert.equal(error, original)
})
})

describe('encrypted vaults', () => {
const SIGNED_URL = 'https://objects.example.test/upload?signature=signed'

Expand Down
Loading
Loading