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 @@ -11,8 +11,8 @@ archives are vendored under `vendor/zennotes/` with their source identity and
checksums (`manifest.json`), and `package-lock.json` pins the complete install.
A clean checkout installs them with `npm ci`, without a source clone or sibling
repository. 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).
[core-2.80.0-core.hc5a4589d040cd0da](https://github.com/ZenNotes/zennotes/releases/tag/core-2.80.0-core.hc5a4589d040cd0da)
(desktop commit `31520814`, clean tree).

`npm run boundaries:check` verifies the pins, installed versions, singleton
React/CodeMirror peers, and public export usage. `npm run core:adopt -- <core-tag>`
Expand Down
24 changes: 12 additions & 12 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -624,12 +624,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ZenWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.34.0;
MARKETING_VERSION = 1.35.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -753,12 +753,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = WYY7PK57DM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.34.0;
MARKETING_VERSION = 1.35.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -775,12 +775,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = WYY7PK57DM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.34.0;
MARKETING_VERSION = 1.35.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Expand All @@ -795,12 +795,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.34.0;
MARKETING_VERSION = 1.35.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand All @@ -817,12 +817,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ZenWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.34.0;
MARKETING_VERSION = 1.35.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -855,12 +855,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.34.0;
MARKETING_VERSION = 1.35.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down
30 changes: 15 additions & 15 deletions package-lock.json

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

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@
"@lezer/highlight": "^1.2.5",
"@replit/codemirror-vim": "^6.4.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
34 changes: 34 additions & 0 deletions src/bridge/cloud-sync-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { it } from 'node:test'
import type { CloudSyncMutationRequest } from '@zennotes/bridge-contract/cloud-sync'
import { generateCloudE2eeKey, toBase64 } from '@zennotes/shared-domain/cloud-e2ee'
import { CloudE2eeKeyring } from '@zennotes/shared-domain/cloud-e2ee-account'
import { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault'
Expand Down Expand Up @@ -186,3 +187,36 @@ 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 instead of stopping (#1004)', async () => {
const refused = 'This path is not eligible for vault sync.'
const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', {
'@capacitor/core': {
registerPlugin: () => ({}),
CapacitorHttp: {
request: async () => ({
status: 422,
data: {
error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' },
errors: { 'mutations.1.path': [refused] }
}
})
}
}
})
const { cloudSyncPathRefusal } = await import('@zennotes/shared-domain/cloud-sync-refusal')
const content = { encoding: 'base64' as const, data: 'YWJjZA==', sha256: 'a'.repeat(64), byte_length: 4, media_type: 'text/markdown' }
const body: CloudSyncMutationRequest = {
mutations: [
{ type: 'upsert', operation_id: 'first', item_id: 'item-1', base_revision: null, path: 'inbox/Note.md', kind: 'text', content },
{ type: 'upsert', operation_id: 'second', item_id: 'item-2', base_revision: null, path: '.zennotes/agent-edits/inbox/Note.md.json', kind: 'text', content }
]
}
const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'account' })
const error = await client.mutate('vault-1', body).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, body)?.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 @@ -119,7 +119,7 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: {
: `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 @@ -263,6 +263,16 @@ function unansweredUpload(error: unknown): boolean {
(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
}

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

Expand Down
75 changes: 75 additions & 0 deletions src/bridge/mobile-direct-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@zennotes/bridge-contract/cloud-sync'
import { generateCloudE2eeKey, zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee'
import { CloudE2eeLockedError, CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault'
import { CloudSyncPathRefusedError } from '@zennotes/shared-domain/cloud-sync-refusal'
import {
CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES,
MobileDirectUploadError,
Expand Down Expand Up @@ -141,6 +142,74 @@ describe('mutateWithMobileDirectUploads', () => {
assert.equal(uploads[0]?.headers.Authorization, undefined)
})

it('names a direct upload refused for its path by its place in the batch, with what went before (#1004)', async () => {
const refused = 'This path is not eligible for vault sync.'
const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1
const small = { ...upsertMutation(4, 'YWJjZA=='), operation_id: 'small', item_id: 'small-item', path: 'inbox/Note.md' }
const large = upsertMutation(byteLength, Buffer.alloc(byteLength, 17).toString('base64'))
const after = { ...small, operation_id: 'after', item_id: 'after-item', path: 'inbox/After.md' }
const smallAck = { operation_id: 'small', item_id: 'small-item', revision: 1, sequence: 7 }
const calls: string[] = []
const api = fakeApi({
mutate: async (_vaultId, body) => {
calls.push(`mutate ${body.mutations.map((mutation) => mutation.operation_id).join(',')}`)
return { acknowledged: [smallAck], conflicts: [], cursor: 7 }
},
initiateUpload: async () => {
calls.push('initiate')
throw serviceError(refused, 422, 'VALIDATION_FAILED', { errors: { path: [refused] } })
}
})

const error = await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [small, large, after] }, async () => {
throw new Error('Unexpected object upload')
}).catch((caught: unknown) => caught)

assert.ok(error instanceof CloudSyncPathRefusedError)
assert.deepEqual(error.refusal.indices, [1])
assert.deepEqual(error.refusal.applied, { acknowledged: [smallAck], conflicts: [], cursor: 7 })
assert.equal(error.refusal.detail, `HTTP 422 · VALIDATION_FAILED · ${refused}`)
// After.md was never sent; sync sends it again without the refused file.
assert.deepEqual(calls, ['mutate small', 'initiate'])
})

it('names an inline path refusal by its place in the whole batch, after a direct upload', async () => {
const refused = 'This path is not eligible for vault sync.'
const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1
const large = upsertMutation(byteLength, Buffer.alloc(byteLength, 3).toString('base64'))
const first = { ...upsertMutation(4, 'YWJjZA=='), operation_id: 'first', item_id: 'first-item', path: 'inbox/A.md' }
const second = { ...first, operation_id: 'second', item_id: 'second-item', path: '.zennotes/agent-edits/inbox/A.md.json' }
const largeAck = { operation_id: large.operation_id, item_id: large.item_id, revision: 2, sequence: 4 }
const api = fakeApi({
initiateUpload: async () => uploadInstruction(large, 'https://objects.example.test/upload'),
completeUpload: async () => ({
data: { id: 'upload-1', operation_id: large.operation_id, status: 'completed', result: { acknowledged: [largeAck], conflicts: [], cursor: 4 } }
}),
// The inline request holds the batch's mutations 1 and 2, so Cloud names
// its own positions; the refusal must name the batch's.
mutate: async () => {
throw serviceError(refused, 422, 'VALIDATION_FAILED', { errors: { 'mutations.1.path': [refused] } })
}
})

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

assert.ok(error instanceof CloudSyncPathRefusedError)
assert.deepEqual(error.refusal.indices, [2])
assert.deepEqual(error.refusal.applied, { acknowledged: [largeAck], conflicts: [], cursor: 4 })
})

it('still stops on a refusal that is not about a path', async () => {
const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1
const large = upsertMutation(byteLength, Buffer.alloc(byteLength, 5).toString('base64'))
const failure = serviceError('Invalid.', 422, 'VALIDATION_FAILED', { errors: { sha256: ['Bad hash.'] } })
const api = fakeApi({ initiateUpload: async () => { throw failure } })
const error = await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [large] }, async () => {})
.catch((caught: unknown) => caught)
assert.equal(error, failure)
})

it('aborts the reservation when object storage rejects the upload', async () => {
const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1
const mutation = upsertMutation(byteLength, Buffer.alloc(byteLength, 11).toString('base64'))
Expand Down Expand Up @@ -186,6 +255,12 @@ describe('mutateWithMobileDirectUploads', () => {
})
})

/** What cloud-sync-client's transport throws for an answer with an error
* status (CloudServiceRequestError, whose module needs Capacitor). */
function serviceError(message: string, status: number, code: string, details: Record<string, unknown>): Error {
return Object.assign(new Error(message), { name: 'CloudServiceRequestError', status, code, details })
}

function fakeApi(overrides: Partial<MobileDirectUploadApi>): MobileDirectUploadApi {
return {
mutate: async () => emptyResponse(),
Expand Down
Loading
Loading