@dotrino/identity 0.86.2 → 0.87.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
@@ -168,10 +168,20 @@ ceremonia, no seguridad. Cualquier **dato tuyo** —nombre, foto, correo, enlace
168
168
  cosa: hace falta que lo concedas a ESE origen, y lo concedido se guarda y se puede retirar.
169
169
 
170
170
  ```js
171
- await id.listGrants() // [{ origin, scopes, at }]
171
+ await id.listGrants() // [{ origin, scopes, at, lastUsed, onBehalfOf? }]
172
172
  await id.revokeGrant(origin) // y la próxima vez se vuelve a preguntar
173
173
  ```
174
174
 
175
+ `lastUsed` se apunta cada vez que ese origen pide una prueba, **aunque solo pida el
176
+ mínimo**: entrar es usar, lleve datos o no. Sin ese dato, «dónde se usó mi identidad» solo
177
+ podría decir qué concediste, no si sigue usándose — que es lo que hace que uno se decida a
178
+ retirar un permiso que ya no hace falta.
179
+
180
+ `onBehalfOf` lo declara el origen cuando pide **por otro** (lo usa el puente OIDC, que pide
181
+ por una aplicación de fuera; sin esto todas ellas se verían como una sola entrada). Es una
182
+ afirmación suya, no un hecho comprobable, así que se enseña siempre subordinada al origen —
183
+ «Tal App · a través de sso.dotrino.com»— y nunca en su lugar.
184
+
175
185
  **El panel lo pinta la bóveda, no la aplicación.** Vive en otro origen, así que la página
176
186
  que pide no puede pulsar ahí dentro ni leerlo; lo único que puede hacer es no mostrarlo, y
177
187
  entonces no consigue el permiso — que es el lado correcto en el que fallar. Sin nadie a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.86.2",
3
+ "version": "0.87.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.d.ts CHANGED
@@ -123,9 +123,9 @@ export class Identity {
123
123
  removeContact (publickey: string): Promise<PeerInfo | null>
124
124
  listContacts (): Promise<PeerInfo[]>
125
125
  signData (data: any): Promise<{ signature: string; publickey: string }>
126
- requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number }): Promise<Assertion>
126
+ requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number; onBehalfOf?: string }): Promise<Assertion>
127
127
  /** Qué le has concedido a cada aplicación (permiso por origen). */
128
- listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number }>>
128
+ listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number; lastUsed: number; onBehalfOf?: string }>>
129
129
  /** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
130
130
  revokeGrant (origin: string): Promise<{ ok: boolean }>
131
131
  setMyNickname (nickname: string): Promise<{ me: Me }>
package/src/index.js CHANGED
@@ -277,14 +277,14 @@ export class Identity {
277
277
  /** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
278
278
  async revokeGrant (origin) { return this._call('revokeGrant', { origin }) }
279
279
 
280
- async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
280
+ async requestAssertion ({ audience, nonce, scopes, ttlMs, onBehalfOf } = {}) {
281
281
  // ESPERA LO QUE TARDE UNA PERSONA. Los cinco segundos de siempre valen para una
282
282
  // llamada que solo hace cuentas; esta puede abrir la pantalla de permiso (§permiso por
283
283
  // origen) y ahí quien contesta es un humano. Con el plazo corto, el cliente se rendía
284
284
  // con «Vault timeout» mientras el usuario todavía miraba el panel — y pulsar «Permitir»
285
285
  // ya no servía de nada. El tope es un poco mayor que el del propio panel, para que el
286
286
  // error que llegue sea «no lo concedió», que dice lo que pasó.
287
- const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs }, 65000)
287
+ const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs, onBehalfOf }, 65000)
288
288
  return assertion
289
289
  }
290
290
 
package/vault/core.js CHANGED
@@ -1415,31 +1415,48 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1415
1415
  * lo que ya estuviera concedido y nada más. **No se amplía en silencio**: sin respuesta,
1416
1416
  * la respuesta es no.
1417
1417
  */
