socialcli 0.3.0__py3-none-any.whl

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.

Potentially problematic release.


This version of socialcli might be problematic. Click here for more details.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
@@ -0,0 +1,229 @@
1
+ """Prepare an isolated restore; never write over a running or existing database.
2
+
3
+ Manifest provenance is an explicit operator trust boundary. The operator must
4
+ export from the still-current authoritative service, never regenerate timestamps
5
+ from a backup. JSON labels alone cannot establish that provenance. No command
6
+ contacts a provider or the identity service. Identity cleanup is separately applied
7
+ in the isolated PostgreSQL restore and explicitly attested before release.
8
+ """
9
+ from __future__ import annotations
10
+ import argparse
11
+ from contextlib import closing
12
+ import json
13
+ import math
14
+ import os
15
+ from pathlib import Path
16
+ import sqlite3
17
+ import tempfile
18
+ import time
19
+
20
+ from socialctl.saas.backup import snapshot, verify_snapshot, BackupError, _read
21
+ from socialctl.connection_service.store import Store
22
+ from socialctl.saas.store import SaaSStore
23
+
24
+
25
+ class RestoreError(ValueError):
26
+ """Fixed operator diagnostics without credential or identity data."""
27
+
28
+
29
+ def checked_manifest(manifest, now):
30
+ try:
31
+ if not isinstance(manifest,dict) or manifest['schema']!=1 or manifest['source']!='current-authority':
32
+ raise ValueError()
33
+ generated=manifest['generated_at']
34
+ if type(generated) not in (int,float) or not math.isfinite(generated) or not now-300<=generated<=now+60:
35
+ raise ValueError()
36
+ tombstones=manifest['tombstones'];pending=manifest['pending_revocation_ids']
37
+ if not isinstance(tombstones,list) or not isinstance(pending,list):raise ValueError()
38
+ if len(tombstones)>1000000 or len(pending)>1000000:raise ValueError()
39
+ for row in tombstones:
40
+ if (not isinstance(row,dict) or row['kind'] not in {'user','grant'}
41
+ or not isinstance(row['id'],str) or not 1<=len(row['id'])<=512
42
+ or any(ord(c)<32 for c in row['id'])
43
+ or type(row['requested']) not in (int,float) or not math.isfinite(row['requested'])
44
+ or row['requested']>generated+60):raise ValueError()
45
+ if any(not isinstance(value,str) or not 1<=len(value)<=512 for value in pending):raise ValueError()
46
+ return manifest
47
+ except (KeyError,TypeError,ValueError):
48
+ raise RestoreError('A fresh manifest from the current authority is required; backup-derived or unavailable authority cannot release a restore.') from None
49
+
50
+
51
+ def export_manifest(database, *, now):
52
+ """Read-only; caller attests this path is the current authority, not a backup."""
53
+ try:
54
+ with closing(_read(database)) as db:
55
+ db.row_factory=sqlite3.Row
56
+ db.execute('BEGIN')
57
+ if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='saas_restore_guard'").fetchone():
58
+ guard=db.execute('SELECT state FROM saas_restore_guard WHERE singleton=1').fetchone()
59
+ if not guard or guard['state']!='ready':raise RestoreError('A quarantined restore is not an authority.')
60
+ tombstones=[dict(row) for row in db.execute('SELECT kind,id,requested FROM saas_deletion_tombstones ORDER BY kind,id')]
61
+ pending=[row[0] for row in db.execute("SELECT id FROM saas_google_revocations WHERE state='pending' ORDER BY id")]
62
+ return {'schema':1,'source':'current-authority','generated_at':now,'tombstones':tombstones,'pending_revocation_ids':pending}
63
+ except sqlite3.Error:
64
+ raise RestoreError('Current deletion authority is unavailable; keep the restore quarantined.') from None
65
+
66
+
67
+ def _wipe_access(db, *, quarantine):
68
+ kinds=['saas-session','saas-login','saas-pair','saas-installation','saas-capability',
69
+ 'saas-candidate','authorization','connection',
70
+ 'saas-meta-webhook-binding','saas-meta-webhook-event']
71
+ if quarantine:kinds+=['saas-grant','google-revocation']
72
+ db.execute('DELETE FROM records WHERE kind IN ('+','.join('?' for _ in kinds)+')',kinds)
73
+ db.execute('UPDATE saas_users SET session_version=session_version+1')
74
+ if quarantine:
75
+ db.execute('DELETE FROM saas_slots')
76
+ db.execute('DELETE FROM saas_google_revocations')
77
+ if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='legacy_allowance'").fetchone():
78
+ db.execute('DELETE FROM legacy_allowance')
79
+
80
+
81
+ def _apply(vault,db,manifest):
82
+ _wipe_access(db,quarantine=manifest is None)
83
+ if manifest is None:return
84
+ pending=set(manifest['pending_revocation_ids'])
85
+ for row in db.execute('SELECT id FROM saas_google_revocations').fetchall():
86
+ if row['id'] not in pending:
87
+ vault.delete(db,'google-revocation',row['id'])
88
+ db.execute('DELETE FROM saas_google_revocations WHERE id=?',(row['id'],))
89
+ else:
90
+ db.execute("UPDATE saas_google_revocations SET state='pending',lease=NULL,lease_until=0,next_attempt=0 WHERE id=?",(row['id'],))
91
+ tables={row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
92
+ for item in manifest['tombstones']:
93
+ kind,record_id=item['kind'],item['id']
94
+ if kind=='grant':
95
+ vault.delete(db,'saas-grant',record_id)
96
+ db.execute('DELETE FROM saas_slots WHERE grant_id=?',(record_id,))
97
+ else:
98
+ billing=db.execute('SELECT customer_id FROM saas_billing WHERE user_id=?',(record_id,)).fetchone()
99
+ if billing and billing['customer_id']:
100
+ for row in db.execute('SELECT account_id,mode,event_id FROM saas_billing_events WHERE customer_id=?',(billing['customer_id'],)).fetchall():
101
+ vault.delete(db,'saas-stripe-event',':'.join(row))
102
+ db.execute('DELETE FROM saas_billing_events WHERE customer_id=?',(billing['customer_id'],))
103
+ db.execute('DELETE FROM records WHERE (kind,id) IN (SELECT kind,id FROM saas_record_owners WHERE user_id=?)',(record_id,))
104
+ vault.delete(db,'saas-user',record_id)
105
+ for table in ('saas_users','saas_entitlements','saas_billing','saas_slots','saas_brands','saas_reminders','saas_deletions','saas_media_allocations'):
106
+ if table in tables:
107
+ db.execute('DELETE FROM '+table+' WHERE '+('id' if table=='saas_users' else 'user_id')+'=?',(record_id,))
108
+ db.execute('INSERT OR REPLACE INTO saas_deletion_tombstones VALUES(?,?,?)',(kind,record_id,item['requested']))
109
+ db.execute('DELETE FROM saas_brands WHERE NOT EXISTS (SELECT 1 FROM saas_slots WHERE saas_slots.user_id=saas_brands.user_id)')
110
+
111
+
112
+ def prepare_restore(source,destination,keys,manifest,*,now,snapshot_created):
113
+ if type(snapshot_created) not in (int,float) or not math.isfinite(snapshot_created) or not now-6*86400<=snapshot_created<=now+60:
114
+ raise RestoreError('Only an independently dated snapshot no older than six days can enter this restore workflow.')
115
+ if manifest is not None:checked_manifest(manifest,now)
116
+ source,destination=Path(source),Path(destination)
117
+ if destination.exists() or destination.is_symlink() or source.resolve()==destination.resolve():
118
+ raise RestoreError('Restore destination must be new and isolated; existing databases are never overwritten.')
119
+ temporary=None
120
+ try:
121
+ destination.parent.mkdir(parents=True,exist_ok=True,mode=0o700)
122
+ if destination.parent.stat().st_mode & 0o077:raise RestoreError('Use a private restore directory with mode 0700.')
123
+ fd,name=tempfile.mkstemp(prefix='.restore-',dir=destination.parent);os.close(fd)
124
+ temporary=Path(name);temporary.unlink()
125
+ snapshot(source,temporary,keys)
126
+ # Remove the unprepared marker only inside the unpublished offline copy;
127
+ # re-establish a blocked marker atomically with deletion suppression.
128
+ with sqlite3.connect(temporary) as db:db.execute('DROP TABLE IF EXISTS saas_restore_guard')
129
+ vault=Store(temporary,keys);SaaSStore(vault,keys)
130
+ state='identity_cleanup_required' if manifest is not None else 'authority_unavailable'
131
+ with vault.transaction() as db:
132
+ _apply(vault,db,manifest)
133
+ db.execute('CREATE TABLE saas_restore_guard (singleton INTEGER PRIMARY KEY CHECK(singleton=1),state TEXT NOT NULL,prepared REAL NOT NULL,snapshot_created REAL NOT NULL)')
134
+ db.execute('INSERT INTO saas_restore_guard VALUES(1,?,?,?)',(state,now,snapshot_created))
135
+ with sqlite3.connect(temporary) as db:
136
+ db.execute('PRAGMA wal_checkpoint(TRUNCATE)')
137
+ db.execute('PRAGMA journal_mode=DELETE')
138
+ verify_snapshot(temporary,keys)
139
+ with temporary.open('rb') as stream:os.fsync(stream.fileno())
140
+ os.link(temporary,destination)
141
+ return {'status':state,'identity_cleanup_verified':False,'access_credentials_removed':True}
142
+ except (OSError,sqlite3.Error,BackupError):
143
+ raise RestoreError('Restore preparation failed; source preserved and no usable restore released.') from None
144
+ finally:
145
+ if temporary:
146
+ temporary.unlink(missing_ok=True)
147
+ for suffix in ('-wal','-shm','-journal'):Path(str(temporary)+suffix).unlink(missing_ok=True)
148
+
149
+
150
+ def release_restore(database,keys,manifest,*,now,identity_cleanup_confirmed=False):
151
+ checked_manifest(manifest,now)
152
+ if not identity_cleanup_confirmed:
153
+ raise RestoreError('Apply the generated cleanup to the isolated Auth restore, then explicitly attest completion.')
154
+ database=Path(database)
155
+ if not database.is_file():raise RestoreError('A prepared isolated restore is required.')
156
+ # Reject a live/unprepared database read-only, before Store can migrate,
157
+ # change journal mode, or alter its filesystem permissions.
158
+ try:
159
+ with closing(_read(database)) as db:
160
+ row=db.execute('SELECT state FROM saas_restore_guard WHERE singleton=1').fetchone()
161
+ if row is None or row[0] not in {'identity_cleanup_required','authority_unavailable'}:
162
+ raise RestoreError('Only a quarantined offline restore can be released.')
163
+ except sqlite3.Error:
164
+ raise RestoreError('A prepared isolated restore is required; live databases cannot be released here.') from None
165
+ verify_snapshot(database,keys)
166
+ vault=Store(database,keys)
167
+ try:
168
+ with vault.transaction() as db:
169
+ row=db.execute('SELECT * FROM saas_restore_guard WHERE singleton=1').fetchone()
170
+ if row is None or row['state'] not in {'identity_cleanup_required','authority_unavailable'}:
171
+ raise RestoreError('Only a quarantined offline restore can be released.')
172
+ if row['snapshot_created']<now-6*86400:
173
+ raise RestoreError('The staged snapshot is too old; obtain a current recovery point.')
174
+ _apply(vault,db,manifest)
175
+ for record_id in manifest['pending_revocation_ids']:
176
+ if vault.get(db,'google-revocation',record_id) is None or not db.execute('SELECT id FROM saas_google_revocations WHERE id=?',(record_id,)).fetchone():
177
+ raise RestoreError('Current revocation jobs are missing. Recover the authoritative encrypted queue before release.')
178
+ db.execute("UPDATE saas_restore_guard SET state='ready' WHERE singleton=1")
179
+ except sqlite3.Error:
180
+ raise RestoreError('A prepared isolated restore is required; live databases cannot be released here.') from None
181
+ return {'status':'ready','identity_cleanup_verified':False,'identity_cleanup_operator_attested':True}
182
+
183
+
184
+ def auth_cleanup_sql(manifest,*,now):
185
+ checked_manifest(manifest,now)
186
+ # All old Auth sessions are invalidated even for owners not deleted. This
187
+ # script runs only in the isolated restored Auth DB with ON_ERROR_STOP.
188
+ lines=['BEGIN;','DELETE FROM auth.refresh_tokens;','DELETE FROM auth.sessions;']
189
+ users=sorted({item['id'] for item in manifest['tombstones'] if item['kind']=='user'})
190
+ if users:
191
+ values=','.join("'"+value.replace("'","''")+"'" for value in users)
192
+ lines.append('DELETE FROM auth.users WHERE id IN ('+values+');')
193
+ lines.append('COMMIT;')
194
+ return '\n'.join(lines)+'\n'
195
+
196
+
197
+ def _write_private(path,value):
198
+ fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
199
+ with os.fdopen(fd,'w') as stream:
200
+ stream.write(value);stream.flush();os.fsync(stream.fileno())
201
+
202
+
203
+ def main():
204
+ parser=argparse.ArgumentParser(description=__doc__)
205
+ sub=parser.add_subparsers(dest='operation',required=True)
206
+ export=sub.add_parser('export');export.add_argument('database',type=Path);export.add_argument('destination',type=Path)
207
+ export.add_argument('--current-authority-confirmed',action='store_true',required=True)
208
+ prepare=sub.add_parser('prepare');prepare.add_argument('database',type=Path);prepare.add_argument('destination',type=Path)
209
+ prepare.add_argument('--manifest',type=Path);prepare.add_argument('--snapshot-created',type=float,required=True)
210
+ auth=sub.add_parser('auth-sql');auth.add_argument('manifest',type=Path);auth.add_argument('destination',type=Path)
211
+ release=sub.add_parser('release');release.add_argument('database',type=Path);release.add_argument('--manifest',type=Path,required=True)
212
+ release.add_argument('--identity-cleanup-confirmed',action='store_true',required=True)
213
+ args=parser.parse_args();now=time.time()
214
+ keys=[v.strip().encode() for v in os.environ.get('SOCIALCLI_ENCRYPTION_KEYS','').split(',') if v.strip()]
215
+ try:
216
+ manifest=json.loads(args.manifest.read_text()) if getattr(args,'manifest',None) else None
217
+ if args.operation=='export':
218
+ _write_private(args.destination,json.dumps(export_manifest(args.database,now=now)))
219
+ result={'status':'manifest_exported','provenance':'operator_attested_current_authority'}
220
+ elif args.operation=='prepare':result=prepare_restore(args.database,args.destination,keys,manifest,now=now,snapshot_created=args.snapshot_created)
221
+ elif args.operation=='auth-sql':
222
+ _write_private(args.destination,auth_cleanup_sql(manifest,now=now));result={'status':'auth_sql_written','identity_cleanup_verified':False}
223
+ else:result=release_restore(args.database,keys,manifest,now=now,identity_cleanup_confirmed=args.identity_cleanup_confirmed)
224
+ except (RestoreError,BackupError,OSError,ValueError,TypeError):
225
+ parser.exit(1,'Restore guard failed; keep all restored services offline and review authority, freshness, keys and Auth cleanup.\n')
226
+ print(json.dumps(result,sort_keys=True))
227
+
228
+
229
+ if __name__=='__main__':main()
@@ -0,0 +1,97 @@
1
+ """Durable customer Google revocation; credentials are never usable by the broker.
2
+
3
+ The encrypted queue keeps only the token required to finish revocation, until
4
+ Google confirms completion. All network requests occur outside SQLite locks.
5
+ """
6
+ import secrets
7
+ from socialctl.saas.budget import using_budget
8
+
9
+ TOKEN_KIND = 'google-revocation'
10
+
11
+
12
+ def tombstone(db, kind, record_id, now):
13
+ db.execute('INSERT OR REPLACE INTO saas_deletion_tombstones VALUES(?,?,?)',
14
+ (kind, record_id, now))
15
+
16
+
17
+ def reserve(db, job_id, user_id, now):
18
+ """Fence reconnect while an already-started provider request is unresolved."""
19
+ tombstone(db, 'grant', job_id, now)
20
+ db.execute('INSERT OR IGNORE INTO saas_google_revocations(id,user_id,requested) VALUES(?,?,?)',
21
+ (job_id, user_id, now))
22
+
23
+
24
+ def enqueue(metadata, db, grant_id, payload, now):
25
+ """Called in the same transaction that removes local access."""
26
+ tombstone(db, 'grant', grant_id, now)
27
+ if payload.get('platform') != 'youtube':
28
+ return None
29
+ grant = payload.get('grant') or {}
30
+ token = grant.get('refresh_token') or grant.get('access_token')
31
+ if not token:
32
+ return None
33
+ existing=db.execute('SELECT state FROM saas_google_revocations WHERE id=?', (grant_id,)).fetchone()
34
+ if existing:
35
+ if existing['state']=='pending' and metadata.vault.get(db,TOKEN_KIND,grant_id) is None:
36
+ metadata.vault.put(db,TOKEN_KIND,grant_id,{'token':token},expires=253402300799)
37
+ db.execute('UPDATE saas_google_revocations SET next_attempt=0 WHERE id=?',(grant_id,))
38
+ return grant_id
39
+ metadata.vault.put(db, TOKEN_KIND, grant_id, {'token': token}, expires=253402300799)
40
+ db.execute('INSERT INTO saas_google_revocations(id,user_id,requested) VALUES(?,?,?)',
41
+ (grant_id, payload['user_id'], now))
42
+ return grant_id
43
+
44
+
45
+ def export_current_deletion_manifest(metadata, clock):
46
+ """Export from the current authoritative service, NEVER from a restored backup."""
47
+ with metadata.vault.read_transaction() as db:
48
+ rows=[dict(row) for row in db.execute('SELECT kind,id,requested FROM saas_deletion_tombstones ORDER BY kind,id')]
49
+ pending=[row[0] for row in db.execute("SELECT id FROM saas_google_revocations WHERE state='pending' ORDER BY id")]
50
+ return {'schema': 1, 'source': 'current-authority', 'generated_at': clock(), 'tombstones': rows, 'pending_revocation_ids':pending}
51
+
52
+
53
+ def run_revocations(metadata, providers, clock, limit=100, *, user_id=None, job_id=None, budget=None):
54
+ vault=metadata.vault
55
+ where="state='pending' AND next_attempt<=? AND lease_until<=?"
56
+ args=[clock(),clock()]
57
+ for field,value in (('user_id',user_id),('id',job_id)):
58
+ if value is not None:
59
+ where+=' AND '+field+'=?';args.append(value)
60
+ with vault.read_transaction() as db:
61
+ jobs=[dict(row) for row in db.execute('SELECT * FROM saas_google_revocations WHERE '+where+' ORDER BY requested LIMIT ?',(*args,limit))]
62
+ completed=0
63
+ with using_budget(budget,providers):
64
+ for job in jobs:
65
+ if budget and budget.expired():break
66
+ lease=secrets.token_hex(20)
67
+ with vault.transaction() as db:
68
+ claimed=db.execute("UPDATE saas_google_revocations SET lease=?,lease_until=? WHERE id=? AND state='pending' AND lease_until<=? AND next_attempt<=?",(lease,clock()+300,job['id'],clock(),clock())).rowcount
69
+ if not claimed:continue
70
+ record=vault.get(db,TOKEN_KIND,job['id'])
71
+ try:
72
+ if record is None or providers is None:
73
+ raise ValueError('google_revocation_pending')
74
+ providers.revoke_google(record['payload']['token'])
75
+ except Exception:
76
+ with vault.transaction() as db:
77
+ db.execute('UPDATE saas_google_revocations SET lease=NULL,lease_until=0,attempts=attempts+1,next_attempt=? WHERE id=? AND lease=?',(clock()+300,job['id'],lease))
78
+ else:
79
+ with vault.transaction() as db:
80
+ updated=db.execute("UPDATE saas_google_revocations SET state='complete',lease=NULL,lease_until=0,attempts=attempts+1 WHERE id=? AND lease=?",(job['id'],lease)).rowcount
81
+ if updated:
82
+ vault.delete(db,TOKEN_KIND,job['id']);completed+=1
83
+ with vault.read_transaction() as db:
84
+ pending=db.execute("SELECT count(*) FROM saas_google_revocations WHERE state='pending'").fetchone()[0]
85
+ due=db.execute("SELECT count(*) FROM saas_google_revocations WHERE state='pending' AND next_attempt<=?",(clock(),)).fetchone()[0]
86
+ overdue=db.execute("SELECT count(*) FROM saas_google_revocations WHERE state='pending' AND requested<=?",(clock()-86400,)).fetchone()[0]
87
+ awaiting=db.execute("SELECT count(*) FROM saas_google_revocations q WHERE state='pending' AND NOT EXISTS (SELECT 1 FROM records r WHERE r.kind='google-revocation' AND r.id=q.id)").fetchone()[0]
88
+ return {'completed':completed,'pending':pending,'due':due,'overdue':overdue,
89
+ 'awaiting_credentials':awaiting,'operator_review_required':bool(awaiting),
90
+ 'budget_exhausted':bool(budget and budget.expired())}
91
+
92
+
93
+ def status(metadata, job_id):
94
+ if job_id is None:return 'not_applicable'
95
+ with metadata.vault.read_transaction() as db:
96
+ row=db.execute('SELECT state FROM saas_google_revocations WHERE id=?',(job_id,)).fetchone()
97
+ return row['state'] if row else 'pending'
@@ -0,0 +1,114 @@
1
+ """Two-phase installation/capability rotation; provider grants remain independent."""
2
+ import hmac
3
+ import secrets
4
+ from fastapi import HTTPException,Request
5
+ from pydantic import BaseModel,ConfigDict,Field
6
+ from socialctl.saas.pairing import proof,INSTALLATION_SECONDS
7
+
8
+
9
+ class CapabilityRotation(BaseModel):
10
+ model_config=ConfigDict(extra='forbid')
11
+ connection_id:str=Field(pattern='^[A-Za-z0-9_-]{16,256}$')
12
+ challenge:str=Field(pattern='^[0-9a-f]{64}$')
13
+
14
+
15
+ class RotationRequest(BaseModel):
16
+ model_config=ConfigDict(extra='forbid')
17
+ installation_challenge:str=Field(pattern='^[0-9a-f]{64}$')
18
+ connections:list[CapabilityRotation]=Field(default_factory=list,max_length=4)
19
+
20
+
21
+ def install_rotation_routes(app,metadata,clock):
22
+ vault=metadata.vault;installations=app.state.saas_installations
23
+
24
+ def owned_capability(db,connection_id,installation_id,user_id):
25
+ record=vault.get(db,'saas-capability',connection_id)
26
+ if record is None:raise HTTPException(409,'connection_changed: Review the current connection.')
27
+ data=record['payload']
28
+ if data['installation_id']!=installation_id or data['user_id']!=user_id:
29
+ raise HTTPException(403,'This connection belongs to another installation.')
30
+ slot=db.execute('SELECT grant_id FROM saas_slots WHERE user_id=? AND platform=?',(user_id,data['platform'])).fetchone()
31
+ grant=vault.get(db,'saas-grant',data['grant_id'])
32
+ if slot is None or slot['grant_id']!=data['grant_id'] or grant is None or grant['expires']<=clock():
33
+ raise HTTPException(409,'connection_changed: Review the current connection.')
34
+ return record
35
+
36
+ @app.post('/api/saas/v1/installations/{installation_id}/rotations/{rotation_id}',status_code=201)
37
+ def prepare(installation_id:str,rotation_id:str,body:RotationRequest,request:Request):
38
+ import re
39
+ if not re.fullmatch(r'[A-Za-z0-9_-]{16,64}',rotation_id):raise HTTPException(400,'Invalid rotation identifier.')
40
+ wanted=body.model_dump()
41
+ with vault.transaction() as db:
42
+ old=installations.require(db,installation_id,request);user_id=old['payload']['user_id']
43
+ pending=vault.get(db,'saas-rotation',rotation_id)
44
+ if pending:
45
+ data=pending['payload']
46
+ if (pending['expires']<=clock() or data.get('status')!='prepared'
47
+ or data['installation_id']!=installation_id or data['user_id']!=user_id
48
+ or data['request']!=wanted or data['old_hash']!=old['credential_hash']):
49
+ raise HTTPException(409,'rotation_changed: Resume the original rotation or review installation access.')
50
+ return data['result']
51
+ if not metadata.admit(db,'rotation:'+installation_id,clock(),limit=5):
52
+ raise HTTPException(429,'Too many rotation attempts. Wait a minute.')
53
+ if len({c.connection_id for c in body.connections})!=len(body.connections):
54
+ raise HTTPException(400,'Duplicate connection in rotation.')
55
+ next_id=secrets.token_urlsafe(24);connections=[];platforms=set()
56
+ for entry in body.connections:
57
+ cap=owned_capability(db,entry.connection_id,installation_id,user_id)['payload']
58
+ if cap['platform'] in platforms:raise HTTPException(400,'Only one connection per platform can be rotated.')
59
+ platforms.add(cap['platform'])
60
+ connections.append({'connection_id':secrets.token_urlsafe(24),'previous_id':entry.connection_id,
61
+ 'platform':cap['platform'],'account':cap['account'],'grant_id':cap['grant_id']})
62
+ result={'version':1,'status':'prepared','installation_id':next_id,'connections':connections}
63
+ payload={'status':'prepared','user_id':user_id,'installation_id':installation_id,
64
+ 'old_hash':old['credential_hash'],'request':wanted,'result':result}
65
+ vault.put(db,'saas-rotation',rotation_id,payload,expires=min(old['expires'],clock()+600),
66
+ credential_hash=body.installation_challenge)
67
+ return result
68
+
69
+ @app.post('/api/saas/v1/rotations/{rotation_id}/commit')
70
+ def commit(rotation_id:str,request:Request):
71
+ with vault.transaction() as db:
72
+ record=vault.get(db,'saas-rotation',rotation_id)
73
+ if record is None or record['expires']<=clock() or not hmac.compare_digest(record['credential_hash'],proof(request)):
74
+ raise HTTPException(404,'rotation_unavailable: Review pending rotation recovery.')
75
+ data=record['payload']
76
+ if data['status']=='committed':
77
+ installations.require(db,data['installation_id'],request)
78
+ return {'version':1,'status':'active'}
79
+ old=vault.get(db,'saas-installation',data['installation_id'])
80
+ if old is None or old['expires']<=clock() or old['credential_hash']!=data['old_hash']:
81
+ raise HTTPException(401,'installation_revoked: Sign in to SocialCli again.')
82
+ user=metadata.user(db,data['user_id'])
83
+ if user is None or user['deleting']:raise HTTPException(401,'account_unavailable: Sign in to SocialCli again.')
84
+ next_id=data['result']['installation_id'];expires=clock()+INSTALLATION_SECONDS
85
+ for item,entry in zip(data['result']['connections'],data['request']['connections'],strict=True):
86
+ cap=owned_capability(db,entry['connection_id'],data['installation_id'],data['user_id'])
87
+ if cap['payload']['grant_id']!=item['grant_id'] or cap['payload']['account']!=item['account']:
88
+ raise HTTPException(409,'connection_changed: Review the current connection.')
89
+ vault.put(db,'saas-capability',item['connection_id'],{**cap['payload'],'installation_id':next_id},
90
+ expires=expires,credential_hash=entry['challenge'])
91
+ vault.put(db,'saas-installation',next_id,{**old['payload'],'rotated_at':clock()},
92
+ expires=expires,credential_hash=record['credential_hash'])
93
+ vault.delete(db,'saas-installation',data['installation_id'])
94
+ # Old caps and pending OAuth are unusable because their installation
95
+ # no longer exists. Remove owned capability records immediately.
96
+ db.execute("""DELETE FROM records WHERE kind='saas-capability' AND id IN (
97
+ SELECT link.id FROM saas_record_links link JOIN saas_record_owners owner
98
+ ON owner.kind=link.kind AND owner.id=link.id
99
+ WHERE link.kind='saas-capability' AND link.installation_id=? AND owner.user_id=?)""",
100
+ (data['installation_id'],data['user_id']))
101
+ vault.put(db,'saas-rotation',rotation_id,{'status':'committed','user_id':data['user_id'],'installation_id':next_id},
102
+ expires=expires,credential_hash=record['credential_hash'])
103
+ return {'version':1,'status':'active'}
104
+
105
+ @app.delete('/api/saas/v1/installations/{installation_id}/rotations/{rotation_id}')
106
+ def cancel(installation_id:str,rotation_id:str,request:Request):
107
+ with vault.transaction() as db:
108
+ installations.require(db,installation_id,request)
109
+ record=vault.get(db,'saas-rotation',rotation_id)
110
+ if record:
111
+ if record['payload']['installation_id']!=installation_id or record['payload']['status']!='prepared':
112
+ raise HTTPException(409,'rotation_changed: Resume the committed rotation.')
113
+ vault.delete(db,'saas-rotation',rotation_id)
114
+ return {'version':1,'status':'cancelled'}