@edgedev/firebase 2.2.82 → 2.2.84

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cms.js +276 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgedev/firebase",
3
- "version": "2.2.82",
3
+ "version": "2.2.84",
4
4
  "description": "Vue 3 / Nuxt 3 Plugin or Nuxt 3 plugin for firebase authentication and firestore.",
5
5
  "main": "index.ts",
6
6
  "scripts": {
package/src/cms.js CHANGED
@@ -235,16 +235,76 @@ const normalizeDomain = (value) => {
235
235
  }
236
236
  }
237
237
  normalized = normalized.split('/')[0] || ''
238
+ if (normalized.startsWith('[')) {
239
+ const closingIndex = normalized.indexOf(']')
240
+ if (closingIndex !== -1)
241
+ normalized = normalized.slice(0, closingIndex + 1)
242
+ }
238
243
  if (normalized.includes(':') && !normalized.startsWith('[')) {
239
244
  normalized = normalized.split(':')[0] || ''
240
245
  }
241
246
  return normalized.replace(/\.+$/g, '')
242
247
  }
243
248
 
249
+ const stripIpv6Brackets = (value) => {
250
+ const text = String(value || '').trim()
251
+ if (text.startsWith('[') && text.endsWith(']'))
252
+ return text.slice(1, -1)
253
+ return text
254
+ }
255
+
256
+ const isIpv4Address = (value) => {
257
+ const parts = String(value || '').split('.')
258
+ if (parts.length !== 4)
259
+ return false
260
+ return parts.every((part) => {
261
+ if (!/^\d{1,3}$/.test(part))
262
+ return false
263
+ const num = Number(part)
264
+ return num >= 0 && num <= 255
265
+ })
266
+ }
267
+
268
+ const isIpv6Address = (value) => {
269
+ const normalized = String(value || '').toLowerCase()
270
+ if (!normalized.includes(':'))
271
+ return false
272
+ return /^[0-9a-f:]+$/.test(normalized)
273
+ }
274
+
275
+ const isIpAddress = (value) => {
276
+ if (!value)
277
+ return false
278
+ const normalized = stripIpv6Brackets(value)
279
+ return isIpv4Address(normalized) || isIpv6Address(normalized)
280
+ }
281
+
282
+ const getCloudflareApexDomain = (domain) => {
283
+ if (!domain)
284
+ return ''
285
+ if (domain.startsWith('www.'))
286
+ return domain.slice(4)
287
+ return domain
288
+ }
289
+
290
+ const shouldDisplayDomainDnsRecords = (domain) => {
291
+ const normalizedDomain = normalizeDomain(domain)
292
+ const apexDomain = getCloudflareApexDomain(normalizedDomain)
293
+ if (!apexDomain)
294
+ return false
295
+ if (apexDomain === 'localhost' || apexDomain.endsWith('.localhost'))
296
+ return false
297
+ if (isIpAddress(apexDomain))
298
+ return false
299
+ if (apexDomain.endsWith('.dev'))
300
+ return false
301
+ return true
302
+ }
303
+
244
304
  const shouldSyncCloudflareDomain = (domain) => {
245
305
  if (!domain)
246
306
  return false
247
- if (domain.includes('localhost'))
307
+ if (!shouldDisplayDomainDnsRecords(domain))
248
308
  return false
249
309
  if (CLOUDFLARE_PAGES_PROJECT) {
250
310
  const pagesDomain = `${CLOUDFLARE_PAGES_PROJECT}.pages.dev`
@@ -262,6 +322,44 @@ const getCloudflarePagesDomain = (domain) => {
262
322
  return `www.${domain}`
263
323
  }
264
324
 
325
+ const getCloudflarePagesTarget = () => {
326
+ if (!CLOUDFLARE_PAGES_PROJECT)
327
+ return ''
328
+ return `${CLOUDFLARE_PAGES_PROJECT}.pages.dev`
329
+ }
330
+
331
+ const buildDomainDnsPayload = (domain, pagesTarget = '') => {
332
+ const normalizedDomain = normalizeDomain(domain)
333
+ const apexDomain = getCloudflareApexDomain(normalizedDomain)
334
+ const wwwDomain = getCloudflarePagesDomain(apexDomain)
335
+ const target = pagesTarget || getCloudflarePagesTarget()
336
+ const dnsEligible = shouldDisplayDomainDnsRecords(apexDomain)
337
+
338
+ return {
339
+ domain: normalizedDomain,
340
+ apexDomain,
341
+ wwwDomain,
342
+ dnsEligible,
343
+ dnsRecords: {
344
+ target,
345
+ www: {
346
+ type: 'CNAME',
347
+ name: 'www',
348
+ host: wwwDomain,
349
+ value: target,
350
+ enabled: dnsEligible && !!target,
351
+ },
352
+ apex: {
353
+ type: 'CNAME',
354
+ name: '@',
355
+ host: apexDomain,
356
+ value: target,
357
+ enabled: dnsEligible && !!target,
358
+ },
359
+ },
360
+ }
361
+ }
362
+
265
363
  const isCloudflareDomainAlreadyExistsError = (status, errors = [], message = '') => {
266
364
  if (status === 409)
267
365
  return true
@@ -994,8 +1092,11 @@ exports.onSiteWritten = createKvMirrorHandler({
994
1092
  document: 'organizations/{orgId}/published-site-settings/{siteId}',
995
1093
  makeCanonicalKey: ({ orgId, siteId }) =>
996
1094
  `sites:${orgId}:${siteId}`,
997
- makeIndexKeys: ({ orgId }, data) => {
1095
+ makeIndexKeys: ({ orgId, siteId }, data) => {
998
1096
  const keys = []
1097
+ const siteDocId = slug(siteId)
1098
+ if (siteDocId)
1099
+ keys.push(`idx:sites:docId:${orgId}:${siteDocId}:${siteId}`)
999
1100
  const domains = Array.isArray(data?.domains) ? data.domains : []
1000
1101
  for (const domain of domains) {
1001
1102
  const st = slug(domain)
@@ -1917,13 +2018,64 @@ exports.updateSeoFromAi = onCall({ timeoutSeconds: 180 }, async (request) => {
1917
2018
 
1918
2019
  exports.getCloudflarePagesProject = onCall(async (request) => {
1919
2020
  assertCallableUser(request)
2021
+ const data = request.data || {}
2022
+ const orgId = String(data.orgId || '').trim()
2023
+ const siteId = String(data.siteId || '').trim()
2024
+ const rawDomains = Array.isArray(data.domains) ? data.domains : []
2025
+ const normalizedDomains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)))
2026
+ const pagesTarget = getCloudflarePagesTarget()
1920
2027
 
1921
- if (!CLOUDFLARE_PAGES_PROJECT) {
2028
+ if (!CLOUDFLARE_PAGES_PROJECT)
1922
2029
  logger.warn('CLOUDFLARE_PAGES_PROJECT is not set.')
1923
- return { project: '' }
2030
+
2031
+ const domainRegistry = {}
2032
+ if (orgId && siteId && normalizedDomains.length) {
2033
+ const allowed = await permissionCheck(request.auth.uid, 'read', `organizations/${orgId}/sites`)
2034
+ if (!allowed)
2035
+ throw new HttpsError('permission-denied', 'Not allowed to read site settings')
2036
+
2037
+ await Promise.all(normalizedDomains.map(async (domain) => {
2038
+ const registryRef = db.collection(DOMAIN_REGISTRY_COLLECTION).doc(domain)
2039
+ const registrySnap = await registryRef.get()
2040
+ const fallback = buildDomainDnsPayload(domain, pagesTarget)
2041
+ if (!registrySnap.exists) {
2042
+ domainRegistry[domain] = {
2043
+ ...fallback,
2044
+ apexAttempted: false,
2045
+ apexAdded: false,
2046
+ apexError: '',
2047
+ dnsGuidance: fallback.dnsEligible
2048
+ ? 'Add the www CNAME. Apex is unavailable; forward apex to www.'
2049
+ : 'DNS records are not shown for localhost, IP addresses, or .dev domains.',
2050
+ }
2051
+ return
2052
+ }
2053
+
2054
+ const value = registrySnap.data() || {}
2055
+ domainRegistry[domain] = {
2056
+ ...fallback,
2057
+ ...value,
2058
+ dnsRecords: {
2059
+ ...fallback.dnsRecords,
2060
+ ...(value.dnsRecords || {}),
2061
+ www: {
2062
+ ...fallback.dnsRecords.www,
2063
+ ...(value?.dnsRecords?.www || {}),
2064
+ },
2065
+ apex: {
2066
+ ...fallback.dnsRecords.apex,
2067
+ ...(value?.dnsRecords?.apex || {}),
2068
+ },
2069
+ },
2070
+ }
2071
+ }))
1924
2072
  }
1925
2073
 
1926
- return { project: CLOUDFLARE_PAGES_PROJECT }
2074
+ return {
2075
+ project: CLOUDFLARE_PAGES_PROJECT || '',
2076
+ pagesDomain: pagesTarget,
2077
+ domainRegistry,
2078
+ }
1927
2079
  })
1928
2080
 
1929
2081
  exports.generateBlockFields = onCall({ timeoutSeconds: 180 }, async (request) => {
@@ -2071,7 +2223,6 @@ exports.ensurePublishedSiteDomains = onDocumentWritten(
2071
2223
  const siteRef = change.after.ref
2072
2224
  const siteData = change.after.data() || {}
2073
2225
  const beforeData = change.before?.data?.() || {}
2074
- const domainErrorChanged = beforeData?.domainError !== siteData?.domainError
2075
2226
  const rawDomains = Array.isArray(siteData.domains) ? siteData.domains : []
2076
2227
  const normalizedDomains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)))
2077
2228
  const beforeRawDomains = Array.isArray(beforeData.domains) ? beforeData.domains : []
@@ -2141,41 +2292,143 @@ exports.ensurePublishedSiteDomains = onDocumentWritten(
2141
2292
  filteredDomains = normalizedDomains.filter(domain => !conflictSet.has(domain))
2142
2293
  }
2143
2294
 
2144
- const syncDomains = Array.from(new Set(
2145
- filteredDomains
2146
- .map(domain => getCloudflarePagesDomain(domain))
2147
- .filter(domain => shouldSyncCloudflareDomain(domain)),
2148
- ))
2295
+ const pagesTarget = getCloudflarePagesTarget()
2296
+ const registryStateByDomain = new Map()
2297
+ const syncPlanMap = new Map()
2298
+ for (const domain of filteredDomains) {
2299
+ const dnsPayload = buildDomainDnsPayload(domain, pagesTarget)
2300
+ registryStateByDomain.set(domain, {
2301
+ ...dnsPayload,
2302
+ wwwAdded: false,
2303
+ wwwError: '',
2304
+ apexAttempted: false,
2305
+ apexAdded: false,
2306
+ apexError: '',
2307
+ dnsGuidance: dnsPayload.dnsEligible
2308
+ ? 'Add the www CNAME record. Apex is unavailable; forward apex to www.'
2309
+ : 'DNS records are not shown for localhost, IP addresses, or .dev domains.',
2310
+ })
2311
+
2312
+ const apexDomain = dnsPayload.apexDomain
2313
+ if (!apexDomain)
2314
+ continue
2315
+ const existingPlan = syncPlanMap.get(apexDomain) || {
2316
+ apexDomain,
2317
+ wwwDomain: dnsPayload.wwwDomain,
2318
+ domains: new Set(),
2319
+ }
2320
+ existingPlan.domains.add(domain)
2321
+ syncPlanMap.set(apexDomain, existingPlan)
2322
+ }
2323
+
2324
+ const syncPlans = Array.from(syncPlanMap.values())
2325
+ .filter(plan => shouldSyncCloudflareDomain(plan.wwwDomain))
2326
+ .map(plan => ({ ...plan, domains: Array.from(plan.domains) }))
2327
+
2149
2328
  const removeDomains = Array.from(new Set(
2150
2329
  removedOwnedDomains
2151
- .map(domain => getCloudflarePagesDomain(domain))
2330
+ .flatMap((domain) => {
2331
+ const apexDomain = getCloudflareApexDomain(domain)
2332
+ const wwwDomain = getCloudflarePagesDomain(apexDomain)
2333
+ return [wwwDomain, apexDomain]
2334
+ })
2152
2335
  .filter(domain => shouldSyncCloudflareDomain(domain)),
2153
2336
  ))
2154
2337
  if (removeDomains.length) {
2155
2338
  await Promise.all(removeDomains.map(domain => removeCloudflarePagesDomain(domain, { orgId, siteId })))
2156
2339
  }
2157
- if (!syncDomains.length) {
2158
- if (!conflictDomains.length && siteData.domainError && !domainErrorChanged) {
2159
- await siteRef.set({ domainError: Firestore.FieldValue.delete() }, { merge: true })
2340
+
2341
+ const syncResults = await Promise.all(syncPlans.map(async (plan) => {
2342
+ const wwwResult = await addCloudflarePagesDomain(plan.wwwDomain, { orgId, siteId, variant: 'www' })
2343
+ let apexAttempted = false
2344
+ let apexResult = { ok: false, error: '' }
2345
+ if (shouldSyncCloudflareDomain(plan.apexDomain)) {
2346
+ apexAttempted = true
2347
+ apexResult = await addCloudflarePagesDomain(plan.apexDomain, { orgId, siteId, variant: 'apex' })
2348
+ }
2349
+ return {
2350
+ ...plan,
2351
+ apexAttempted,
2352
+ wwwResult,
2353
+ apexResult,
2354
+ }
2355
+ }))
2356
+
2357
+ for (const plan of syncResults) {
2358
+ const wwwAdded = !!plan.wwwResult?.ok
2359
+ const wwwError = wwwAdded ? '' : String(plan.wwwResult?.error || 'Failed to add www domain.')
2360
+ const apexAdded = !!plan.apexResult?.ok
2361
+ const apexError = apexAdded
2362
+ ? ''
2363
+ : (plan.apexAttempted ? String(plan.apexResult?.error || 'Failed to add apex domain.') : '')
2364
+
2365
+ for (const domain of plan.domains) {
2366
+ const current = registryStateByDomain.get(domain) || buildDomainDnsPayload(domain, pagesTarget)
2367
+ const dnsGuidance = !current.dnsEligible
2368
+ ? 'DNS records are not shown for localhost, IP addresses, or .dev domains.'
2369
+ : (apexAdded
2370
+ ? 'Apex and www were added to Cloudflare Pages. Add both DNS records if your provider requires manual setup.'
2371
+ : 'Add the www CNAME record. Apex is unavailable; forward apex to www.')
2372
+ const nextDnsRecords = {
2373
+ ...(current.dnsRecords || {}),
2374
+ apex: {
2375
+ ...(current?.dnsRecords?.apex || {}),
2376
+ enabled: !!current.dnsEligible && !!current?.dnsRecords?.apex?.value && apexAdded,
2377
+ },
2378
+ www: {
2379
+ ...(current?.dnsRecords?.www || {}),
2380
+ enabled: !!current.dnsEligible && !!current?.dnsRecords?.www?.value,
2381
+ },
2382
+ }
2383
+
2384
+ registryStateByDomain.set(domain, {
2385
+ ...current,
2386
+ dnsRecords: nextDnsRecords,
2387
+ wwwAdded,
2388
+ wwwError,
2389
+ apexAttempted: !!plan.apexAttempted,
2390
+ apexAdded,
2391
+ apexError,
2392
+ dnsGuidance,
2393
+ })
2160
2394
  }
2161
- return
2162
2395
  }
2163
2396
 
2164
- const results = await Promise.all(syncDomains.map(domain => addCloudflarePagesDomain(domain, { orgId, siteId })))
2165
- const failed = results
2166
- .map((result, index) => ({ result, domain: syncDomains[index] }))
2167
- .filter(item => !item.result?.ok)
2397
+ if (registryStateByDomain.size) {
2398
+ for (const [domain, value] of registryStateByDomain.entries()) {
2399
+ const registryRef = db.collection(DOMAIN_REGISTRY_COLLECTION).doc(domain)
2400
+ const payload = {
2401
+ domain,
2402
+ orgId,
2403
+ siteId,
2404
+ sitePath: siteRef.path,
2405
+ updatedAt: Firestore.FieldValue.serverTimestamp(),
2406
+ apexDomain: value.apexDomain || '',
2407
+ wwwDomain: value.wwwDomain || '',
2408
+ dnsEligible: !!value.dnsEligible,
2409
+ apexAttempted: !!value.apexAttempted,
2410
+ apexAdded: !!value.apexAdded,
2411
+ wwwAdded: !!value.wwwAdded,
2412
+ dnsRecords: value.dnsRecords || {},
2413
+ dnsGuidance: value.dnsGuidance || '',
2414
+ }
2415
+ payload.apexError = value.apexError ? value.apexError : Firestore.FieldValue.delete()
2416
+ payload.wwwError = value.wwwError ? value.wwwError : Firestore.FieldValue.delete()
2417
+ await registryRef.set(payload, { merge: true })
2418
+ }
2419
+ }
2168
2420
 
2421
+ const failed = syncResults.filter(item => !item.wwwResult?.ok)
2169
2422
  if (!failed.length) {
2170
- if (!conflictDomains.length && siteData.domainError && !domainErrorChanged) {
2423
+ if (!conflictDomains.length && siteData.domainError) {
2171
2424
  await siteRef.set({ domainError: Firestore.FieldValue.delete() }, { merge: true })
2172
2425
  }
2173
2426
  return
2174
2427
  }
2175
2428
 
2176
- const errorDomains = failed.map(item => item.domain)
2429
+ const errorDomains = failed.map(item => item.wwwDomain)
2177
2430
  const errorDetails = failed
2178
- .map(item => item.result?.error)
2431
+ .map(item => item.wwwResult?.error)
2179
2432
  .filter(Boolean)
2180
2433
  .join('; ')
2181
2434
  const cloudflareMessage = `Cloudflare domain sync failed for "${errorDomains.join(', ')}". ${errorDetails || 'Check function logs.'}`.trim()