@dotrino/identity 0.85.0 → 0.86.1
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 +18 -0
- package/package.json +1 -1
- package/src/index.d.ts +4 -0
- package/src/index.js +21 -0
- package/vault/core.js +83 -3
- package/vault/vault.js +74 -2
package/README.md
CHANGED
|
@@ -160,6 +160,24 @@ const v = await verifySignedFor({ data: pin, signature, publickey, chain, audien
|
|
|
160
160
|
`aud` es obligatorio igual que en la prueba; `exp` es opcional, porque la caducidad de lo
|
|
161
161
|
publicado la lleva el servicio (el TTL del pin) y no el cuerpo.
|
|
162
162
|
|
|
163
|
+
### Permiso por origen (0.86.0+)
|
|
164
|
+
|
|
165
|
+
Una prueba dice **quién eres** sin preguntar nada: quien llega al iframe ya pasó el filtro
|
|
166
|
+
de orígenes, y pedir permiso treinta veces al día por aplicaciones del mismo dueño es
|
|
167
|
+
ceremonia, no seguridad. Cualquier **dato tuyo** —nombre, foto, correo, enlaces— es otra
|
|
168
|
+
cosa: hace falta que lo concedas a ESE origen, y lo concedido se guarda y se puede retirar.
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
await id.listGrants() // [{ origin, scopes, at }]
|
|
172
|
+
await id.revokeGrant(origin) // y la próxima vez se vuelve a preguntar
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**El panel lo pinta la bóveda, no la aplicación.** Vive en otro origen, así que la página
|
|
176
|
+
que pide no puede pulsar ahí dentro ni leerlo; lo único que puede hacer es no mostrarlo, y
|
|
177
|
+
entonces no consigue el permiso — que es el lado correcto en el que fallar. Sin nadie a
|
|
178
|
+
quien preguntar (Node, o un cliente que no muestra nada), la respuesta es **no**: nunca se
|
|
179
|
+
amplía en silencio.
|
|
180
|
+
|
|
163
181
|
### Entrar sin enrolar: las sesiones (0.85.0+)
|
|
164
182
|
|
|
165
183
|
Enlazar un aparato y entrar en uno no son lo mismo. Enlazar mete una llave en el acta —hay
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -124,6 +124,10 @@ export class Identity {
|
|
|
124
124
|
listContacts (): Promise<PeerInfo[]>
|
|
125
125
|
signData (data: any): Promise<{ signature: string; publickey: string }>
|
|
126
126
|
requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number }): Promise<Assertion>
|
|
127
|
+
/** Qué le has concedido a cada aplicación (permiso por origen). */
|
|
128
|
+
listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number }>>
|
|
129
|
+
/** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
|
|
130
|
+
revokeGrant (origin: string): Promise<{ ok: boolean }>
|
|
127
131
|
setMyNickname (nickname: string): Promise<{ me: Me }>
|
|
128
132
|
getEncryptionPubkey (): Promise<string>
|
|
129
133
|
encrypt (recipients: EncryptRecipient[], plaintext: string): Promise<EnvelopeV1>
|
package/src/index.js
CHANGED
|
@@ -146,6 +146,12 @@ export class Identity {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
if (msg.type === 'event') {
|
|
149
|
+
// MOSTRAR EL IFRAME PARA QUE PREGUNTE. El panel de permiso lo pinta la bóveda,
|
|
150
|
+
// en su propio origen: esta página no puede pulsar ahí dentro ni leerlo. Lo
|
|
151
|
+
// único que hace aquí es dejar sitio. Si una aplicación decidiera no hacerlo,
|
|
152
|
+
// no obtiene el permiso — que es el lado correcto en el que fallar.
|
|
153
|
+
if (msg.event === 'consent:open') this._showVault(true)
|
|
154
|
+
if (msg.event === 'consent:close') this._showVault(false)
|
|
149
155
|
this._emit(msg.event, msg.payload)
|
|
150
156
|
}
|
|
151
157
|
}
|
|
@@ -157,6 +163,16 @@ export class Identity {
|
|
|
157
163
|
return this._ready
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
/** Deja ver la bóveda (a pantalla completa) mientras pregunta, y la devuelve a su sitio. */
|
|
167
|
+
_showVault (visible) {
|
|
168
|
+
const f = this._iframe
|
|
169
|
+
if (!f) return
|
|
170
|
+
f.style.cssText = visible
|
|
171
|
+
? 'position:fixed;inset:0;width:100%;height:100%;border:0;z-index:2147483000'
|
|
172
|
+
: 'display:none'
|
|
173
|
+
f.setAttribute('aria-hidden', visible ? 'false' : 'true')
|
|
174
|
+
}
|
|
175
|
+
|
|
160
176
|
destroy () {
|
|
161
177
|
if (this._handler) window.removeEventListener('message', this._handler)
|
|
162
178
|
if (this._iframe && this._iframe.parentNode) this._iframe.parentNode.removeChild(this._iframe)
|
|
@@ -256,6 +272,11 @@ export class Identity {
|
|
|
256
272
|
* @returns {Promise<object>} la prueba, lista para mandar. Se comprueba con
|
|
257
273
|
* `verifyAssertion(prueba, { audience, nonce })`.
|
|
258
274
|
*/
|
|
275
|
+
/** Qué le has concedido a cada aplicación. Sin esto, conceder no significaría nada. */
|
|
276
|
+
async listGrants () { return this._call('listGrants') }
|
|
277
|
+
/** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
|
|
278
|
+
async revokeGrant (origin) { return this._call('revokeGrant', { origin }) }
|
|
279
|
+
|
|
259
280
|
async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
|
|
260
281
|
const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs })
|
|
261
282
|
return assertion
|
package/vault/core.js
CHANGED
|
@@ -37,6 +37,7 @@ export const ACTA_STORAGE = 'dotrino.identity.acta' // acta de p
|
|
|
37
37
|
export const ACTA_HISTORY_STORAGE = 'dotrino.identity.acta.history' // últimas actas selladas (§1.3)
|
|
38
38
|
export const PENDING_JOIN_STORAGE = 'dotrino.identity.pendingJoin' // «nací para adoptar la cuenta de otro»
|
|
39
39
|
export const RENOUNCE_STORAGE = 'dotrino.identity.renounced' // renuncias propias aún no absorbidas por el master
|
|
40
|
+
export const GRANTS_STORAGE = 'dotrino.identity.grants' // qué le concediste a cada origen (permiso por origen)
|
|
40
41
|
// Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
|
|
41
42
|
// namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
|
|
42
43
|
export const PROFILES_STORAGE = 'dotrino.identity.profiles' // [{ id, name, pubkey }]
|
|
@@ -183,7 +184,15 @@ function sanitizeProfilePatch (patch = {}) {
|
|
|
183
184
|
return out
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
|
|
187
|
+
/**
|
|
188
|
+
* `askConsent` es CÓMO SE PREGUNTA, y lo pone quien monta el núcleo (el iframe pinta su
|
|
189
|
+
* propio panel; en Node no hay a quién preguntar). Se inyecta en vez de vivir aquí porque
|
|
190
|
+
* este módulo no toca interfaz — y porque QUIÉN pinta importa: un panel dibujado por la
|
|
191
|
+
* aplicación que pide sería la aplicación aprobándose a sí misma.
|
|
192
|
+
*
|
|
193
|
+
* Si no se inyecta, no se concede nada nuevo: sin forma de preguntar, la respuesta es no.
|
|
194
|
+
*/
|
|
195
|
+
export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null, askConsent = null }) {
|
|
187
196
|
const {
|
|
188
197
|
initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
|
|
189
198
|
} = peers
|
|
@@ -594,6 +603,16 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
594
603
|
// Diseño en `dotrino-vault/docs/acta-de-perfil.md`. Aquí solo se guarda, se lee y se
|
|
595
604
|
// sella; las reglas (sellador único, seq/prev, no dejar el perfil sin firmante) viven en
|
|
596
605
|
// `acta.js`, que es puro y está probado aparte.
|
|
606
|
+
// ----- PERMISO POR ORIGEN: qué le concedió el usuario a cada aplicación -----
|
|
607
|
+
//
|
|
608
|
+
// `{ [origin]: { scopes: [...], at } }`. Vive en el kv del PERFIL, así que cambiar de
|
|
609
|
+
// perfil cambia lo concedido: lo que le diste a una aplicación desde tu cuenta de trabajo
|
|
610
|
+
// no vale para la personal.
|
|
611
|
+
const loadGrants = () => { try { return JSON.parse(kv.getItem(GRANTS_STORAGE) || '{}') } catch (_) { return {} } }
|
|
612
|
+
const saveGrants = (g) => { try { kv.setItem(GRANTS_STORAGE, JSON.stringify(g)) } catch (_) {} }
|
|
613
|
+
/** Lo concedido a un origen, hoy. */
|
|
614
|
+
const grantedTo = (origin) => (loadGrants()[String(origin || '')]?.scopes) || []
|
|
615
|
+
|
|
597
616
|
const loadActa = () => { try { return JSON.parse(kv.getItem(ACTA_STORAGE) || 'null') } catch (_) { return null } }
|
|
598
617
|
const saveActa = (a) => kv.setItem(ACTA_STORAGE, JSON.stringify(a))
|
|
599
618
|
// VENTANA DE RETENCIÓN (§1.3): el master conserva las últimas actas para que un miembro
|
|
@@ -1382,6 +1401,49 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1382
1401
|
'profileActa', 'profileMembers', 'myMembership', 'isMaster', 'sealerChain'
|
|
1383
1402
|
])
|
|
1384
1403
|
|
|
1404
|
+
/**
|
|
1405
|
+
* ¿QUÉ SE LE DEJA VER A ESTE ORIGEN? El corazón del permiso por origen.
|
|
1406
|
+
*
|
|
1407
|
+
* `id:whoami` —solo quién eres, sin ningún dato— se concede sin preguntar: quien llega
|
|
1408
|
+
* aquí ya pasó el filtro de orígenes del iframe, y preguntarlo treinta veces al día por
|
|
1409
|
+
* aplicaciones del mismo dueño es ceremonia, no seguridad.
|
|
1410
|
+
*
|
|
1411
|
+
* Todo lo DEMÁS (nombre, foto, correo, redes) exige una concesión guardada, y si no la
|
|
1412
|
+
* hay se pregunta. Lo que el usuario diga se guarda por origen y se puede retirar.
|
|
1413
|
+
*
|
|
1414
|
+
* Y si no hay a quién preguntar —Node, o un cliente que no muestra el panel— se devuelve
|
|
1415
|
+
* lo que ya estuviera concedido y nada más. **No se amplía en silencio**: sin respuesta,
|
|
1416
|
+
* la respuesta es no.
|
|
1417
|
+
*/
|
|
1418
|
+
async function consentFor (origin, pedidos) {
|
|
1419
|
+
const org = String(origin || '').trim()
|
|
1420
|
+
const base = pedidos.filter((s) => s === 'id:whoami')
|
|
1421
|
+
const extra = pedidos.filter((s) => s !== 'id:whoami')
|
|
1422
|
+
if (!extra.length) return pedidos
|
|
1423
|
+
// Sin origen no se puede llevar la cuenta de a quién se le concedió qué, así que no se
|
|
1424
|
+
// concede nada más que el mínimo. Es el caso de Node y el de una llamada interna.
|
|
1425
|
+
if (!org) return base.length ? base : ['id:whoami']
|
|
1426
|
+
|
|
1427
|
+
const yaTiene = grantedTo(org)
|
|
1428
|
+
const faltan = extra.filter((s) => !yaTiene.includes(s))
|
|
1429
|
+
if (!faltan.length) return pedidos
|
|
1430
|
+
|
|
1431
|
+
if (typeof askConsent !== 'function') {
|
|
1432
|
+
const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
|
|
1433
|
+
return conocidos.length ? conocidos : ['id:whoami']
|
|
1434
|
+
}
|
|
1435
|
+
let ok = false
|
|
1436
|
+
try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene })) } catch (_) { ok = false }
|
|
1437
|
+
if (!ok) {
|
|
1438
|
+
const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
|
|
1439
|
+
return conocidos.length ? conocidos : ['id:whoami']
|
|
1440
|
+
}
|
|
1441
|
+
const g = loadGrants()
|
|
1442
|
+
g[org] = { scopes: [...new Set([...yaTiene, ...faltan])].sort(), at: Date.now() }
|
|
1443
|
+
saveGrants(g)
|
|
1444
|
+
return pedidos
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1385
1447
|
const handlers = {
|
|
1386
1448
|
async profileLockStatus () {
|
|
1387
1449
|
refreshLockState()
|
|
@@ -1641,13 +1703,31 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1641
1703
|
* (`dotrino-vault/docs/inicio-de-sesion.md`); hasta entonces esto no entrega nada que
|
|
1642
1704
|
* no se entregue ya, que es la forma de no adelantar una decisión del usuario.
|
|
1643
1705
|
*/
|
|
1644
|
-
|
|
1706
|
+
/**
|
|
1707
|
+
* QUÉ LE HAS CONCEDIDO A CADA APLICACIÓN. Es la mitad que hace que el permiso sea del
|
|
1708
|
+
* usuario y no un trámite: si no se puede ver ni retirar, conceder no significa nada.
|
|
1709
|
+
*/
|
|
1710
|
+
async listGrants () {
|
|
1711
|
+
const g = loadGrants()
|
|
1712
|
+
return Object.entries(g).map(([origin, v]) => ({ origin, scopes: v?.scopes || [], at: v?.at || 0 }))
|
|
1713
|
+
.sort((a, b) => b.at - a.at)
|
|
1714
|
+
},
|
|
1715
|
+
/** Retirar lo concedido a un origen. La próxima vez que pida, se vuelve a preguntar. */
|
|
1716
|
+
async revokeGrant ({ origin } = {}) {
|
|
1717
|
+
const g = loadGrants()
|
|
1718
|
+
const org = String(origin || '')
|
|
1719
|
+
if (!(org in g)) return { ok: false }
|
|
1720
|
+
delete g[org]; saveGrants(g)
|
|
1721
|
+
return { ok: true }
|
|
1722
|
+
},
|
|
1723
|
+
|
|
1724
|
+
async requestAssertion ({ audience, nonce, scopes, ttlMs, __origin } = {}) {
|
|
1645
1725
|
if (typeof audience !== 'string' || !audience.trim()) throw new Error('audience required')
|
|
1646
1726
|
if (typeof nonce !== 'string' || !nonce) throw new Error('nonce required')
|
|
1647
1727
|
const acta = loadActa()
|
|
1648
1728
|
// A NOMBRE DE QUIÉN va: la identidad es el `profileId`, no la llave de este aparato.
|
|
1649
1729
|
const sub = acta?.profileId || publickeyJwkStr
|
|
1650
|
-
const granted = cleanScopes(scopes)
|
|
1730
|
+
const granted = await consentFor(__origin, cleanScopes(scopes))
|
|
1651
1731
|
const permitido = claimsAllowed(granted)
|
|
1652
1732
|
const claims = {}
|
|
1653
1733
|
if (permitido.size) {
|
package/vault/vault.js
CHANGED
|
@@ -55,12 +55,81 @@ import { pubkeyId } from './capabilities.js'
|
|
|
55
55
|
removeItem: (k) => sessionStorage.removeItem(k)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* EL PANEL DE PERMISO LO PINTA ESTE IFRAME, no la aplicación que pide.
|
|
60
|
+
*
|
|
61
|
+
* Es la diferencia entre un permiso y un trámite: la aplicación vive en otro origen, así
|
|
62
|
+
* que no puede pulsar aquí dentro ni leer lo que hay. Lo único que puede hacer es NO
|
|
63
|
+
* mostrarnos —y entonces no consigue el permiso, que es el lado correcto en el que
|
|
64
|
+
* fallar—. Por eso se le pide que nos muestre (`consent:open`) y, si no lo hace, la
|
|
65
|
+
* pregunta se queda sin responder y se deniega sola.
|
|
66
|
+
*/
|
|
67
|
+
const T_CONSENT = (() => {
|
|
68
|
+
const en = (navigator.language || 'es').startsWith('en')
|
|
69
|
+
return en
|
|
70
|
+
? { title: 'wants to see', allow: 'Allow', deny: 'No', who: 'Your Dotrino identity', once: 'Only what you allow leaves here.' }
|
|
71
|
+
: { title: 'quiere ver', allow: 'Permitir', deny: 'No', who: 'Tu identidad de Dotrino', once: 'De aquí solo sale lo que permitas.' }
|
|
72
|
+
})()
|
|
73
|
+
const SCOPE_TXT = (() => {
|
|
74
|
+
const en = (navigator.language || 'es').startsWith('en')
|
|
75
|
+
return en
|
|
76
|
+
? { 'profile:name': 'your name', 'profile:avatar': 'your picture', 'profile:email': 'your email', 'profile:social': 'your links', 'id:whoami': 'who you are' }
|
|
77
|
+
: { 'profile:name': 'tu nombre', 'profile:avatar': 'tu foto', 'profile:email': 'tu correo', 'profile:social': 'tus enlaces', 'id:whoami': 'quién eres' }
|
|
78
|
+
})()
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* CÓMO SE LLAMA QUIEN PIDE. Una dirección cruda no dice nada —y quien la lee deprisa no
|
|
82
|
+
* distingue `chat.dotrino.com` de `chat.dotrlno.com`—, así que se enseña el nombre y
|
|
83
|
+
* DEBAJO la dirección entera, que es lo que de verdad identifica.
|
|
84
|
+
*
|
|
85
|
+
* No se importa el catálogo de aplicaciones: eso ataría este iframe al repositorio del
|
|
86
|
+
* home y habría que subirlo cada vez que nace una app. El subdominio ya es el nombre.
|
|
87
|
+
*/
|
|
88
|
+
function nombreDeOrigen (origin) {
|
|
89
|
+
try {
|
|
90
|
+
const h = new URL(origin).hostname
|
|
91
|
+
if (h === 'dotrino.com' || h === 'www.dotrino.com') return 'Dotrino'
|
|
92
|
+
const m = /^([a-z0-9-]+)\.dotrino\.com$/.exec(h)
|
|
93
|
+
if (m) return m[1].charAt(0).toUpperCase() + m[1].slice(1)
|
|
94
|
+
return h
|
|
95
|
+
} catch (_) { return String(origin) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let consentAbierto = null
|
|
99
|
+
function askConsent ({ origin, scopes }) {
|
|
100
|
+
if (consentAbierto) return Promise.resolve(false) // una pregunta a la vez
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const host = document.createElement('div')
|
|
103
|
+
host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(10,8,20,.86);font-family:system-ui,-apple-system,Segoe UI,sans-serif'
|
|
104
|
+
const lista = scopes.map((x) => `<li>${SCOPE_TXT[x] || x}</li>`).join('')
|
|
105
|
+
host.innerHTML = `<div style="background:#171331;border:1px solid #2a2350;border-radius:16px;padding:22px;min-width:min(320px,90vw);max-width:90vw;color:#e7e3ff">
|
|
106
|
+
<div style="opacity:.7;font-size:13px">${T_CONSENT.who}</div>
|
|
107
|
+
<div style="font-weight:700;margin:8px 0 4px">${nombreDeOrigen(origin)} ${T_CONSENT.title}:</div>
|
|
108
|
+
<div style="opacity:.55;font-size:12px;margin-bottom:6px">${String(origin).replace(/^https?:\/\//, '')}</div>
|
|
109
|
+
<ul style="margin:6px 0 12px 18px;padding:0">${lista}</ul>
|
|
110
|
+
<div style="opacity:.7;font-size:12px;margin-bottom:12px">${T_CONSENT.once}</div>
|
|
111
|
+
<div style="display:flex;gap:8px">
|
|
112
|
+
<button data-yes style="flex:1;padding:10px;border-radius:10px;border:0;background:#7c3aed;color:#fff;font:inherit;font-weight:600;cursor:pointer">${T_CONSENT.allow}</button>
|
|
113
|
+
<button data-no style="flex:1;padding:10px;border-radius:10px;border:1px solid #2a2350;background:transparent;color:inherit;font:inherit;cursor:pointer">${T_CONSENT.deny}</button>
|
|
114
|
+
</div></div>`
|
|
115
|
+
const cerrar = (v) => { try { host.remove() } catch (_) {} consentAbierto = null; broadcast('consent:close', {}); resolve(v) }
|
|
116
|
+
host.querySelector('[data-yes]').addEventListener('click', () => cerrar(true))
|
|
117
|
+
host.querySelector('[data-no]').addEventListener('click', () => cerrar(false))
|
|
118
|
+
consentAbierto = host
|
|
119
|
+
document.body.appendChild(host)
|
|
120
|
+
broadcast('consent:open', { origin })
|
|
121
|
+
// Si nadie contesta —porque nadie nos mostró—, se deniega. Nunca al revés.
|
|
122
|
+
setTimeout(() => { if (consentAbierto === host) cerrar(false) }, 60000)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
58
126
|
const core = await createIdentityCore({
|
|
59
127
|
kv,
|
|
60
128
|
peers: { initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty },
|
|
61
129
|
makeSync: createSync,
|
|
62
130
|
keyStore,
|
|
63
|
-
sessionKv
|
|
131
|
+
sessionKv,
|
|
132
|
+
askConsent
|
|
64
133
|
})
|
|
65
134
|
|
|
66
135
|
const { handlers } = core
|
|
@@ -290,7 +359,10 @@ import { pubkeyId } from './capabilities.js'
|
|
|
290
359
|
const handler = selfHandlers[method] || handlers[method]
|
|
291
360
|
if (!handler) return reply({ error: `Unknown method: ${method}` })
|
|
292
361
|
try {
|
|
293
|
-
|
|
362
|
+
// EL ORIGEN LO PONE EL IFRAME, no quien llama: va pisado a propósito. Es el único
|
|
363
|
+
// dato que la aplicación no puede falsificar —el navegador lo garantiza— y de él
|
|
364
|
+
// depende a quién se le concedió qué.
|
|
365
|
+
const result = await handler({ ...(params || {}), __origin: event.origin })
|
|
294
366
|
reply({ result })
|
|
295
367
|
} catch (e) {
|
|
296
368
|
// `code` (y su `detail`) CRUZAN. Sin ellos, al otro lado solo llegaba la frase, y una
|