1418
- async function consentFor (origin, pedidos) {
1418
+ async function consentFor (origin, pedidos, onBehalfOf = null) {
1419
1419
  const org = String(origin || '').trim()
1420
+ // APUNTA CUÁNDO SE USÓ. Sin esto, «dónde se usó mi identidad» solo puede decir qué
1421
+ // concediste, no si sigue usándose — y eso es lo que hace que uno se decida a retirar
1422
+ // un permiso que ya no hace falta. Se apunta aunque solo se pida el mínimo: entrar es
1423
+ // usar, lleve datos o no.
1424
+ const marcarUso = () => {
1425
+ if (!org) return
1426
+ const g = loadGrants()
1427
+ const prev = g[org] || { scopes: [], at: Date.now() }
1428
+ g[org] = { ...prev, lastUsed: Date.now(), ...(onBehalfOf ? { onBehalfOf: String(onBehalfOf).slice(0, 60) } : {}) }
1429
+ saveGrants(g)
1430
+ }
1420
1431
  const base = pedidos.filter((s) => s === 'id:whoami')
1421
1432
  const extra = pedidos.filter((s) => s !== 'id:whoami')
1422
- if (!extra.length) return pedidos
1433
+ if (!extra.length) { marcarUso(); return pedidos }
1423
1434
  // Sin origen no se puede llevar la cuenta de a quién se le concedió qué, así que no se
1424
1435
  // concede nada más que el mínimo. Es el caso de Node y el de una llamada interna.
1425
1436
  if (!org) return base.length ? base : ['id:whoami']
1426
1437
 
1427
1438
  const yaTiene = grantedTo(org)
1428
1439
  const faltan = extra.filter((s) => !yaTiene.includes(s))
1429
- if (!faltan.length) return pedidos
1440
+ if (!faltan.length) { marcarUso(); return pedidos }
1430
1441
 
1431
1442
  if (typeof askConsent !== 'function') {
1432
1443
  const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
1444
+ marcarUso()
1433
1445
  return conocidos.length ? conocidos : ['id:whoami']
1434
1446
  }
1435
1447
  let ok = false
1436
- try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene })) } catch (_) { ok = false }
1448
+ try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene, onBehalfOf })) } catch (_) { ok = false }
1437
1449
  if (!ok) {
1438
1450
  const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
1451
+ marcarUso()
1439
1452
  return conocidos.length ? conocidos : ['id:whoami']
1440
1453
  }
1441
1454
  const g = loadGrants()
1442
- g[org] = { scopes: [...new Set([...yaTiene, ...faltan])].sort(), at: Date.now() }
1455
+ g[org] = {
1456
+ scopes: [...new Set([...yaTiene, ...faltan])].sort(),
1457
+ at: (g[org]?.at) || Date.now(), lastUsed: Date.now(),
1458
+ ...(onBehalfOf ? { onBehalfOf: String(onBehalfOf).slice(0, 60) } : {})
1459
+ }
1443
1460
  saveGrants(g)
1444
1461
  return pedidos
1445
1462
  }
@@ -1709,8 +1726,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1709
1726
  */
1710
1727
  async listGrants () {
1711
1728
  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)
1729
+ return Object.entries(g).map(([origin, v]) => ({
1730
+ origin, scopes: v?.scopes || [], at: v?.at || 0,
1731
+ lastUsed: v?.lastUsed || v?.at || 0,
1732
+ ...(v?.onBehalfOf ? { onBehalfOf: v.onBehalfOf } : {})
1733
+ })).sort((a, b) => b.lastUsed - a.lastUsed)
1714
1734
  },
1715
1735
  /** Retirar lo concedido a un origen. La próxima vez que pida, se vuelve a preguntar. */
1716
1736
  async revokeGrant ({ origin } = {}) {
@@ -1721,13 +1741,22 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1721
1741
  return { ok: true }
1722
1742
  },
1723
1743
 
