@absolutejs/sync-capacitor 0.6.1 → 0.8.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 CHANGED
@@ -14,13 +14,16 @@ import { createSyncClient } from '@absolutejs/sync/client';
14
14
  import { lifecycle, network } from '@absolutejs/devices';
15
15
  import {
16
16
  createCapacitorSyncLocalStore,
17
+ createCapacitorSyncProtection,
17
18
  installCapacitorSyncLifecycle
18
19
  } from '@absolutejs/sync-capacitor';
19
20
 
20
21
  const client = createSyncClient({
21
22
  url: 'wss://app.example.com/sync/ws',
22
23
  durable: {
23
- store: createCapacitorSyncLocalStore(),
24
+ store: createCapacitorSyncLocalStore({
25
+ protection: createCapacitorSyncProtection()
26
+ }),
24
27
  namespace: authenticatedPrincipalNamespace
25
28
  }
26
29
  });
@@ -38,6 +41,14 @@ failures obey the client's delivery ceiling; conflicts and permanent failures
38
41
  remain in the principal's dead-letter partition for explicit remediation rather
39
42
  than replaying forever.
40
43
 
44
+ Generated mutation conflict policies are captured inside each encrypted outbox
45
+ record. The TypeScript foreground/headless runner and the Android WorkManager or
46
+ iOS BGProcessingTask runner therefore make the same bounded decision: manual
47
+ conflicts become dead letters, `server-wins` discards the rejected local intent,
48
+ and `client-wins` retries the unchanged operation ID up to its declared ceiling.
49
+ An explicit argument-changing rebase is left to the foreground remediation API
50
+ and creates a new traceable intent.
51
+
41
52
  The namespace must come from a verified Auth principal, never from an untrusted
42
53
  route or form value. AbsoluteJS derives an opaque namespace from the verified
43
54
  issuer, public client ID, and subject. Signing out locks that partition by
@@ -86,6 +97,22 @@ deterministic component bundle. SQLite tracks each component independently,
86
97
  keeps removed-pack ledgers as orphan diagnostics, and migrates all records and
87
98
  ledger updates in one transaction.
88
99
 
100
+ ## Protected local data and quotas
101
+
102
+ AbsoluteJS automatically installs `createCapacitorSyncProtection()`. It creates
103
+ one random AES-256-GCM data key, seals that key in the existing iOS Keychain or
104
+ Android Keystore vault, and writes only authenticated ciphertext envelopes to
105
+ SQLite. Namespace, record kind, and collection/mutation name are authenticated
106
+ as associated data. The foreground adapter and both finite native workers use
107
+ the same versioned format; a missing key, changed identity, or modified record
108
+ fails closed. Direct Capacitor integrations opt in as shown above.
109
+
110
+ The generated `localData` policy also enforces memory-only records,
111
+ whole-projection expiry, deterministic eviction priority, and a logical
112
+ per-principal byte ceiling. Pending mutations are never evicted. When protected
113
+ data declares a memory-only fallback, browsers without an audited key provider
114
+ remain usable without writing that data to disk.
115
+
89
116
  ## Managed native background Sync
90
117
 
91
118
  AbsoluteJS also configures `AbsoluteBackgroundSync` when the application uses
@@ -130,9 +157,10 @@ generated regions. Android scheduling is registered by the plugin.
130
157
 
131
158
  ## Platform notes
132
159
 
133
- - Native Android and iOS use SQLCipher through
134
- `@capacitor-community/sqlite`, including for unencrypted databases. Review the
135
- plugin's encryption-export compliance notice before shipping.
160
+ - AbsoluteJS protects record payloads with AES-256-GCM rather than claiming the
161
+ default SQLite connection is SQLCipher-encrypted. SQLite keys, namespaces,
162
+ and ordering columns remain metadata; sensitive row and mutation payloads are
163
+ ciphertext. Review platform encryption-export requirements before shipping.
136
164
  - Web/PWA builds should use `createIndexedDbSyncLocalStore` from
137
165
  `@absolutejs/sync/client`; they do not need the plugin's WASM/web component.
