@absolutejs/sync-capacitor 0.6.0 → 0.7.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
  });
@@ -86,6 +89,22 @@ deterministic component bundle. SQLite tracks each component independently,
86
89
  keeps removed-pack ledgers as orphan diagnostics, and migrates all records and
87
90
  ledger updates in one transaction.
88
91
 
92
+ ## Protected local data and quotas
93
+
94
+ AbsoluteJS automatically installs `createCapacitorSyncProtection()`. It creates
95
+ one random AES-256-GCM data key, seals that key in the existing iOS Keychain or
96
+ Android Keystore vault, and writes only authenticated ciphertext envelopes to
97
+ SQLite. Namespace, record kind, and collection/mutation name are authenticated
98
+ as associated data. The foreground adapter and both finite native workers use
99
+ the same versioned format; a missing key, changed identity, or modified record
100
+ fails closed. Direct Capacitor integrations opt in as shown above.
101
+
102
+ The generated `localData` policy also enforces memory-only records,
103
+ whole-projection expiry, deterministic eviction priority, and a logical
104
+ per-principal byte ceiling. Pending mutations are never evicted. When protected
105
+ data declares a memory-only fallback, browsers without an audited key provider
106
+ remain usable without writing that data to disk.
107
+
89
108
  ## Managed native background Sync
90
109
 
91
110
  AbsoluteJS also configures `AbsoluteBackgroundSync` when the application uses
@@ -130,9 +149,10 @@ generated regions. Android scheduling is registered by the plugin.
130
149
 
131
150
  ## Platform notes
132
151
 
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.
152
+ - AbsoluteJS protects record payloads with AES-256-GCM rather than claiming the
153
+ default SQLite connection is SQLCipher-encrypted. SQLite keys, namespaces,
154
+ and ordering columns remain metadata; sensitive row and mutation payloads are
155
+ ciphertext. Review platform encryption-export requirements before shipping.
136
156
  - Web/PWA builds should use `createIndexedDbSyncLocalStore` from
137
157
  `@absolutejs/sync/client`; they do not need the plugin's WASM/web component.
138
158
  - 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);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;