Setting the file. One moment.
Source Access · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 155
function withheldCoverageId
— line 155
This file
Number 45.147
Position 147 of 182
Type JavaScript
Size 11 KB
Lines 231 lib/ source-access.js
JavaScript · 231 lines · 11 KB
// Reads are fail-visible: the action is listed while an entry is pending, while the file is
15 // unreadable, and while the file is missing but a passing session receipt shows a connection
16 // happened. Only a confirmed entry, or no evidence of any connection, lists nothing.
17
18 const fs = require ( 'node:fs' );
19 const path = require ( 'node:path' );
20 const { parseEnvText } = require ( './config-env.js' );
21
22 const SHOPIFY_ACCESS_DIR = 'data/shopify-discovery' ;
23 const STAFF_ACCESS_FILE = `${ SHOPIFY_ACCESS_DIR }/staff-access.json` ;
24 const SESSION_RECEIPT_FILE = `${ SHOPIFY_ACCESS_DIR }/session-receipt.json` ;
25 const SHOPIFY_SOURCE_ENV = 'config/source.shopify.env' ;
26 const STAFF_ACCESS_SCHEMA_VERSION = 1 ;
27 const STAFF_REMOVAL_CODE = 'shopify-staff-account-removal' ;
28 const STAFF_REMOVAL_CONFIRMATION = 'owner-removed-account' ;
29 const WITHHELD_EXPORT_FILE = `${ SHOPIFY_ACCESS_DIR }/withheld-export.json` ;
30 const SHOPIFY_COVERAGE_FILE = 'discovery/source-coverage.json' ;
31 const SHOPIFY_EXTRACT_MANIFEST_FILE = 'data/source-extract/extract-manifest.json' ;
32 const WITHHELD_EXPORT_CODE = 'shopify-withheld-details-export' ;
33 const WITHHELD_EXPORT_CONFIRMATION = 'owner-exported' ;
34 const SESSION_CHECK_SCRIPT = 'skills/wix-replatform/resources/rp-source-shopify/scripts/shopify-session-check.js' ;
35 const SHOPIFY_DOMAIN_PATTERN = / ^ [a-z0-9][a-z0-9-] * \. myshopify \. com $ / ;
36
37 function readJsonFileSync ( file ) {
38 let text;
39 try {
40 text = fs. readFileSync (file, 'utf8' );
41 } catch (error) {
42 if (error.code === 'ENOENT' ) return { state: 'missing' , value: null };
43 return { state: 'unreadable' , value: null , error: error.message };
44 }
45 try {
46 return { state: 'ok' , value: JSON . parse (text) };
47 } catch (error) {
48 return { state: 'unreadable' , value: null , error: error.message };
49 }
50 }
51
52 function validIsoTime ( value ) {
53 return typeof value === 'string' && ! Number. isNaN (Date. parse (value));
54 }
55
56 function validAccount ( entry ) {
57 if ( ! entry || typeof entry !== 'object'
58 || ! SHOPIFY_DOMAIN_PATTERN . test (entry.shopDomain)
59 || ! validIsoTime (entry.createdAt)
60 || (entry.reconstructed !== undefined && entry.reconstructed !== true )) {
61 return false ;
62 }
63 if (entry.state === 'pending' ) {
64 return entry.confirmedAt === undefined && entry.confirmation === undefined ;
65 }
66 return entry.state === 'confirmed'
67 && validIsoTime (entry.confirmedAt)
68 && entry.confirmation === STAFF_REMOVAL_CONFIRMATION ;
69 }
70
71 // { state: 'missing' | 'unreadable' | 'ok', accounts }
72 function readStaffAccess ( projectDir ) {
73 const read = readJsonFileSync (path. join (projectDir, STAFF_ACCESS_FILE ));
74 if (read.state !== 'ok' ) return { state: read.state, accounts: [] };
75 const value = read.value;
76 if ( ! value || typeof value !== 'object'
77 || value.schemaVersion !== STAFF_ACCESS_SCHEMA_VERSION
78 || ! Array. isArray (value.accounts)
79 || value.accounts. length === 0
80 || ! value.accounts. every (validAccount)) {
81 return { state: 'unreadable' , accounts: [] };
82 }
83 return { state: 'ok' , accounts: value.accounts };
84 }
85
86 // Temp file, then rename: a crash between the two leaves the previous file intact. `io` is
87 // injectable so a test can crash between them.
88 function writeJsonAtomicSync ( file , value , io = fs) {
89 io. mkdirSync (path. dirname (file), { recursive: true });
90 const temp = `${ file }.${ process . pid }.${ Date . now () }.tmp` ;
91 io. writeFileSync (temp, `${ JSON . stringify ( value , null , 2 ) } \n ` , 'utf8' );
92 io. renameSync (temp, file);
93 }
94
95 function writeStaffAccess ( projectDir , accounts , { io = fs } = {}) {
96 writeJsonAtomicSync (path. join (projectDir, STAFF_ACCESS_FILE ), { schemaVersion: STAFF_ACCESS_SCHEMA_VERSION , accounts }, io);
97 }
98
99 function readSessionReceipt ( projectDir ) {
100 const read = readJsonFileSync (path. join (projectDir, SESSION_RECEIPT_FILE ));
101 return read.state === 'ok' && read.value && typeof read.value === 'object' ? read.value : null ;
102 }
103
104 function passingReceipt ( receipt ) {
105 return Boolean (receipt && typeof receipt.shopDomain === 'string' && Array. isArray (receipt.missingScopes) && receipt.missingScopes. length === 0 );
106 }
107
108 function configuredShopDomain ( projectDir ) {
109 try {
110 const values = parseEnvText (fs. readFileSync (path. join (projectDir, SHOPIFY_SOURCE_ENV ), 'utf8' ));
111 const value = String (values. SHOPIFY_SHOP_DOMAIN || '' ). trim (). toLowerCase ();
112 return value || null ;
113 } catch {
114 return null ;
115 }
116 }
117
118 function removalAction ( domain , { recordProblem = null } = {}) {
119 const prompt = `In this project, once the store owner confirms the migration staff account is removed, run \` node ${ SESSION_CHECK_SCRIPT } --project <project> --confirm-staff-removal ${ STAFF_REMOVAL_CONFIRMATION } \` (if it reports the record is unreadable or missing, run \` node ${ SESSION_CHECK_SCRIPT } --project <project> --repair-staff-access \` first), then regenerate the completion report and check this action is gone. Requires Import Operator; the Cutover Operator asks the store owner to remove the account in Shopify Admin after cutover and after the last read from Shopify, including any catch-up.` ;
120 const reason = 'The Shopify staff account the migration connection was approved from can still change the live store through Shopify Admin until the store owner removes it.' ;
121 return {
122 code: STAFF_REMOVAL_CODE ,
123 severity: 'high' ,
124 action: `Ask the store owner to remove the migration staff account from ${ domain || 'the Shopify store'}. ${ prompt }` ,
125 deadlineReason: recordProblem ? `${ reason } Record ${ recordProblem } — run ${ SESSION_CHECK_SCRIPT } --repair-staff-access.` : reason,
126 entity: domain || null ,
127 affectedCount: 1 ,
128 };
129 }
130
131 // Operator-action rows for completion-report.json. Never throws for a damaged record.
132 function sourceAccessOperatorActions ( projectDir ) {
133 const record = readStaffAccess (projectDir);
134 const receipt = readSessionReceipt (projectDir);
135 const configuredDomain = configuredShopDomain (projectDir);
136 const receiptMatchesConfiguration = passingReceipt (receipt)
137 && ( ! configuredDomain || receipt.shopDomain === configuredDomain);
138 const connectedDomain = receiptMatchesConfiguration ? receipt.shopDomain : null ;
139 if (record.state === 'ok' ) {
140 const actions = record.accounts. filter (( entry ) => entry.state === 'pending' ). map (( entry ) => removalAction (entry.shopDomain));
141 if (connectedDomain && ! record.accounts. some (( entry ) => entry.shopDomain === connectedDomain)) {
142 actions. push ( removalAction (connectedDomain, { recordProblem: 'has no entry for the connected store' }));
143 }
144 return actions;
145 }
146 const domain = configuredDomain || (receipt && receipt.shopDomain) || null ;
147 if (record.state === 'unreadable' ) return [ removalAction (domain, { recordProblem: 'unreadable' })];
148 // Missing: list it only when a passing receipt for the configured domain shows a connection.
149 if (receiptMatchesConfiguration) {
150 return [ removalAction (receipt.shopDomain, { recordProblem: 'missing' })];
151 }
152 return [];
153 }
154
155 function withheldCoverageId ( value ) {
156 return / ^ shopify:(?:customer | order)-(?:name | email | phone | address) $ / . test ( String (value))
157 || value === 'shopify:customers-withheld-identity' ;
158 }
159
160 function manifestLatestReadAt ( manifest ) {
161 const times = [];
162 for ( const entry of Object. values ((manifest && manifest.queries) || {})) {
163 for ( const value of Object. values ((entry && entry.readAt) || {})) {
164 if ( validIsoTime (value)) times. push (value);
165 }
166 }
167 times. sort (( a , b ) => Date. parse (a) - Date. parse (b));
168 return times[times. length - 1 ] || null ;
169 }
170
171 function entityAffectedCount ( manifest , coverageId ) {
172 if (coverageId === 'shopify:customers-withheld-identity' ) return Number (manifest && manifest.withheldIdentityCount) || 0 ;
173 const entity = coverageId. startsWith ( 'shopify:customer-' ) ? 'customers' : 'orders' ;
174 const count = manifest && manifest.counts && manifest.counts[entity];
175 if (count && Number. isInteger (count.expected)) return count.expected;
176 if (count && Number. isInteger (count.received)) return count.received;
177 const query = entity === 'customers' ? 'customers' : 'orders-core' ;
178 return Number (manifest && manifest.queries && manifest.queries[query] && manifest.queries[query].records) || 0 ;
179 }
180
181 // A separate action remains after any mapping disposition until the merchant's
182 // export is attested after the latest Shopify read. Damaged or stale confirmation is simply open.
183 function withheldDetailsOperatorActions ( projectDir ) {
184 const coverageRead = readJsonFileSync (path. join (projectDir, SHOPIFY_COVERAGE_FILE ));
185 if (coverageRead.state !== 'ok' || ! coverageRead.value || ! Array. isArray (coverageRead.value.rows)) return [];
186 const coverageIds = coverageRead.value.rows. map (( row ) => row && row.coverageId). filter (withheldCoverageId). sort ();
187 if ( ! coverageIds. length ) return [];
188 const manifestRead = readJsonFileSync (path. join (projectDir, SHOPIFY_EXTRACT_MANIFEST_FILE ));
189 const manifest = manifestRead.state === 'ok' && manifestRead.value && typeof manifestRead.value === 'object' ? manifestRead.value : null ;
190 const latestReadAt = manifestLatestReadAt (manifest);
191 const confirmationRead = readJsonFileSync (path. join (projectDir, WITHHELD_EXPORT_FILE ));
192 const confirmation = confirmationRead.state === 'ok' ? confirmationRead.value : null ;
193 const covered = confirmation
194 && confirmation.schemaVersion === 1
195 && confirmation.confirmation === WITHHELD_EXPORT_CONFIRMATION
196 && validIsoTime (confirmation.confirmedAt)
197 && validIsoTime (confirmation.afterReadAt)
198 && Array. isArray (confirmation.coverageIds)
199 && coverageIds. every (( coverageId ) => confirmation.coverageIds. includes (coverageId))
200 && latestReadAt
201 && Date. parse (confirmation.afterReadAt) >= Date. parse (latestReadAt);
202 if (covered) return [];
203 const affectedCount = Math. max ( 0 , ... coverageIds. map (( coverageId ) => entityAffectedCount (manifest, coverageId)));
204 return [{
205 code: WITHHELD_EXPORT_CODE ,
206 severity: 'high' ,
207 entity: 'customers' ,
208 affectedCount,
209 action: 'Ask the merchant to export customers and orders from Shopify Admin (or re-collect the withheld details) after the final Shopify read. Requires Import Operator: once the merchant confirms the export, run `shopify-discovery.js --project <project> --confirm-withheld-export owner-exported`, regenerate the completion report, and check this action is gone.' ,
210 deadlineReason: 'Shopify holds the only copy of these details; export them before the store is closed.' ,
211 }];
212 }
213
214 module . exports = {
215 STAFF_ACCESS_FILE,
216 SESSION_RECEIPT_FILE,
217 SHOPIFY_SOURCE_ENV,
218 STAFF_REMOVAL_CODE,
219 STAFF_REMOVAL_CONFIRMATION,
220 WITHHELD_EXPORT_FILE,
221 WITHHELD_EXPORT_CODE,
222 WITHHELD_EXPORT_CONFIRMATION,
223 readStaffAccess,
224 writeStaffAccess,
225 writeJsonAtomicSync,
226 readSessionReceipt,
227 passingReceipt,
228 configuredShopDomain,
229 sourceAccessOperatorActions,
230 withheldDetailsOperatorActions,
231 };