138
166
  - The adapter serializes transactions so an app cannot overlap two explicit
@@ -16,6 +16,12 @@ import java.nio.charset.StandardCharsets;
16
16
  import java.util.HashMap;
17
17
  import java.util.LinkedHashMap;
18
18
  import java.util.Map;
19
+ import java.util.Arrays;
20
+ import java.security.SecureRandom;
21
+ import javax.crypto.Cipher;
22
+ import javax.crypto.spec.GCMParameterSpec;
23
+ import javax.crypto.spec.SecretKeySpec;
24
+ import android.util.Base64;
19
25
  import js.absolute.devices.AbsoluteSecureStorageVault;
20
26
  import org.json.JSONArray;
21
27
  import org.json.JSONObject;
@@ -23,6 +29,7 @@ import org.json.JSONObject;
23
29
  final class AbsoluteBackgroundSyncEngine {
24
30
  static final class NoSession extends Exception {}
25
31
  private static final int MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
32
+ private static final String DATA_KEY="absolutejs.sync.data-key.v1";
26
33
  private static final String RUNNING="running", LAST_RUN="lastRunAt", LAST_ERROR="lastError", LAST_ACK="lastAcknowledged", LAST_PULL="lastPulled";
27
34
  private static final class Mutation { final String id; final int attempts; Mutation(String i,int a){id=i;attempts=a;} }
28
35
  private static final class Batch { final JSONObject request=new JSONObject(); final Map<String,Mutation> mutations=new LinkedHashMap<>(); final Map<String,JSONObject> pulls=new LinkedHashMap<>(); }
@@ -38,7 +45,7 @@ final class AbsoluteBackgroundSyncEngine {
38
45
  AbsoluteBackgroundSyncConfig.preferences(context).edit().putBoolean(RUNNING,true).remove(LAST_ERROR).apply();
39
46
  try{
40
47
  String access=refresh(context,config); SQLiteDatabase db=SQLiteDatabase.openDatabase(context.getDatabasePath(config.databaseName+"SQLite.db").getPath(),null,SQLiteDatabase.OPEN_READWRITE);
41
- try{db.execSQL("PRAGMA busy_timeout=5000"); Batch batch=prepare(db,config); JSONObject response=request(config.endpoint,"POST",batch.request.toString().getBytes(StandardCharsets.UTF_8),"application/json","Bearer "+access,config.issuer); int[] counts=settle(db,config,batch,response);
48
+ try{db.execSQL("PRAGMA busy_timeout=5000"); Batch batch=prepare(context,db,config); JSONObject response=request(config.endpoint,"POST",batch.request.toString().getBytes(StandardCharsets.UTF_8),"application/json","Bearer "+access,config.issuer); int[] counts=settle(context,db,config,batch,response);
42
49
  AbsoluteBackgroundSyncConfig.preferences(context).edit().putBoolean(RUNNING,false).putLong(LAST_RUN,System.currentTimeMillis()).putInt(LAST_ACK,counts[0]).putInt(LAST_PULL,counts[1]).remove(LAST_ERROR).apply();
43
50
  }finally{db.close();}
44
51
  }catch(NoSession e){AbsoluteBackgroundSyncConfig.preferences(context).edit().putBoolean(RUNNING,false).remove(LAST_ERROR).apply();throw e;}
@@ -56,24 +63,24 @@ final class AbsoluteBackgroundSyncEngine {
56
63
  }
57
64
  private static String form(String n,String v)throws Exception{return URLEncoder.encode(n,"UTF-8")+"="+URLEncoder.encode(v,"UTF-8");}
58
65
 
59
- private static Batch prepare(SQLiteDatabase db,AbsoluteBackgroundSyncConfig config)throws Exception{
66
+ private static Batch prepare(Context context,SQLiteDatabase db,AbsoluteBackgroundSyncConfig config)throws Exception{
60
67
  Batch b=new Batch(); JSONArray mutations=new JSONArray(),pulls=new JSONArray(); long now=System.currentTimeMillis(); db.beginTransaction();
61
68
  try{
62
69
  try(Cursor c=db.rawQuery("SELECT operation_id,record_json FROM absolute_sync_mutations WHERE namespace=? ORDER BY created_at,operation_id",new String[]{config.namespace})){
63
- while(c.moveToNext()&&mutations.length()<config.maxMutations){String id=c.getString(0);JSONObject r=new JSONObject(c.getString(1));if("dead-letter".equals(r.optString("state"))||r.optLong("nextAttemptAt",0)>now)continue;int attempts=r.optInt("attempts",0)+1;r.put("attempts",attempts).remove("nextAttemptAt");updateMutation(db,config.namespace,id,r);JSONObject m=new JSONObject().put("operationId",id).put("name",r.getString("name"));if(r.has("args"))m.put("args",r.get("args"));mutations.put(m);b.mutations.put(id,new Mutation(id,attempts));}
70
+ while(c.moveToNext()&&mutations.length()<config.maxMutations){String id=c.getString(0);JSONObject r=decodeRecord(context,c.getString(1),"mutation",config.namespace);if("dead-letter".equals(r.optString("state"))||r.optLong("nextAttemptAt",0)>now)continue;int attempts=r.optInt("attempts",0)+1;r.put("attempts",attempts).remove("nextAttemptAt");updateMutation(context,db,config.namespace,id,r);JSONObject m=new JSONObject().put("operationId",id).put("name",r.getString("name"));if(r.has("args"))m.put("args",r.get("args"));mutations.put(m);b.mutations.put(id,new Mutation(id,attempts));}
64
71
  }
65
72
  try(Cursor c=db.rawQuery("SELECT collection_key,record_json FROM absolute_sync_collections WHERE namespace=? ORDER BY collection_key",new String[]{config.namespace})){
66
- while(c.moveToNext()&&pulls.length()<config.maxPulls){String key=c.getString(0);JSONObject r=new JSONObject(c.getString(1));String collection=r.optString("collection");if(collection.isEmpty())continue;JSONObject p=new JSONObject().put("id",key).put("collection",collection);if(r.has("params"))p.put("params",r.get("params"));if("id".equals(r.optString("headlessKey"))){if(r.has("cursor"))p.put("since",r.get("cursor"));else if(r.optInt("version",0)>0)p.put("since",r.getInt("version"));}pulls.put(p);b.pulls.put(key,r);}
73
+ while(c.moveToNext()&&pulls.length()<config.maxPulls){String key=c.getString(0);JSONObject r=decodeRecord(context,c.getString(1),"collection",config.namespace);String collection=r.optString("collection");if(collection.isEmpty())continue;JSONObject p=new JSONObject().put("id",key).put("collection",collection);if(r.has("params"))p.put("params",r.get("params"));if("id".equals(r.optString("headlessKey"))){if(r.has("cursor"))p.put("since",r.get("cursor"));else if(r.optInt("version",0)>0)p.put("since",r.getInt("version"));}pulls.put(p);b.pulls.put(key,r);}
67
74
  }
68
75
  db.setTransactionSuccessful();
69
76
  }finally{db.endTransaction();}
70
77
  b.request.put("version",1).put("mutations",mutations).put("pulls",pulls);return b;
71
78
  }
72
- private static int[] settle(SQLiteDatabase db,AbsoluteBackgroundSyncConfig config,Batch b,JSONObject response)throws Exception{
79
+ private static int[] settle(Context context,SQLiteDatabase db,AbsoluteBackgroundSyncConfig config,Batch b,JSONObject response)throws Exception{
73
80
  if(response.getInt("version")!=1)throw new IllegalStateException("Unsupported Headless Sync response.");Map<String,JSONObject> mutationResults=index(response.getJSONArray("mutations"),"operationId"),pullResults=index(response.getJSONArray("pulls"),"id");int ack=0,pulled=0;db.beginTransaction();
74
81
  try{
75
- for(Mutation sent:b.mutations.values()){JSONObject current=record(db,"absolute_sync_mutations","operation_id",config.namespace,sent.id);if(current==null||current.optInt("attempts",-1)!=sent.attempts)continue;JSONObject outcome=mutationResults.get(sent.id);if(outcome!=null&&"ack".equals(outcome.optString("status"))){db.delete("absolute_sync_mutations","namespace=? AND operation_id=?",new String[]{config.namespace,sent.id});ack++;continue;}JSONObject rejection=outcome==null?new JSONObject().put("kind","retryable").put("message","Missing mutation response."):outcome.getJSONObject("rejection");current.put("lastError",rejection.optString("message","Mutation rejected.")).put("rejection",rejection);if(!"retryable".equals(rejection.optString("kind"))||sent.attempts>=config.maxAttempts)current.put("state","dead-letter").put("deadLetteredAt",System.currentTimeMillis()).remove("nextAttemptAt");else current.put("state","pending").put("nextAttemptAt",System.currentTimeMillis()+Math.max(0,rejection.optLong("retryAfterMs",Math.min(30000,500L<<Math.min(20,sent.attempts-1)))));updateMutation(db,config.namespace,sent.id,current);}
76
- for(Map.Entry<String,JSONObject> requested:b.pulls.entrySet()){JSONObject result=pullResults.get(requested.getKey());if(result==null||"error".equals(result.optString("type")))continue;JSONObject current=record(db,"absolute_sync_collections","collection_key",config.namespace,requested.getKey());if(!same(current,requested.getValue()))continue;ContentValues values=new ContentValues();values.put("record_json",applyPull(current,result).toString());db.update("absolute_sync_collections",values,"namespace=? AND collection_key=?",new String[]{config.namespace,requested.getKey()});pulled++;}
82
+ for(Mutation sent:b.mutations.values()){JSONObject current=record(context,db,"absolute_sync_mutations","operation_id","mutation",config.namespace,sent.id);if(current==null||current.optInt("attempts",-1)!=sent.attempts)continue;JSONObject outcome=mutationResults.get(sent.id);if(outcome!=null&&"ack".equals(outcome.optString("status"))){db.delete("absolute_sync_mutations","namespace=? AND operation_id=?",new String[]{config.namespace,sent.id});ack++;continue;}JSONObject rejection=outcome==null?new JSONObject().put("kind","retryable").put("message","Missing mutation response."):outcome.getJSONObject("rejection");current.put("lastError",rejection.optString("message","Mutation rejected.")).put("rejection",rejection);JSONObject conflictPolicy=current.optJSONObject("conflictPolicy");String conflictStrategy=conflictPolicy==null?"manual":conflictPolicy.optString("strategy","manual");if("conflict".equals(rejection.optString("kind"))&&"server-wins".equals(conflictStrategy)){db.delete("absolute_sync_mutations","namespace=? AND operation_id=?",new String[]{config.namespace,sent.id});continue;}if("conflict".equals(rejection.optString("kind"))&&"client-wins".equals(conflictStrategy)&&current.optInt("conflictAttempts",0)<conflictPolicy.optInt("maxAttempts",1)){current.put("conflictAttempts",current.optInt("conflictAttempts",0)+1).put("state","pending").remove("nextAttemptAt");updateMutation(context,db,config.namespace,sent.id,current);continue;}if(!"retryable".equals(rejection.optString("kind"))||sent.attempts>=config.maxAttempts)current.put("state","dead-letter").put("deadLetteredAt",System.currentTimeMillis()).remove("nextAttemptAt");else current.put("state","pending").put("nextAttemptAt",System.currentTimeMillis()+Math.max(0,rejection.optLong("retryAfterMs",Math.min(30000,500L<<Math.min(20,sent.attempts-1)))));updateMutation(context,db,config.namespace,sent.id,current);}
83
+ for(Map.Entry<String,JSONObject> requested:b.pulls.entrySet()){JSONObject result=pullResults.get(requested.getKey());if(result==null||"error".equals(result.optString("type")))continue;JSONObject current=record(context,db,"absolute_sync_collections","collection_key","collection",config.namespace,requested.getKey());if(!same(current,requested.getValue()))continue;ContentValues values=new ContentValues();JSONObject next=applyPull(current,result);values.put("record_json",encodeRecord(context,next,"collection",config.namespace,next.optString("collection",requested.getKey())));db.update("absolute_sync_collections",values,"namespace=? AND collection_key=?",new String[]{config.namespace,requested.getKey()});pulled++;}
77
84
  db.setTransactionSuccessful();
78
85
  }finally{db.endTransaction();}return new int[]{ack,pulled};
79
86
  }
@@ -83,8 +90,12 @@ final class AbsoluteBackgroundSyncEngine {
83
90
  private static void remove(Map<String,JSONObject> rows,JSONArray values)throws Exception{for(int i=0;i<values.length();i++)rows.remove(key(values.getJSONObject(i)));}
84
91
  private static boolean same(JSONObject a,JSONObject b){return a!=null&&a.optInt("version")==b.optInt("version")&&a.optString("cursor").equals(b.optString("cursor"));}
85
92
  private static Map<String,JSONObject> index(JSONArray values,String field)throws Exception{Map<String,JSONObject> result=new HashMap<>();for(int i=0;i<values.length();i++){JSONObject value=values.getJSONObject(i);result.put(value.getString(field),value);}return result;}
86
- private static JSONObject record(SQLiteDatabase db,String table,String field,String namespace,String id)throws Exception{try(Cursor c=db.rawQuery("SELECT record_json FROM "+table+" WHERE namespace=? AND "+field+"=? LIMIT 1",new String[]{namespace,id})){return c.moveToFirst()?new JSONObject(c.getString(0)):null;}}
87
- private static void updateMutation(SQLiteDatabase db,String namespace,String id,JSONObject record){ContentValues values=new ContentValues();values.put("record_json",record.toString());db.update("absolute_sync_mutations",values,"namespace=? AND operation_id=?",new String[]{namespace,id});}
93
+ private static JSONObject record(Context context,SQLiteDatabase db,String table,String field,String kind,String namespace,String id)throws Exception{try(Cursor c=db.rawQuery("SELECT record_json FROM "+table+" WHERE namespace=? AND "+field+"=? LIMIT 1",new String[]{namespace,id})){return c.moveToFirst()?decodeRecord(context,c.getString(0),kind,namespace):null;}}
94
+ private static void updateMutation(Context context,SQLiteDatabase db,String namespace,String id,JSONObject record)throws Exception{ContentValues values=new ContentValues();values.put("record_json",encodeRecord(context,record,"mutation",namespace,record.getString("name")));db.update("absolute_sync_mutations",values,"namespace=? AND operation_id=?",new String[]{namespace,id});}
95
+ private static byte[] dataKey(Context context)throws Exception{String value=AbsoluteSecureStorageVault.get(context,DATA_KEY);if(value==null)return null;byte[] key=Base64.decode(value,Base64.NO_WRAP);if(key.length!=32)throw new IllegalStateException("Protected Sync data key is invalid.");return key;}
96
+ private static String aad(String kind,String namespace,String name){return "absolute-sync-v1\u0000"+kind+"\u0000"+namespace+"\u0000"+name;}
97
+ private static JSONObject decodeRecord(Context context,String stored,String kind,String namespace)throws Exception{JSONObject outer=new JSONObject(stored);if(!outer.has("__absoluteSyncProtected"))return outer;JSONObject envelope=outer.getJSONObject("__absoluteSyncProtected");if(!"aes-256-gcm-v1".equals(envelope.getString("protector")))throw new IllegalStateException("Unsupported Sync data protector.");byte[] key=dataKey(context);if(key==null)throw new IllegalStateException("Protected Sync data key is unavailable.");byte[] packed=Base64.decode(envelope.getString("value"),Base64.NO_WRAP),nonce=Arrays.copyOfRange(packed,0,12),ciphertext=Arrays.copyOfRange(packed,12,packed.length);Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding");cipher.init(Cipher.DECRYPT_MODE,new SecretKeySpec(key,"AES"),new GCMParameterSpec(128,nonce));cipher.updateAAD(aad(kind,namespace,envelope.getString("name")).getBytes(StandardCharsets.UTF_8));return new JSONObject(new String(cipher.doFinal(ciphertext),StandardCharsets.UTF_8));}
98
+ private static String encodeRecord(Context context,JSONObject record,String kind,String namespace,String name)throws Exception{byte[] key=dataKey(context);if(key==null)return record.toString();byte[] nonce=new byte[12];new SecureRandom().nextBytes(nonce);Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding");cipher.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(key,"AES"),new GCMParameterSpec(128,nonce));cipher.updateAAD(aad(kind,namespace,name).getBytes(StandardCharsets.UTF_8));byte[] ciphertext=cipher.doFinal(record.toString().getBytes(StandardCharsets.UTF_8)),packed=new byte[nonce.length+ciphertext.length];System.arraycopy(nonce,0,packed,0,nonce.length);System.arraycopy(ciphertext,0,packed,nonce.length,ciphertext.length);return new JSONObject().put("__absoluteSyncProtected",new JSONObject().put("name",name).put("protector","aes-256-gcm-v1").put("value",Base64.encodeToString(packed,Base64.NO_WRAP))).toString();}
88
99
 
89
100
  private static JSONObject request(String address,String method,byte[] body,String type,String auth,String trustedOrigin)throws Exception{return request(address,method,body,type,auth,trustedOrigin,false);}
90
101
  private static JSONObject request(String address,String method,byte[] body,String type,String auth,String trustedOrigin,boolean advertisedTokenEndpoint)throws Exception{
package/dist/index.d.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { type SQLiteDBConnection } from '@capacitor-community/sqlite';
2
- import type { SyncLocalStore, SyncLocalStoreSchemaInput } from '@absolutejs/sync/client';
2
+ import type { SyncLocalStore, SyncLocalStoreSchemaInput, SyncLocalProtectionProvider } from '@absolutejs/sync/client';
3
3
  import type { DeviceLifecycleCapability, DeviceNetworkCapability, DeviceSubscription } from '@absolutejs/devices';
4
4
  import type { SyncClient } from '@absolutejs/sync/client';
5
+ import type { DeviceSecureStorageCapability } from '@absolutejs/devices';
6
+ export type CapacitorSyncProtectionOptions = {
7
+ secureStorage?: DeviceSecureStorageCapability;
8
+ };
9
+ /** AES-256-GCM record protection whose key is sealed by Keychain/Keystore. */
10
+ export declare const createCapacitorSyncProtection: (options?: CapacitorSyncProtectionOptions) => SyncLocalProtectionProvider;
5
11
  export type CapacitorBackgroundSyncConfig = {
6
12
  endpoint: string;
7
13
  issuer: string;
@@ -39,13 +45,15 @@ export type CapacitorSyncLocalStoreOptions = {
39
45
  connection?: CapacitorSyncSqliteFactory;
40
46
  /** Same generated logical migration plan used by web IndexedDB. */
41
47
  storageSchema?: SyncLocalStoreSchemaInput;
48
+ protection?: SyncLocalProtectionProvider;
49
+ now?: () => number;
42
50
  };
43
51
  /**
44
52
  * Native SQLite implementation of Sync's principal-partitioned atomic cache
45
53
  * and mutation outbox. All calls are serialized because a Capacitor database
46
54
  * connection owns one explicit transaction at a time.
47
55
  */
48
- export declare const createCapacitorSyncLocalStore: ({ databaseName, connection: createConnection, storageSchema }?: CapacitorSyncLocalStoreOptions) => SyncLocalStore;
56
+ export declare const createCapacitorSyncLocalStore: ({ databaseName, connection: createConnection, storageSchema, protection, now }?: CapacitorSyncLocalStoreOptions) => SyncLocalStore;
49
57
  export type CapacitorSyncLifecycleOptions = {
50
58
  client: Pick<SyncClient, 'reconnect'> & Partial<Pick<SyncClient, 'flush'>>;
51
59
  lifecycle: DeviceLifecycleCapability;