1724
- async requestAssertion ({ audience, nonce, scopes, ttlMs, __origin } = {}) {
1744
+ /**
1745
+ * `onBehalfOf` es EN NOMBRE DE QUIÉN dice pedir el origen. Lo usa el puente OIDC, que
1746
+ * pide por una aplicación de fuera: sin esto, todas ellas se verían como una sola
1747
+ * entrada («Sso») y el usuario no sabría a quién le está dejando entrar.
1748
+ *
1749
+ * Es una AFIRMACIÓN del origen, no un hecho comprobable — y por eso se enseña siempre
1750
+ * subordinado a él («sso.dotrino.com dice: Tal App»), nunca en su lugar. Quien
1751
+ * responde por lo que pase sigue siendo el origen.
1752
+ */
1753
+ async requestAssertion ({ audience, nonce, scopes, ttlMs, onBehalfOf, __origin } = {}) {
1725
1754
  if (typeof audience !== 'string' || !audience.trim()) throw new Error('audience required')
1726
1755
  if (typeof nonce !== 'string' || !nonce) throw new Error('nonce required')
1727
1756
  const acta = loadActa()
1728
1757
  // A NOMBRE DE QUIÉN va: la identidad es el `profileId`, no la llave de este aparato.
1729
1758
  const sub = acta?.profileId || publickeyJwkStr
1730
- const granted = await consentFor(__origin, cleanScopes(scopes))
1759
+ const granted = await consentFor(__origin, cleanScopes(scopes), onBehalfOf)
1731
1760
  const permitido = claimsAllowed(granted)
1732
1761
  const claims = {}
1733
1762
  if (permitido.size) {
package/vault/vault.js CHANGED
@@ -67,8 +67,8 @@ import { pubkeyId } from './capabilities.js'
67
67
  const T_CONSENT = (() => {
68
68
  const en = (navigator.language || 'es').startsWith('en')
69
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.' }
70
+ ? { title: 'wants to see', allow: 'Allow', deny: 'No', who: 'Your Dotrino identity', once: 'Only what you allow leaves here.', via: 'through' }
71
+ : { title: 'quiere ver', allow: 'Permitir', deny: 'No', who: 'Tu identidad de Dotrino', once: 'De aquí solo sale lo que permitas.', via: 'a través de' }
72
72
  })()
73
73
  const SCOPE_TXT = (() => {
74
74
  const en = (navigator.language || 'es').startsWith('en')
@@ -85,6 +85,9 @@ import { pubkeyId } from './capabilities.js'
85
85
  * No se importa el catálogo de aplicaciones: eso ataría este iframe al repositorio del
86
86
  * home y habría que subirlo cada vez que nace una app. El subdominio ya es el nombre.
87
87
  */
88
+ /** Lo que dice el origen se pinta como texto, nunca como marcado: quien lo escribe es él. */
89
+ const escaparTexto = (t) => String(t).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
90
+
88
91
  function nombreDeOrigen (origin) {
89
92
  try {
90
93
  const h = new URL(origin).hostname
@@ -96,7 +99,7 @@ import { pubkeyId } from './capabilities.js'
96
99
  }
97
100
 
98
101
  let consentAbierto = null
99
- function askConsent ({ origin, scopes }) {
102
+ function askConsent ({ origin, scopes, onBehalfOf }) {
100
103
  if (consentAbierto) return Promise.resolve(false) // una pregunta a la vez
101
104
  return new Promise((resolve) => {
102
105
  const host = document.createElement('div')
@@ -104,8 +107,8 @@ import { pubkeyId } from './capabilities.js'
104
107
  const lista = scopes.map((x) => `<li>${SCOPE_TXT[x] || x}</li>`).join('')
105
108
  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
109
  <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>
110
+ <div style="font-weight:700;margin:8px 0 4px">${onBehalfOf ? escaparTexto(onBehalfOf) : nombreDeOrigen(origin)} ${T_CONSENT.title}:</div>
111
+ <div style="opacity:.55;font-size:12px;margin-bottom:6px">${onBehalfOf ? T_CONSENT.via + ' ' : ''}${String(origin).replace(/^https?:\/\//, '')}</div>
109
112
  <ul style="margin:6px 0 12px 18px;padding:0">${lista}</ul>
110
113
  <div style="opacity:.7;font-size:12px;margin-bottom:12px">${T_CONSENT.once}</div>
111
114
  <div style="display:flex;gap:8px">