@microteia/console 2.10.0 → 2.19.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/CONSOLE-VERSIONS.md +57 -0
- package/README.md +1 -1
- package/lib/console/appliedFunctions.d.ts +114 -0
- package/lib/console/appliedFunctions.js +123 -0
- package/lib/console/backendResidue.d.ts +55 -0
- package/lib/console/backendResidue.js +53 -0
- package/lib/console/customFunctions.d.ts +51 -1
- package/lib/console/customFunctions.js +93 -1
- package/lib/console/deploy.js +31 -7
- package/lib/console/environment.d.ts +7 -6
- package/lib/console/firestoreRest.d.ts +27 -0
- package/lib/console/firestoreRest.js +23 -0
- package/lib/console/functionGate.d.ts +228 -0
- package/lib/console/functionGate.js +437 -0
- package/lib/console/provision.d.ts +26 -13
- package/lib/console/provision.js +393 -58
- package/lib/kernel/backendShape.d.ts +27 -0
- package/lib/kernel/backendShape.js +38 -0
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/packageTemplates.d.ts +12 -1
- package/lib/vendor/packageTemplates.js +42 -2
- package/package.json +1 -1
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O portão de instalação, conferido na FONTE.
|
|
3
|
+
*
|
|
4
|
+
* `forInstalledExtension` é a metade da retirada que age no instante certo: a Cloud Function é do
|
|
5
|
+
* PROJETO e a instalação da extensão é do ESCRITÓRIO, então um `onDocumentWritten('tenants/{tenantId}/…')`
|
|
6
|
+
* declarado por uma extensão dispara para TODOS os escritórios do projeto — os que nunca a instalaram, e
|
|
7
|
+
* o que a desinstalou enquanto outro a mantém. Nesse último caso `--prune` não pode ajudar: a function
|
|
8
|
+
* tem de continuar no ar, porque alguém ainda precisa dela.
|
|
9
|
+
*
|
|
10
|
+
* Só que nada cobrava o portão. O `create function` o escrevia no molde, e era só isso: nenhuma
|
|
11
|
+
* verificação do empacotamento, do deploy ou do provisionamento olhava a fonte. Um gatilho sem portão
|
|
12
|
+
* passava por tudo e ia para o ar — e o sintoma dele é o pior que existe, porque não é uma falha: é
|
|
13
|
+
* código de uma extensão que ninguém tem mais rodando sobre os dados de quem nunca a pediu, sem erro em
|
|
14
|
+
* lugar nenhum.
|
|
15
|
+
*
|
|
16
|
+
* É recusa, e não aviso, pelo mesmo motivo do nome de function repetido: o defeito é invisível depois de
|
|
17
|
+
* instalado, e um aviso na saída de um deploy é lido uma vez. Mas ela é cobrada da function que VAI
|
|
18
|
+
* SUBIR, e não de toda fonte que existe no disco — ver `gateProblemsFor`. Toda origem é lida, sempre, e
|
|
19
|
+
* o que não vai subir é relatado pelo `provision`: quem tem a fonte parada ao lado do `microteia.yaml`
|
|
20
|
+
* fica sabendo dela, sem ter o comando trancado por ela.
|
|
21
|
+
*
|
|
22
|
+
* A análise é por texto, no idioma dos outros leitores de fonte do Console (`callableFunctionNames`,
|
|
23
|
+
* `declaredParametersIn`). Ela erra para o lado ALTO — o que ela não entende ela recusa —, e é por isso
|
|
24
|
+
* que o portão declarado num identificador ao lado é reconhecido: recusar código correto é o que ensina
|
|
25
|
+
* a desligar a verificação.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Os construtores de gatilho de Firestore.
|
|
29
|
+
*
|
|
30
|
+
* São os únicos eventos cujo caminho carrega `{tenantId}`, e portanto os únicos para os quais o portão
|
|
31
|
+
* existe. Um `onSchedule` ou um `onMessagePublished` não tem escritório de onde perguntar — envolvê-los
|
|
32
|
+
* no portão os deixaria permanentemente inertes, porque ele falha FECHADO. Eles ficam de fora desta
|
|
33
|
+
* verificação de propósito: exigir deles um portão que não funciona seria trocar um defeito por outro.
|
|
34
|
+
*/
|
|
35
|
+
export const TENANT_TRIGGER_CONSTRUCTORS = [
|
|
36
|
+
'onDocumentWritten',
|
|
37
|
+
'onDocumentCreated',
|
|
38
|
+
'onDocumentUpdated',
|
|
39
|
+
'onDocumentDeleted',
|
|
40
|
+
'onDocumentWrittenWithAuthContext',
|
|
41
|
+
'onDocumentCreatedWithAuthContext',
|
|
42
|
+
'onDocumentUpdatedWithAuthContext',
|
|
43
|
+
'onDocumentDeletedWithAuthContext',
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Os construtores cujo evento NÃO carrega escritório no caminho.
|
|
47
|
+
*
|
|
48
|
+
* Para eles as duas saídas do autor eram erradas, e é isso que faz deles uma recusa e não um aviso: sem
|
|
49
|
+
* portão a function age sobre o projeto inteiro; COM portão ela nunca roda — nem enquanto a extensão está
|
|
50
|
+
* instalada —, porque `forInstalledExtension` falha FECHADO e nunca acha o `{tenantId}` que não existe.
|
|
51
|
+
* Um autor que seguisse a orientação genérica "envolva o handler no portão" produzia uma function que
|
|
52
|
+
* passava no deploy, não acusava nada e nunca fazia nada.
|
|
53
|
+
*
|
|
54
|
+
* A resposta é o laço por escritório (`forEachInstalledOffice`), e é por existir uma resposta que a
|
|
55
|
+
* recusa é honesta.
|
|
56
|
+
*
|
|
57
|
+
* A lista é EXPLÍCITA, e não "tudo o que não é gatilho de documento": o que não se conhece não se recusa.
|
|
58
|
+
* O RTDB fica de fora de propósito — o caminho dele TEM parâmetros, então um `{tenantId}` ali é possível
|
|
59
|
+
* —, e a plataforma não usa RTDB em lugar nenhum.
|
|
60
|
+
*/
|
|
61
|
+
export const UNSCOPED_TRIGGER_CONSTRUCTORS = [
|
|
62
|
+
'onSchedule',
|
|
63
|
+
'onMessagePublished',
|
|
64
|
+
'onRequest',
|
|
65
|
+
'onObjectFinalized',
|
|
66
|
+
'onObjectArchived',
|
|
67
|
+
'onObjectDeleted',
|
|
68
|
+
'onObjectMetadataUpdated',
|
|
69
|
+
'onCustomEventPublished',
|
|
70
|
+
'onTaskDispatched',
|
|
71
|
+
'beforeUserCreated',
|
|
72
|
+
'beforeUserSignedIn',
|
|
73
|
+
];
|
|
74
|
+
const GATE = 'forInstalledExtension';
|
|
75
|
+
const OFFICE_LOOP = 'forEachInstalledOffice';
|
|
76
|
+
const ANY_GATE = 'forAnyInstalledExtension';
|
|
77
|
+
const CALLABLE_GUARD = 'requireInstalledExtension';
|
|
78
|
+
/** Onde uma string termina, a partir da abertura dela. Aspas não fechada não engole o resto do arquivo. */
|
|
79
|
+
const stringEnd = (source, start, quote) => {
|
|
80
|
+
let index = start + 1;
|
|
81
|
+
while (index < source.length) {
|
|
82
|
+
const char = source[index];
|
|
83
|
+
if (char === '\\') {
|
|
84
|
+
index += 2;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (char === quote)
|
|
88
|
+
return index + 1;
|
|
89
|
+
if (quote !== '`' && char === '\n')
|
|
90
|
+
return index;
|
|
91
|
+
index += 1;
|
|
92
|
+
}
|
|
93
|
+
return source.length;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* A fonte sem comentário nenhum, com as strings intactas.
|
|
97
|
+
*
|
|
98
|
+
* O JSDoc que o molde escreve CITA `forInstalledExtension` — é ali que ele explica o portão. Sem esta
|
|
99
|
+
* limpeza, um gatilho que perdesse a chamada e mantivesse o comentário passaria na verificação, o que é
|
|
100
|
+
* o caso exato que ela existe para pegar.
|
|
101
|
+
*/
|
|
102
|
+
export const withoutComments = (source) => {
|
|
103
|
+
let out = '';
|
|
104
|
+
let index = 0;
|
|
105
|
+
while (index < source.length) {
|
|
106
|
+
const pair = source.slice(index, index + 2);
|
|
107
|
+
if (pair === '//') {
|
|
108
|
+
const end = source.indexOf('\n', index);
|
|
109
|
+
index = end === -1 ? source.length : end;
|
|
110
|
+
out += ' ';
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (pair === '/*') {
|
|
114
|
+
const end = source.indexOf('*/', index + 2);
|
|
115
|
+
index = end === -1 ? source.length : end + 2;
|
|
116
|
+
out += ' ';
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const char = source[index];
|
|
120
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
121
|
+
const end = stringEnd(source, index, char);
|
|
122
|
+
out += source.slice(index, end);
|
|
123
|
+
index = end;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
out += char;
|
|
127
|
+
index += 1;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* O texto entre os parênteses da chamada que abre em `open`. Conta os parênteses e pula as strings: um
|
|
133
|
+
* `console.log('(')` no corpo do handler desequilibraria a contagem, e o efeito seria recusar uma
|
|
134
|
+
* function correta.
|
|
135
|
+
*/
|
|
136
|
+
const callArguments = (source, open) => {
|
|
137
|
+
let depth = 0;
|
|
138
|
+
let index = open;
|
|
139
|
+
while (index < source.length) {
|
|
140
|
+
const char = source[index];
|
|
141
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
142
|
+
index = stringEnd(source, index, char);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (char === '(')
|
|
146
|
+
depth += 1;
|
|
147
|
+
else if (char === ')') {
|
|
148
|
+
depth -= 1;
|
|
149
|
+
if (depth === 0)
|
|
150
|
+
return source.slice(open + 1, index);
|
|
151
|
+
}
|
|
152
|
+
index += 1;
|
|
153
|
+
}
|
|
154
|
+
return source.slice(open + 1);
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* O id que o portão daquele gatilho recebeu, ou `null` quando não há portão.
|
|
158
|
+
*
|
|
159
|
+
* Duas formas valem, e a segunda não é indulgência: `onDocumentWritten(opts, gated)` com
|
|
160
|
+
* `const gated = forInstalledExtension('id', handler)` ao lado é código correto e comum quando o handler
|
|
161
|
+
* cresce. Recusá-lo seria a verificação errando contra quem fez certo.
|
|
162
|
+
*/
|
|
163
|
+
const gatedFor = (args, source) => {
|
|
164
|
+
const direct = args.match(new RegExp(String.raw `\b${GATE}\s*\(\s*['"]([^'"]*)['"]`));
|
|
165
|
+
if (direct !== null)
|
|
166
|
+
return direct[1];
|
|
167
|
+
for (const token of new Set(args.match(/[A-Za-z_$][\w$]*/g) ?? [])) {
|
|
168
|
+
const indirect = source.match(new RegExp(String.raw `(?:const|let|var)\s+${token}\s*(?::[^=;\n]*)?=\s*${GATE}\s*\(\s*['"]([^'"]*)['"]`));
|
|
169
|
+
if (indirect !== null)
|
|
170
|
+
return indirect[1];
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Todos os ids que as guardas daquele gatilho recebem.
|
|
176
|
+
*
|
|
177
|
+
* A function COMUM usa `forAnyInstalledExtension(['a', 'b'], handler)`, cuja pergunta é outra — "alguma
|
|
178
|
+
* das que eu sirvo está instalada?" —, então ela devolve mais de um id. A de extensão continua respondida
|
|
179
|
+
* por `gatedFor`, que devolve um: ali um segundo id não significaria nada.
|
|
180
|
+
*/
|
|
181
|
+
const guardedIds = (args, source) => {
|
|
182
|
+
const found = new Set();
|
|
183
|
+
const any = args.match(new RegExp(String.raw `\b${ANY_GATE}\s*\(\s*\[([^\]]*)\]`));
|
|
184
|
+
if (any !== null) {
|
|
185
|
+
for (const id of any[1].matchAll(/['"]([^'"]+)['"]/g))
|
|
186
|
+
found.add(id[1]);
|
|
187
|
+
}
|
|
188
|
+
const single = gatedFor(args, source);
|
|
189
|
+
if (single !== null)
|
|
190
|
+
found.add(single);
|
|
191
|
+
return [...found];
|
|
192
|
+
};
|
|
193
|
+
/** Onde cada `export const x = <construtor>(` começa, para os construtores pedidos. */
|
|
194
|
+
const declarationsOf = (source, constructors) => {
|
|
195
|
+
const pattern = new RegExp(String.raw `(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;\n]*)?=\s*(`
|
|
196
|
+
+ `${constructors.join('|')})\\s*(?:<[^>]*>)?\\s*\\(`, 'g');
|
|
197
|
+
return [...source.matchAll(pattern)].map((match) => ({
|
|
198
|
+
name: match[1],
|
|
199
|
+
trigger: match[2],
|
|
200
|
+
open: (match.index ?? 0) + match[0].length - 1,
|
|
201
|
+
}));
|
|
202
|
+
};
|
|
203
|
+
/** Os gatilhos de Firestore declarados numa fonte, com o estado do portão de cada um. */
|
|
204
|
+
export const triggerDeclarations = (rawSource) => {
|
|
205
|
+
const source = withoutComments(rawSource);
|
|
206
|
+
return declarationsOf(source, TENANT_TRIGGER_CONSTRUCTORS).map((declaration) => {
|
|
207
|
+
const args = callArguments(source, declaration.open);
|
|
208
|
+
return {
|
|
209
|
+
name: declaration.name,
|
|
210
|
+
trigger: declaration.trigger,
|
|
211
|
+
gatedFor: gatedFor(args, source),
|
|
212
|
+
guards: guardedIds(args, source),
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* As functions sem escritório no evento, e o id que o laço por escritório recebeu no ARQUIVO.
|
|
218
|
+
*
|
|
219
|
+
* O alcance é o arquivo, e não os parênteses da chamada — a diferença é deliberada. O portão de um
|
|
220
|
+
* gatilho de documento embrulha AQUELE handler, então ele tem de estar ali; o laço, ao contrário, roda
|
|
221
|
+
* DENTRO do handler e pode estar algumas chamadas abaixo, num `syncOffice(tenantId)` que o próprio autor
|
|
222
|
+
* escreveu. Exigi-lo nos parênteses recusaria código correto, e a verificação que erra assim é a que
|
|
223
|
+
* ensina a próxima pessoa a desligá-la.
|
|
224
|
+
*
|
|
225
|
+
* O preço é conhecido: dois agendados no mesmo arquivo, um com laço e outro sem, passam os dois. É menos
|
|
226
|
+
* grave que o inverso, e está dito.
|
|
227
|
+
*/
|
|
228
|
+
export const unscopedDeclarations = (rawSource) => {
|
|
229
|
+
const source = withoutComments(rawSource);
|
|
230
|
+
const loop = source.match(new RegExp(String.raw `\b${OFFICE_LOOP}\s*\(\s*['"]([^'"]*)['"]`));
|
|
231
|
+
return declarationsOf(source, UNSCOPED_TRIGGER_CONSTRUCTORS).map((declaration) => ({
|
|
232
|
+
name: declaration.name,
|
|
233
|
+
trigger: declaration.trigger,
|
|
234
|
+
gatedFor: loop === null ? null : loop[1],
|
|
235
|
+
guards: loop === null ? [] : [loop[1]],
|
|
236
|
+
}));
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* As callables declaradas, e o id que a guarda delas recebeu no ARQUIVO.
|
|
240
|
+
*
|
|
241
|
+
* O alcance é o arquivo pela mesma razão do laço por escritório: a guarda roda DENTRO do handler (ela
|
|
242
|
+
* precisa do `request`), e o guia manda o autor manter o handler fino, com a decisão num arquivo ao
|
|
243
|
+
* lado. Exigi-la nos parênteses recusaria justamente o código que o guia pede.
|
|
244
|
+
*/
|
|
245
|
+
export const callableDeclarations = (rawSource) => {
|
|
246
|
+
const source = withoutComments(rawSource);
|
|
247
|
+
const guard = source.match(new RegExp(String.raw `\b${CALLABLE_GUARD}\s*\(\s*['"]([^'"]*)['"]`));
|
|
248
|
+
return declarationsOf(source, ['onCall']).map((declaration) => ({
|
|
249
|
+
name: declaration.name,
|
|
250
|
+
trigger: declaration.trigger,
|
|
251
|
+
gatedFor: guard === null ? null : guard[1],
|
|
252
|
+
guards: guard === null ? [] : [guard[1]],
|
|
253
|
+
}));
|
|
254
|
+
};
|
|
255
|
+
/** Os gatilhos que o `index.ts` de fato exporta — um declarado e não exportado não é deployado. */
|
|
256
|
+
const exportedOnly = (subject, declarations) => {
|
|
257
|
+
const exported = new Set(subject.exported);
|
|
258
|
+
return declarations.filter((trigger) => exported.has(trigger.name));
|
|
259
|
+
};
|
|
260
|
+
const exportedTriggers = (subject) => exportedOnly(subject, subject.triggers);
|
|
261
|
+
const exportedUnscoped = (subject) => exportedOnly(subject, subject.unscoped);
|
|
262
|
+
const exportedCallables = (subject) => exportedOnly(subject, subject.callableGuards);
|
|
263
|
+
/**
|
|
264
|
+
* Gatilho de extensão sem portão.
|
|
265
|
+
*
|
|
266
|
+
* `functions/` fica de fora porque ali a pergunta não tem resposta: a function é comum a várias
|
|
267
|
+
* extensões, nenhuma a reivindica, e não há um id para o portão receber. É lacuna conhecida e tem
|
|
268
|
+
* endereço próprio — enquanto ela não fechar, exigir aqui um portão impossível seria recusar a única
|
|
269
|
+
* forma que a plataforma oferece para uma function compartilhada.
|
|
270
|
+
*/
|
|
271
|
+
export const ungatedTriggerProblems = (subjects) => subjects
|
|
272
|
+
.filter((subject) => !subject.shared)
|
|
273
|
+
.flatMap((subject) => exportedTriggers(subject)
|
|
274
|
+
.filter((trigger) => trigger.gatedFor === null)
|
|
275
|
+
.map((trigger) => ({
|
|
276
|
+
name: trigger.name,
|
|
277
|
+
message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger with no `
|
|
278
|
+
+ `installation gate — wrap its handler in ${GATE}('<this extension's id>', …) or it fires for `
|
|
279
|
+
+ 'every office in the project, including the ones that never installed the extension',
|
|
280
|
+
})));
|
|
281
|
+
/**
|
|
282
|
+
* Function de extensão cujo evento não tem escritório, e que não passa pelo laço.
|
|
283
|
+
*
|
|
284
|
+
* A recusa vem acompanhada do substituto porque sem ele ela seria só uma porta fechada: o laço por
|
|
285
|
+
* escritório (`forEachInstalledOffice`) existe justamente para este caso, e é ele que a mensagem nomeia.
|
|
286
|
+
*
|
|
287
|
+
* `functions/` fica de fora pelo mesmo motivo de sempre: a comum não tem um id de extensão para dar ao
|
|
288
|
+
* laço. Quem responde por ela é a declaração de quem ela serve.
|
|
289
|
+
*/
|
|
290
|
+
export const unscopedTriggerProblems = (subjects) => subjects
|
|
291
|
+
.filter((subject) => !subject.shared)
|
|
292
|
+
.flatMap((subject) => exportedUnscoped(subject)
|
|
293
|
+
.filter((trigger) => trigger.gatedFor === null)
|
|
294
|
+
.map((trigger) => ({
|
|
295
|
+
name: trigger.name,
|
|
296
|
+
message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} function whose `
|
|
297
|
+
+ `event carries no {tenantId} — ${GATE} cannot gate it (it fails closed, so the function would `
|
|
298
|
+
+ `never run at all). Loop with ${OFFICE_LOOP}('<this extension's id>', …) inside the handler, or `
|
|
299
|
+
+ 'it acts on every office in the project, including the ones that never installed the extension',
|
|
300
|
+
})));
|
|
301
|
+
/**
|
|
302
|
+
* Gatilho de uma function COMUM que declara quem serve, e não guarda ninguém.
|
|
303
|
+
*
|
|
304
|
+
* A comum ficava fora desta cobrança porque não havia id para dar à guarda: nenhuma extensão pode
|
|
305
|
+
* reivindicá-la. `microteia.serves` resolve exatamente isso — a partir dela existem ids, e a partir dos
|
|
306
|
+
* ids a cobrança passa a ser possível. Um gatilho comum sem guarda dispara para todo escritório do
|
|
307
|
+
* projeto, e é o mesmo defeito do gatilho de extensão sem portão, com um alcance maior.
|
|
308
|
+
*
|
|
309
|
+
* A comum SEM declaração continua de fora, e é deliberado: cobrar dela uma guarda sem ter ids a oferecer
|
|
310
|
+
* seria fechar a porta sem abrir nenhuma. O que ela recebe é o relato do provisionamento.
|
|
311
|
+
*
|
|
312
|
+
* A segunda recusa é a guarda que nomeia extensão que a function não declara servir: as duas listas
|
|
313
|
+
* respondem à mesma pergunta em dois lugares, e divergirem faz uma delas mentir — a `serves` decide a
|
|
314
|
+
* retirada, a guarda decide a execução, e a function passaria a agir onde não pode ser retirada.
|
|
315
|
+
*/
|
|
316
|
+
export const unguardedSharedProblems = (subjects) => subjects
|
|
317
|
+
.filter((subject) => subject.shared)
|
|
318
|
+
.flatMap((subject) => exportedTriggers(subject).flatMap((trigger) => {
|
|
319
|
+
const serves = subject.serves[trigger.name];
|
|
320
|
+
if (serves === undefined || serves.length === 0)
|
|
321
|
+
return [];
|
|
322
|
+
if (trigger.guards.length === 0) {
|
|
323
|
+
return [{
|
|
324
|
+
name: trigger.name,
|
|
325
|
+
message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger declared as `
|
|
326
|
+
+ `serving ${serves.join(', ')} — guard it with ${ANY_GATE}([${serves.map((id) => `'${id}'`).join(', ')}], …) `
|
|
327
|
+
+ 'or it fires for every office in the project, including the ones that installed none of them',
|
|
328
|
+
}];
|
|
329
|
+
}
|
|
330
|
+
const stranger = trigger.guards.filter((id) => !serves.includes(id));
|
|
331
|
+
if (stranger.length === 0)
|
|
332
|
+
return [];
|
|
333
|
+
return [{
|
|
334
|
+
name: trigger.name,
|
|
335
|
+
message: `${subject.label} guards "${trigger.name}" for ${stranger.join(', ')}, which it does not `
|
|
336
|
+
+ `declare serving in microteia.serves (${serves.join(', ')}) — the two lists answer the same `
|
|
337
|
+
+ 'question, and one of them is wrong',
|
|
338
|
+
}];
|
|
339
|
+
}));
|
|
340
|
+
/**
|
|
341
|
+
* Callable de extensão sem a guarda de instalação.
|
|
342
|
+
*
|
|
343
|
+
* "Uma callable órfã é inerte" era verdade de INTERFACE, e não de segurança — e era a frase que o guia
|
|
344
|
+
* dizia. O deploy concede a `allUsers` o papel `roles/run.invoker` em cada callable; o que sobra depois é
|
|
345
|
+
* a validação do token do Firebase. Então a tela ir embora com a extensão não fecha nada: o endpoint
|
|
346
|
+
* segue no ar aceitando o token de QUALQUER usuário do projeto, de qualquer escritório, tendo ou não a
|
|
347
|
+
* extensão.
|
|
348
|
+
*
|
|
349
|
+
* E o escritório chega no PAYLOAD, porque `useCallable` manda só o que a tela passou. Sem a guarda, um
|
|
350
|
+
* usuário do escritório A chama a function apontando para o B — e o molde gerado conferia apenas se
|
|
351
|
+
* havia `request.auth`.
|
|
352
|
+
*
|
|
353
|
+
* A comum fica de fora: `requireInstalledExtension` pede UM id, e a comum serve vários. É lacuna
|
|
354
|
+
* conhecida e está registrada — a guarda "alguma das que eu sirvo" existe para gatilho, não para
|
|
355
|
+
* callable.
|
|
356
|
+
*/
|
|
357
|
+
export const unguardedCallableProblems = (subjects) => subjects
|
|
358
|
+
.filter((subject) => !subject.shared)
|
|
359
|
+
.flatMap((subject) => exportedCallables(subject)
|
|
360
|
+
.filter((callable) => callable.gatedFor === null)
|
|
361
|
+
.map((callable) => ({
|
|
362
|
+
name: callable.name,
|
|
363
|
+
message: `${subject.label} exports "${callable.name}", a callable with no installation `
|
|
364
|
+
+ `check — call ${CALLABLE_GUARD}('<this extension's id>', tenantId, request) before you read the `
|
|
365
|
+
+ 'payload. The deploy makes every callable publicly invocable, so it answers any signed-in user '
|
|
366
|
+
+ 'of this project, from any office, whether or not they installed the extension',
|
|
367
|
+
})));
|
|
368
|
+
/** Todo problema de portão de um conjunto de origens, na ordem em que as mensagens os citam. */
|
|
369
|
+
export const gateProblems = (subjects) => [
|
|
370
|
+
...ungatedTriggerProblems(subjects),
|
|
371
|
+
...unscopedTriggerProblems(subjects),
|
|
372
|
+
...unguardedSharedProblems(subjects),
|
|
373
|
+
...unguardedCallableProblems(subjects),
|
|
374
|
+
];
|
|
375
|
+
/**
|
|
376
|
+
* Os problemas das functions que ESTÃO subindo — a lista que autoriza uma recusa.
|
|
377
|
+
*
|
|
378
|
+
* É a diferença entre "a fonte foi lida" e "a function vai para o ar", e ela não é sutil: a primeira
|
|
379
|
+
* inclui toda extensão cujo diretório ficou no disco depois de desinstalada, e recusar por causa dela é
|
|
380
|
+
* recusar um comando que não faria nada de errado. A segunda é o momento em que a recusa tem
|
|
381
|
+
* consequência — e é o único em que ela é devida.
|
|
382
|
+
*/
|
|
383
|
+
export const gateProblemsFor = (subjects, deploying) => {
|
|
384
|
+
const going = new Set(deploying);
|
|
385
|
+
return gateProblems(subjects).filter((problem) => going.has(problem.name)).map((p) => p.message);
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* Portão aberto para o id ERRADO.
|
|
389
|
+
*
|
|
390
|
+
* É o defeito do copiar-e-colar entre extensões, e o sintoma dele é o oposto do gatilho sem portão: a
|
|
391
|
+
* function sobe, o gatilho dispara, e o handler nunca roda em escritório nenhum. Nada falha — a extensão
|
|
392
|
+
* apenas não faz o que promete, e a causa está numa string.
|
|
393
|
+
*
|
|
394
|
+
* Devolve os casos, e não as frases, porque quem chama precisa dos dois: a mensagem para relatar e o id
|
|
395
|
+
* da extensão para pendurar. Deduzir o id de volta a partir do texto é o que faz um relatório recusar a
|
|
396
|
+
* extensão errada quando o nome de uma aparece dentro da mensagem de outra.
|
|
397
|
+
*
|
|
398
|
+
* Só se pergunta quando o nome tem UM reivindicante: com dois, `functionClaimProblems` já recusou antes,
|
|
399
|
+
* e apontar um deles como o dono certo seria inventar uma resposta.
|
|
400
|
+
*/
|
|
401
|
+
export const misgatedTriggers = (subjects, claimants) => {
|
|
402
|
+
const wrong = (subject, declarations, via) => declarations.flatMap((trigger) => {
|
|
403
|
+
const owner = claimants.get(trigger.name);
|
|
404
|
+
if (trigger.gatedFor === null || owner === undefined || owner === trigger.gatedFor)
|
|
405
|
+
return [];
|
|
406
|
+
return [{ label: subject.label, name: trigger.name, gatedFor: trigger.gatedFor, owner, via }];
|
|
407
|
+
});
|
|
408
|
+
return subjects
|
|
409
|
+
.filter((subject) => !subject.shared)
|
|
410
|
+
.flatMap((subject) => [
|
|
411
|
+
...wrong(subject, exportedTriggers(subject), GATE),
|
|
412
|
+
...wrong(subject, exportedUnscoped(subject), OFFICE_LOOP),
|
|
413
|
+
...wrong(subject, exportedCallables(subject), CALLABLE_GUARD),
|
|
414
|
+
]);
|
|
415
|
+
};
|
|
416
|
+
/** Como uma guarda de id errado aparece num relatório. */
|
|
417
|
+
export const describeMisgated = (entry) => `${entry.label} passes "${entry.gatedFor}" to ${entry.via} for "${entry.name}", but the extension that `
|
|
418
|
+
+ `claims it is "${entry.owner}" — the guard never opens, so nothing this function does ever happens`;
|
|
419
|
+
/**
|
|
420
|
+
* Quem reivindica cada nome, quando é um só. Duas extensões pedindo o mesmo nome é problema de outra
|
|
421
|
+
* verificação, e o nome sai daqui em vez de receber um dono escolhido a esmo.
|
|
422
|
+
*/
|
|
423
|
+
export const singleClaimants = (claims) => {
|
|
424
|
+
const owners = new Map();
|
|
425
|
+
const disputed = new Set();
|
|
426
|
+
for (const claim of claims) {
|
|
427
|
+
for (const name of claim.backend.functions ?? []) {
|
|
428
|
+
const previous = owners.get(name);
|
|
429
|
+
if (previous !== undefined && previous !== claim.pluginId)
|
|
430
|
+
disputed.add(name);
|
|
431
|
+
owners.set(name, claim.pluginId);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const name of disputed)
|
|
435
|
+
owners.delete(name);
|
|
436
|
+
return owners;
|
|
437
|
+
};
|
|
@@ -8,6 +8,31 @@ export interface InstalledClaim extends BackendClaim {
|
|
|
8
8
|
documentPath: string;
|
|
9
9
|
applied: boolean;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* A varredura inteira, e não só o que ela achou.
|
|
13
|
+
*
|
|
14
|
+
* As contagens estão aqui porque é delas que sai o direito de APAGAR: a Cloud Function é do PROJETO e a
|
|
15
|
+
* instalação é do ESCRITÓRIO, então "extensão nenhuma exige mais" é uma afirmação sobre todos os
|
|
16
|
+
* escritórios do projeto, não sobre o último que foi lido. A varredura é completa ou não existe —
|
|
17
|
+
* `listDocuments` pagina até o fim e qualquer leitura recusada derruba o comando —, e o que faltava era
|
|
18
|
+
* DIZER isso a quem lê a proposta de remoção.
|
|
19
|
+
*/
|
|
20
|
+
export interface InstalledSurvey {
|
|
21
|
+
/** Escritórios lidos. Todos os do projeto, ou nada abaixo teria acontecido. */
|
|
22
|
+
tenants: number;
|
|
23
|
+
/** Cópias de extensão lidas, ativas ou não. */
|
|
24
|
+
extensions: number;
|
|
25
|
+
claims: InstalledClaim[];
|
|
26
|
+
/**
|
|
27
|
+
* Todo id instalado e ativo no projeto, exija ele backend ou não.
|
|
28
|
+
*
|
|
29
|
+
* `claims` não serve para esta pergunta, e a diferença é a que faz a function comum ser retirada pelo
|
|
30
|
+
* motivo certo: uma extensão sem `manifest.backend` nunca vira reivindicação, mas ela pode muito bem
|
|
31
|
+
* ser uma das que a function comum serve. Decidir pela lista de reivindicações apagaria a function de
|
|
32
|
+
* quem está instalado.
|
|
33
|
+
*/
|
|
34
|
+
installedIds: Set<string>;
|
|
35
|
+
}
|
|
11
36
|
/**
|
|
12
37
|
* O que cada escritório do projeto instalou e exige do backend.
|
|
13
38
|
*
|
|
@@ -15,8 +40,7 @@ export interface InstalledClaim extends BackendClaim {
|
|
|
15
40
|
* contra `manifest.functions` antes de qualquer deploy. Nome de function fora do pacote é recusado com
|
|
16
41
|
* explicação; não há injeção possível, porque o código é sempre o do pacote.
|
|
17
42
|
*/
|
|
18
|
-
export declare const
|
|
19
|
-
export declare const parseAppliedFunctions: (value: string | null) => string[];
|
|
43
|
+
export declare const surveyInstallations: (projectId: string) => Promise<InstalledSurvey>;
|
|
20
44
|
export interface ProvisionOptions {
|
|
21
45
|
projectId: string;
|
|
22
46
|
region: string;
|
|
@@ -51,17 +75,6 @@ export interface ProvisionOutcome {
|
|
|
51
75
|
* divergiriam exatamente no ponto em que ninguém olha.
|
|
52
76
|
*/
|
|
53
77
|
export declare const provisionBackend: (options: ProvisionOptions) => Promise<ProvisionOutcome>;
|
|
54
|
-
/**
|
|
55
|
-
* A lista aplicada CONVERGE para o que ainda é exigido: o que já estava e continua sendo pedido, mais
|
|
56
|
-
* o que subiu agora, mais o órfão que não foi removido. Reescrevê-la só com o que subiu NESTA execução
|
|
57
|
-
* a esvaziava na segunda passada — e a terceira redeployava tudo, achando que nada havia sido feito.
|
|
58
|
-
*/
|
|
59
|
-
export declare const appliedAfterRun: (input: {
|
|
60
|
-
alreadyApplied: string[];
|
|
61
|
-
wanted: string[];
|
|
62
|
-
deployed: string[];
|
|
63
|
-
keptOrphans: string[];
|
|
64
|
-
}) => string[];
|
|
65
78
|
/**
|
|
66
79
|
* A impressão digital do parâmetro só avança quando TODAS as functions que o carregam subiram. Gravar
|
|
67
80
|
* antes disso é o que transformaria uma falha de deploy em silêncio permanente: o valor novo ficaria
|