@microteia/console 2.8.0 → 2.18.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 +97 -0
- package/README.md +1 -1
- package/lib/console/appliedFunctions.d.ts +79 -0
- package/lib/console/appliedFunctions.js +88 -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 +94 -1
- package/lib/console/deploy.js +32 -7
- package/lib/console/environment.d.ts +22 -6
- package/lib/console/environment.js +11 -0
- package/lib/console/exportCatalog.d.ts +124 -0
- package/lib/console/exportCatalog.js +264 -0
- package/lib/console/firestoreRest.d.ts +27 -0
- package/lib/console/firestoreRest.js +23 -0
- package/lib/console/functionGate.d.ts +198 -0
- package/lib/console/functionGate.js +400 -0
- package/lib/console/init.js +1 -0
- package/lib/console/installedExtensions.d.ts +37 -0
- package/lib/console/installedExtensions.js +37 -0
- package/lib/console/loginExtensions.d.ts +29 -0
- package/lib/console/loginExtensions.js +101 -0
- package/lib/console/provision.d.ts +26 -13
- package/lib/console/provision.js +312 -73
- package/lib/index.js +8 -0
- package/lib/kernel/backendShape.d.ts +27 -0
- package/lib/kernel/backendShape.js +38 -0
- package/lib/kernel/manifest.d.ts +16 -0
- package/lib/kernel/manifest.js +16 -0
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/exampleSources.js +7 -3
- package/lib/vendor/installationDocs.js +18 -0
- package/lib/vendor/pack.js +3 -1
- package/lib/vendor/packageTemplates.d.ts +12 -1
- package/lib/vendor/packageTemplates.js +42 -2
- package/package.json +1 -1
|
@@ -0,0 +1,198 @@
|
|
|
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 (`functionProblems`): o defeito é
|
|
17
|
+
* invisível depois de instalado, e um aviso na saída de um deploy é lido uma vez.
|
|
18
|
+
*
|
|
19
|
+
* A análise é por texto, no idioma dos outros leitores de fonte do Console (`callableFunctionNames`,
|
|
20
|
+
* `declaredParametersIn`). Ela erra para o lado ALTO — o que ela não entende ela recusa —, e é por isso
|
|
21
|
+
* que o portão declarado num identificador ao lado é reconhecido: recusar código correto é o que ensina
|
|
22
|
+
* a desligar a verificação.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Os construtores de gatilho de Firestore.
|
|
26
|
+
*
|
|
27
|
+
* São os únicos eventos cujo caminho carrega `{tenantId}`, e portanto os únicos para os quais o portão
|
|
28
|
+
* existe. Um `onSchedule` ou um `onMessagePublished` não tem escritório de onde perguntar — envolvê-los
|
|
29
|
+
* no portão os deixaria permanentemente inertes, porque ele falha FECHADO. Eles ficam de fora desta
|
|
30
|
+
* verificação de propósito: exigir deles um portão que não funciona seria trocar um defeito por outro.
|
|
31
|
+
*/
|
|
32
|
+
export declare const TENANT_TRIGGER_CONSTRUCTORS: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Os construtores cujo evento NÃO carrega escritório no caminho.
|
|
35
|
+
*
|
|
36
|
+
* Para eles as duas saídas do autor eram erradas, e é isso que faz deles uma recusa e não um aviso: sem
|
|
37
|
+
* portão a function age sobre o projeto inteiro; COM portão ela nunca roda — nem enquanto a extensão está
|
|
38
|
+
* instalada —, porque `forInstalledExtension` falha FECHADO e nunca acha o `{tenantId}` que não existe.
|
|
39
|
+
* Um autor que seguisse a orientação genérica "envolva o handler no portão" produzia uma function que
|
|
40
|
+
* passava no deploy, não acusava nada e nunca fazia nada.
|
|
41
|
+
*
|
|
42
|
+
* A resposta é o laço por escritório (`forEachInstalledOffice`), e é por existir uma resposta que a
|
|
43
|
+
* recusa é honesta.
|
|
44
|
+
*
|
|
45
|
+
* 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.
|
|
46
|
+
* O RTDB fica de fora de propósito — o caminho dele TEM parâmetros, então um `{tenantId}` ali é possível
|
|
47
|
+
* —, e a plataforma não usa RTDB em lugar nenhum.
|
|
48
|
+
*/
|
|
49
|
+
export declare const UNSCOPED_TRIGGER_CONSTRUCTORS: string[];
|
|
50
|
+
/**
|
|
51
|
+
* A fonte sem comentário nenhum, com as strings intactas.
|
|
52
|
+
*
|
|
53
|
+
* O JSDoc que o molde escreve CITA `forInstalledExtension` — é ali que ele explica o portão. Sem esta
|
|
54
|
+
* limpeza, um gatilho que perdesse a chamada e mantivesse o comentário passaria na verificação, o que é
|
|
55
|
+
* o caso exato que ela existe para pegar.
|
|
56
|
+
*/
|
|
57
|
+
export declare const withoutComments: (source: string) => string;
|
|
58
|
+
export interface TriggerDeclaration {
|
|
59
|
+
/** O nome declarado — é por ele que se sabe se o `index.ts` o exporta. */
|
|
60
|
+
name: string;
|
|
61
|
+
/** O construtor usado. Aparece na mensagem: é ele que explica por que o portão é devido. */
|
|
62
|
+
trigger: string;
|
|
63
|
+
/** O id que `forInstalledExtension` recebeu, ou `null` quando o portão não está lá. */
|
|
64
|
+
gatedFor: string | null;
|
|
65
|
+
/** Todos os ids que as guardas daquele gatilho recebem — a comum guarda mais de um. */
|
|
66
|
+
guards: string[];
|
|
67
|
+
}
|
|
68
|
+
/** Os gatilhos de Firestore declarados numa fonte, com o estado do portão de cada um. */
|
|
69
|
+
export declare const triggerDeclarations: (rawSource: string) => TriggerDeclaration[];
|
|
70
|
+
/**
|
|
71
|
+
* As functions sem escritório no evento, e o id que o laço por escritório recebeu no ARQUIVO.
|
|
72
|
+
*
|
|
73
|
+
* O alcance é o arquivo, e não os parênteses da chamada — a diferença é deliberada. O portão de um
|
|
74
|
+
* gatilho de documento embrulha AQUELE handler, então ele tem de estar ali; o laço, ao contrário, roda
|
|
75
|
+
* DENTRO do handler e pode estar algumas chamadas abaixo, num `syncOffice(tenantId)` que o próprio autor
|
|
76
|
+
* escreveu. Exigi-lo nos parênteses recusaria código correto, e a verificação que erra assim é a que
|
|
77
|
+
* ensina a próxima pessoa a desligá-la.
|
|
78
|
+
*
|
|
79
|
+
* O preço é conhecido: dois agendados no mesmo arquivo, um com laço e outro sem, passam os dois. É menos
|
|
80
|
+
* grave que o inverso, e está dito.
|
|
81
|
+
*/
|
|
82
|
+
export declare const unscopedDeclarations: (rawSource: string) => TriggerDeclaration[];
|
|
83
|
+
/**
|
|
84
|
+
* As callables declaradas, e o id que a guarda delas recebeu no ARQUIVO.
|
|
85
|
+
*
|
|
86
|
+
* O alcance é o arquivo pela mesma razão do laço por escritório: a guarda roda DENTRO do handler (ela
|
|
87
|
+
* precisa do `request`), e o guia manda o autor manter o handler fino, com a decisão num arquivo ao
|
|
88
|
+
* lado. Exigi-la nos parênteses recusaria justamente o código que o guia pede.
|
|
89
|
+
*/
|
|
90
|
+
export declare const callableDeclarations: (rawSource: string) => TriggerDeclaration[];
|
|
91
|
+
/**
|
|
92
|
+
* O que uma origem de functions precisa ter para ser conferida. É a forma de `FunctionSource`, declarada
|
|
93
|
+
* aqui em vez de importada: assim este módulo se testa sozinho, sem montar diretório nenhum.
|
|
94
|
+
*/
|
|
95
|
+
export interface GateSubject {
|
|
96
|
+
label: string;
|
|
97
|
+
/** `functions/` é comum a várias extensões, e não tem uma instalação de onde perguntar. */
|
|
98
|
+
shared: boolean;
|
|
99
|
+
exported: string[];
|
|
100
|
+
triggers: TriggerDeclaration[];
|
|
101
|
+
/** As functions cujo evento não carrega escritório: o agendado, a fila, o HTTP, o Storage. */
|
|
102
|
+
unscoped: TriggerDeclaration[];
|
|
103
|
+
/** Quem cada function comum declara servir. Vazio numa origem de extensão. */
|
|
104
|
+
serves: Record<string, string[]>;
|
|
105
|
+
/** As callables declaradas, com o estado da guarda de cada uma. */
|
|
106
|
+
callableGuards: TriggerDeclaration[];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Gatilho de extensão sem portão.
|
|
110
|
+
*
|
|
111
|
+
* `functions/` fica de fora porque ali a pergunta não tem resposta: a function é comum a várias
|
|
112
|
+
* extensões, nenhuma a reivindica, e não há um id para o portão receber. É lacuna conhecida e tem
|
|
113
|
+
* endereço próprio — enquanto ela não fechar, exigir aqui um portão impossível seria recusar a única
|
|
114
|
+
* forma que a plataforma oferece para uma function compartilhada.
|
|
115
|
+
*/
|
|
116
|
+
export declare const ungatedTriggerProblems: (subjects: GateSubject[]) => string[];
|
|
117
|
+
/**
|
|
118
|
+
* Function de extensão cujo evento não tem escritório, e que não passa pelo laço.
|
|
119
|
+
*
|
|
120
|
+
* A recusa vem acompanhada do substituto porque sem ele ela seria só uma porta fechada: o laço por
|
|
121
|
+
* escritório (`forEachInstalledOffice`) existe justamente para este caso, e é ele que a mensagem nomeia.
|
|
122
|
+
*
|
|
123
|
+
* `functions/` fica de fora pelo mesmo motivo de sempre: a comum não tem um id de extensão para dar ao
|
|
124
|
+
* laço. Quem responde por ela é a declaração de quem ela serve.
|
|
125
|
+
*/
|
|
126
|
+
export declare const unscopedTriggerProblems: (subjects: GateSubject[]) => string[];
|
|
127
|
+
/**
|
|
128
|
+
* Gatilho de uma function COMUM que declara quem serve, e não guarda ninguém.
|
|
129
|
+
*
|
|
130
|
+
* A comum ficava fora desta cobrança porque não havia id para dar à guarda: nenhuma extensão pode
|
|
131
|
+
* reivindicá-la. `microteia.serves` resolve exatamente isso — a partir dela existem ids, e a partir dos
|
|
132
|
+
* ids a cobrança passa a ser possível. Um gatilho comum sem guarda dispara para todo escritório do
|
|
133
|
+
* projeto, e é o mesmo defeito do gatilho de extensão sem portão, com um alcance maior.
|
|
134
|
+
*
|
|
135
|
+
* A comum SEM declaração continua de fora, e é deliberado: cobrar dela uma guarda sem ter ids a oferecer
|
|
136
|
+
* seria fechar a porta sem abrir nenhuma. O que ela recebe é o relato do provisionamento.
|
|
137
|
+
*
|
|
138
|
+
* A segunda recusa é a guarda que nomeia extensão que a function não declara servir: as duas listas
|
|
139
|
+
* respondem à mesma pergunta em dois lugares, e divergirem faz uma delas mentir — a `serves` decide a
|
|
140
|
+
* retirada, a guarda decide a execução, e a function passaria a agir onde não pode ser retirada.
|
|
141
|
+
*/
|
|
142
|
+
export declare const unguardedSharedProblems: (subjects: GateSubject[]) => string[];
|
|
143
|
+
/**
|
|
144
|
+
* Callable de extensão sem a guarda de instalação.
|
|
145
|
+
*
|
|
146
|
+
* "Uma callable órfã é inerte" era verdade de INTERFACE, e não de segurança — e era a frase que o guia
|
|
147
|
+
* dizia. O deploy concede a `allUsers` o papel `roles/run.invoker` em cada callable; o que sobra depois é
|
|
148
|
+
* a validação do token do Firebase. Então a tela ir embora com a extensão não fecha nada: o endpoint
|
|
149
|
+
* segue no ar aceitando o token de QUALQUER usuário do projeto, de qualquer escritório, tendo ou não a
|
|
150
|
+
* extensão.
|
|
151
|
+
*
|
|
152
|
+
* E o escritório chega no PAYLOAD, porque `useCallable` manda só o que a tela passou. Sem a guarda, um
|
|
153
|
+
* usuário do escritório A chama a function apontando para o B — e o molde gerado conferia apenas se
|
|
154
|
+
* havia `request.auth`.
|
|
155
|
+
*
|
|
156
|
+
* A comum fica de fora: `requireInstalledExtension` pede UM id, e a comum serve vários. É lacuna
|
|
157
|
+
* conhecida e está registrada — a guarda "alguma das que eu sirvo" existe para gatilho, não para
|
|
158
|
+
* callable.
|
|
159
|
+
*/
|
|
160
|
+
export declare const unguardedCallableProblems: (subjects: GateSubject[]) => string[];
|
|
161
|
+
export interface MisgatedTrigger {
|
|
162
|
+
/** A origem, como as mensagens a citam. */
|
|
163
|
+
label: string;
|
|
164
|
+
name: string;
|
|
165
|
+
/** O id que a guarda recebeu. */
|
|
166
|
+
gatedFor: string;
|
|
167
|
+
/** O id da extensão que reivindica a function. */
|
|
168
|
+
owner: string;
|
|
169
|
+
/** Qual guarda recebeu o id errado — é ela que o autor tem de corrigir. */
|
|
170
|
+
via: string;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Portão aberto para o id ERRADO.
|
|
174
|
+
*
|
|
175
|
+
* É o defeito do copiar-e-colar entre extensões, e o sintoma dele é o oposto do gatilho sem portão: a
|
|
176
|
+
* function sobe, o gatilho dispara, e o handler nunca roda em escritório nenhum. Nada falha — a extensão
|
|
177
|
+
* apenas não faz o que promete, e a causa está numa string.
|
|
178
|
+
*
|
|
179
|
+
* Devolve os casos, e não as frases, porque quem chama precisa dos dois: a mensagem para relatar e o id
|
|
180
|
+
* da extensão para pendurar. Deduzir o id de volta a partir do texto é o que faz um relatório recusar a
|
|
181
|
+
* extensão errada quando o nome de uma aparece dentro da mensagem de outra.
|
|
182
|
+
*
|
|
183
|
+
* Só se pergunta quando o nome tem UM reivindicante: com dois, `functionClaimProblems` já recusou antes,
|
|
184
|
+
* e apontar um deles como o dono certo seria inventar uma resposta.
|
|
185
|
+
*/
|
|
186
|
+
export declare const misgatedTriggers: (subjects: GateSubject[], claimants: Map<string, string>) => MisgatedTrigger[];
|
|
187
|
+
/** Como uma guarda de id errado aparece num relatório. */
|
|
188
|
+
export declare const describeMisgated: (entry: MisgatedTrigger) => string;
|
|
189
|
+
/**
|
|
190
|
+
* Quem reivindica cada nome, quando é um só. Duas extensões pedindo o mesmo nome é problema de outra
|
|
191
|
+
* verificação, e o nome sai daqui em vez de receber um dono escolhido a esmo.
|
|
192
|
+
*/
|
|
193
|
+
export declare const singleClaimants: (claims: {
|
|
194
|
+
pluginId: string;
|
|
195
|
+
backend: {
|
|
196
|
+
functions?: string[];
|
|
197
|
+
};
|
|
198
|
+
}[]) => Map<string, string>;
|
|
@@ -0,0 +1,400 @@
|
|
|
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 (`functionProblems`): o defeito é
|
|
17
|
+
* invisível depois de instalado, e um aviso na saída de um deploy é lido uma vez.
|
|
18
|
+
*
|
|
19
|
+
* A análise é por texto, no idioma dos outros leitores de fonte do Console (`callableFunctionNames`,
|
|
20
|
+
* `declaredParametersIn`). Ela erra para o lado ALTO — o que ela não entende ela recusa —, e é por isso
|
|
21
|
+
* que o portão declarado num identificador ao lado é reconhecido: recusar código correto é o que ensina
|
|
22
|
+
* a desligar a verificação.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Os construtores de gatilho de Firestore.
|
|
26
|
+
*
|
|
27
|
+
* São os únicos eventos cujo caminho carrega `{tenantId}`, e portanto os únicos para os quais o portão
|
|
28
|
+
* existe. Um `onSchedule` ou um `onMessagePublished` não tem escritório de onde perguntar — envolvê-los
|
|
29
|
+
* no portão os deixaria permanentemente inertes, porque ele falha FECHADO. Eles ficam de fora desta
|
|
30
|
+
* verificação de propósito: exigir deles um portão que não funciona seria trocar um defeito por outro.
|
|
31
|
+
*/
|
|
32
|
+
export const TENANT_TRIGGER_CONSTRUCTORS = [
|
|
33
|
+
'onDocumentWritten',
|
|
34
|
+
'onDocumentCreated',
|
|
35
|
+
'onDocumentUpdated',
|
|
36
|
+
'onDocumentDeleted',
|
|
37
|
+
'onDocumentWrittenWithAuthContext',
|
|
38
|
+
'onDocumentCreatedWithAuthContext',
|
|
39
|
+
'onDocumentUpdatedWithAuthContext',
|
|
40
|
+
'onDocumentDeletedWithAuthContext',
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* Os construtores cujo evento NÃO carrega escritório no caminho.
|
|
44
|
+
*
|
|
45
|
+
* Para eles as duas saídas do autor eram erradas, e é isso que faz deles uma recusa e não um aviso: sem
|
|
46
|
+
* portão a function age sobre o projeto inteiro; COM portão ela nunca roda — nem enquanto a extensão está
|
|
47
|
+
* instalada —, porque `forInstalledExtension` falha FECHADO e nunca acha o `{tenantId}` que não existe.
|
|
48
|
+
* Um autor que seguisse a orientação genérica "envolva o handler no portão" produzia uma function que
|
|
49
|
+
* passava no deploy, não acusava nada e nunca fazia nada.
|
|
50
|
+
*
|
|
51
|
+
* A resposta é o laço por escritório (`forEachInstalledOffice`), e é por existir uma resposta que a
|
|
52
|
+
* recusa é honesta.
|
|
53
|
+
*
|
|
54
|
+
* 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.
|
|
55
|
+
* O RTDB fica de fora de propósito — o caminho dele TEM parâmetros, então um `{tenantId}` ali é possível
|
|
56
|
+
* —, e a plataforma não usa RTDB em lugar nenhum.
|
|
57
|
+
*/
|
|
58
|
+
export const UNSCOPED_TRIGGER_CONSTRUCTORS = [
|
|
59
|
+
'onSchedule',
|
|
60
|
+
'onMessagePublished',
|
|
61
|
+
'onRequest',
|
|
62
|
+
'onObjectFinalized',
|
|
63
|
+
'onObjectArchived',
|
|
64
|
+
'onObjectDeleted',
|
|
65
|
+
'onObjectMetadataUpdated',
|
|
66
|
+
'onCustomEventPublished',
|
|
67
|
+
'onTaskDispatched',
|
|
68
|
+
'beforeUserCreated',
|
|
69
|
+
'beforeUserSignedIn',
|
|
70
|
+
];
|
|
71
|
+
const GATE = 'forInstalledExtension';
|
|
72
|
+
const OFFICE_LOOP = 'forEachInstalledOffice';
|
|
73
|
+
const ANY_GATE = 'forAnyInstalledExtension';
|
|
74
|
+
const CALLABLE_GUARD = 'requireInstalledExtension';
|
|
75
|
+
/** Onde uma string termina, a partir da abertura dela. Aspas não fechada não engole o resto do arquivo. */
|
|
76
|
+
const stringEnd = (source, start, quote) => {
|
|
77
|
+
let index = start + 1;
|
|
78
|
+
while (index < source.length) {
|
|
79
|
+
const char = source[index];
|
|
80
|
+
if (char === '\\') {
|
|
81
|
+
index += 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (char === quote)
|
|
85
|
+
return index + 1;
|
|
86
|
+
if (quote !== '`' && char === '\n')
|
|
87
|
+
return index;
|
|
88
|
+
index += 1;
|
|
89
|
+
}
|
|
90
|
+
return source.length;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* A fonte sem comentário nenhum, com as strings intactas.
|
|
94
|
+
*
|
|
95
|
+
* O JSDoc que o molde escreve CITA `forInstalledExtension` — é ali que ele explica o portão. Sem esta
|
|
96
|
+
* limpeza, um gatilho que perdesse a chamada e mantivesse o comentário passaria na verificação, o que é
|
|
97
|
+
* o caso exato que ela existe para pegar.
|
|
98
|
+
*/
|
|
99
|
+
export const withoutComments = (source) => {
|
|
100
|
+
let out = '';
|
|
101
|
+
let index = 0;
|
|
102
|
+
while (index < source.length) {
|
|
103
|
+
const pair = source.slice(index, index + 2);
|
|
104
|
+
if (pair === '//') {
|
|
105
|
+
const end = source.indexOf('\n', index);
|
|
106
|
+
index = end === -1 ? source.length : end;
|
|
107
|
+
out += ' ';
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (pair === '/*') {
|
|
111
|
+
const end = source.indexOf('*/', index + 2);
|
|
112
|
+
index = end === -1 ? source.length : end + 2;
|
|
113
|
+
out += ' ';
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const char = source[index];
|
|
117
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
118
|
+
const end = stringEnd(source, index, char);
|
|
119
|
+
out += source.slice(index, end);
|
|
120
|
+
index = end;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
out += char;
|
|
124
|
+
index += 1;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* O texto entre os parênteses da chamada que abre em `open`. Conta os parênteses e pula as strings: um
|
|
130
|
+
* `console.log('(')` no corpo do handler desequilibraria a contagem, e o efeito seria recusar uma
|
|
131
|
+
* function correta.
|
|
132
|
+
*/
|
|
133
|
+
const callArguments = (source, open) => {
|
|
134
|
+
let depth = 0;
|
|
135
|
+
let index = open;
|
|
136
|
+
while (index < source.length) {
|
|
137
|
+
const char = source[index];
|
|
138
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
139
|
+
index = stringEnd(source, index, char);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (char === '(')
|
|
143
|
+
depth += 1;
|
|
144
|
+
else if (char === ')') {
|
|
145
|
+
depth -= 1;
|
|
146
|
+
if (depth === 0)
|
|
147
|
+
return source.slice(open + 1, index);
|
|
148
|
+
}
|
|
149
|
+
index += 1;
|
|
150
|
+
}
|
|
151
|
+
return source.slice(open + 1);
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* O id que o portão daquele gatilho recebeu, ou `null` quando não há portão.
|
|
155
|
+
*
|
|
156
|
+
* Duas formas valem, e a segunda não é indulgência: `onDocumentWritten(opts, gated)` com
|
|
157
|
+
* `const gated = forInstalledExtension('id', handler)` ao lado é código correto e comum quando o handler
|
|
158
|
+
* cresce. Recusá-lo seria a verificação errando contra quem fez certo.
|
|
159
|
+
*/
|
|
160
|
+
const gatedFor = (args, source) => {
|
|
161
|
+
const direct = args.match(new RegExp(String.raw `\b${GATE}\s*\(\s*['"]([^'"]*)['"]`));
|
|
162
|
+
if (direct !== null)
|
|
163
|
+
return direct[1];
|
|
164
|
+
for (const token of new Set(args.match(/[A-Za-z_$][\w$]*/g) ?? [])) {
|
|
165
|
+
const indirect = source.match(new RegExp(String.raw `(?:const|let|var)\s+${token}\s*(?::[^=;\n]*)?=\s*${GATE}\s*\(\s*['"]([^'"]*)['"]`));
|
|
166
|
+
if (indirect !== null)
|
|
167
|
+
return indirect[1];
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Todos os ids que as guardas daquele gatilho recebem.
|
|
173
|
+
*
|
|
174
|
+
* A function COMUM usa `forAnyInstalledExtension(['a', 'b'], handler)`, cuja pergunta é outra — "alguma
|
|
175
|
+
* das que eu sirvo está instalada?" —, então ela devolve mais de um id. A de extensão continua respondida
|
|
176
|
+
* por `gatedFor`, que devolve um: ali um segundo id não significaria nada.
|
|
177
|
+
*/
|
|
178
|
+
const guardedIds = (args, source) => {
|
|
179
|
+
const found = new Set();
|
|
180
|
+
const any = args.match(new RegExp(String.raw `\b${ANY_GATE}\s*\(\s*\[([^\]]*)\]`));
|
|
181
|
+
if (any !== null) {
|
|
182
|
+
for (const id of any[1].matchAll(/['"]([^'"]+)['"]/g))
|
|
183
|
+
found.add(id[1]);
|
|
184
|
+
}
|
|
185
|
+
const single = gatedFor(args, source);
|
|
186
|
+
if (single !== null)
|
|
187
|
+
found.add(single);
|
|
188
|
+
return [...found];
|
|
189
|
+
};
|
|
190
|
+
/** Onde cada `export const x = <construtor>(` começa, para os construtores pedidos. */
|
|
191
|
+
const declarationsOf = (source, constructors) => {
|
|
192
|
+
const pattern = new RegExp(String.raw `(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;\n]*)?=\s*(`
|
|
193
|
+
+ `${constructors.join('|')})\\s*(?:<[^>]*>)?\\s*\\(`, 'g');
|
|
194
|
+
return [...source.matchAll(pattern)].map((match) => ({
|
|
195
|
+
name: match[1],
|
|
196
|
+
trigger: match[2],
|
|
197
|
+
open: (match.index ?? 0) + match[0].length - 1,
|
|
198
|
+
}));
|
|
199
|
+
};
|
|
200
|
+
/** Os gatilhos de Firestore declarados numa fonte, com o estado do portão de cada um. */
|
|
201
|
+
export const triggerDeclarations = (rawSource) => {
|
|
202
|
+
const source = withoutComments(rawSource);
|
|
203
|
+
return declarationsOf(source, TENANT_TRIGGER_CONSTRUCTORS).map((declaration) => {
|
|
204
|
+
const args = callArguments(source, declaration.open);
|
|
205
|
+
return {
|
|
206
|
+
name: declaration.name,
|
|
207
|
+
trigger: declaration.trigger,
|
|
208
|
+
gatedFor: gatedFor(args, source),
|
|
209
|
+
guards: guardedIds(args, source),
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* As functions sem escritório no evento, e o id que o laço por escritório recebeu no ARQUIVO.
|
|
215
|
+
*
|
|
216
|
+
* O alcance é o arquivo, e não os parênteses da chamada — a diferença é deliberada. O portão de um
|
|
217
|
+
* gatilho de documento embrulha AQUELE handler, então ele tem de estar ali; o laço, ao contrário, roda
|
|
218
|
+
* DENTRO do handler e pode estar algumas chamadas abaixo, num `syncOffice(tenantId)` que o próprio autor
|
|
219
|
+
* escreveu. Exigi-lo nos parênteses recusaria código correto, e a verificação que erra assim é a que
|
|
220
|
+
* ensina a próxima pessoa a desligá-la.
|
|
221
|
+
*
|
|
222
|
+
* O preço é conhecido: dois agendados no mesmo arquivo, um com laço e outro sem, passam os dois. É menos
|
|
223
|
+
* grave que o inverso, e está dito.
|
|
224
|
+
*/
|
|
225
|
+
export const unscopedDeclarations = (rawSource) => {
|
|
226
|
+
const source = withoutComments(rawSource);
|
|
227
|
+
const loop = source.match(new RegExp(String.raw `\b${OFFICE_LOOP}\s*\(\s*['"]([^'"]*)['"]`));
|
|
228
|
+
return declarationsOf(source, UNSCOPED_TRIGGER_CONSTRUCTORS).map((declaration) => ({
|
|
229
|
+
name: declaration.name,
|
|
230
|
+
trigger: declaration.trigger,
|
|
231
|
+
gatedFor: loop === null ? null : loop[1],
|
|
232
|
+
guards: loop === null ? [] : [loop[1]],
|
|
233
|
+
}));
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* As callables declaradas, e o id que a guarda delas recebeu no ARQUIVO.
|
|
237
|
+
*
|
|
238
|
+
* O alcance é o arquivo pela mesma razão do laço por escritório: a guarda roda DENTRO do handler (ela
|
|
239
|
+
* precisa do `request`), e o guia manda o autor manter o handler fino, com a decisão num arquivo ao
|
|
240
|
+
* lado. Exigi-la nos parênteses recusaria justamente o código que o guia pede.
|
|
241
|
+
*/
|
|
242
|
+
export const callableDeclarations = (rawSource) => {
|
|
243
|
+
const source = withoutComments(rawSource);
|
|
244
|
+
const guard = source.match(new RegExp(String.raw `\b${CALLABLE_GUARD}\s*\(\s*['"]([^'"]*)['"]`));
|
|
245
|
+
return declarationsOf(source, ['onCall']).map((declaration) => ({
|
|
246
|
+
name: declaration.name,
|
|
247
|
+
trigger: declaration.trigger,
|
|
248
|
+
gatedFor: guard === null ? null : guard[1],
|
|
249
|
+
guards: guard === null ? [] : [guard[1]],
|
|
250
|
+
}));
|
|
251
|
+
};
|
|
252
|
+
/** Os gatilhos que o `index.ts` de fato exporta — um declarado e não exportado não é deployado. */
|
|
253
|
+
const exportedOnly = (subject, declarations) => {
|
|
254
|
+
const exported = new Set(subject.exported);
|
|
255
|
+
return declarations.filter((trigger) => exported.has(trigger.name));
|
|
256
|
+
};
|
|
257
|
+
const exportedTriggers = (subject) => exportedOnly(subject, subject.triggers);
|
|
258
|
+
const exportedUnscoped = (subject) => exportedOnly(subject, subject.unscoped);
|
|
259
|
+
const exportedCallables = (subject) => exportedOnly(subject, subject.callableGuards);
|
|
260
|
+
/**
|
|
261
|
+
* Gatilho de extensão sem portão.
|
|
262
|
+
*
|
|
263
|
+
* `functions/` fica de fora porque ali a pergunta não tem resposta: a function é comum a várias
|
|
264
|
+
* extensões, nenhuma a reivindica, e não há um id para o portão receber. É lacuna conhecida e tem
|
|
265
|
+
* endereço próprio — enquanto ela não fechar, exigir aqui um portão impossível seria recusar a única
|
|
266
|
+
* forma que a plataforma oferece para uma function compartilhada.
|
|
267
|
+
*/
|
|
268
|
+
export const ungatedTriggerProblems = (subjects) => subjects
|
|
269
|
+
.filter((subject) => !subject.shared)
|
|
270
|
+
.flatMap((subject) => exportedTriggers(subject)
|
|
271
|
+
.filter((trigger) => trigger.gatedFor === null)
|
|
272
|
+
.map((trigger) => `${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger with no `
|
|
273
|
+
+ `installation gate — wrap its handler in ${GATE}('<this extension's id>', …) or it fires for `
|
|
274
|
+
+ 'every office in the project, including the ones that never installed the extension'));
|
|
275
|
+
/**
|
|
276
|
+
* Function de extensão cujo evento não tem escritório, e que não passa pelo laço.
|
|
277
|
+
*
|
|
278
|
+
* A recusa vem acompanhada do substituto porque sem ele ela seria só uma porta fechada: o laço por
|
|
279
|
+
* escritório (`forEachInstalledOffice`) existe justamente para este caso, e é ele que a mensagem nomeia.
|
|
280
|
+
*
|
|
281
|
+
* `functions/` fica de fora pelo mesmo motivo de sempre: a comum não tem um id de extensão para dar ao
|
|
282
|
+
* laço. Quem responde por ela é a declaração de quem ela serve.
|
|
283
|
+
*/
|
|
284
|
+
export const unscopedTriggerProblems = (subjects) => subjects
|
|
285
|
+
.filter((subject) => !subject.shared)
|
|
286
|
+
.flatMap((subject) => exportedUnscoped(subject)
|
|
287
|
+
.filter((trigger) => trigger.gatedFor === null)
|
|
288
|
+
.map((trigger) => `${subject.label} exports "${trigger.name}", a ${trigger.trigger} function whose `
|
|
289
|
+
+ `event carries no {tenantId} — ${GATE} cannot gate it (it fails closed, so the function would `
|
|
290
|
+
+ `never run at all). Loop with ${OFFICE_LOOP}('<this extension's id>', …) inside the handler, or `
|
|
291
|
+
+ 'it acts on every office in the project, including the ones that never installed the extension'));
|
|
292
|
+
/**
|
|
293
|
+
* Gatilho de uma function COMUM que declara quem serve, e não guarda ninguém.
|
|
294
|
+
*
|
|
295
|
+
* A comum ficava fora desta cobrança porque não havia id para dar à guarda: nenhuma extensão pode
|
|
296
|
+
* reivindicá-la. `microteia.serves` resolve exatamente isso — a partir dela existem ids, e a partir dos
|
|
297
|
+
* ids a cobrança passa a ser possível. Um gatilho comum sem guarda dispara para todo escritório do
|
|
298
|
+
* projeto, e é o mesmo defeito do gatilho de extensão sem portão, com um alcance maior.
|
|
299
|
+
*
|
|
300
|
+
* A comum SEM declaração continua de fora, e é deliberado: cobrar dela uma guarda sem ter ids a oferecer
|
|
301
|
+
* seria fechar a porta sem abrir nenhuma. O que ela recebe é o relato do provisionamento.
|
|
302
|
+
*
|
|
303
|
+
* A segunda recusa é a guarda que nomeia extensão que a function não declara servir: as duas listas
|
|
304
|
+
* respondem à mesma pergunta em dois lugares, e divergirem faz uma delas mentir — a `serves` decide a
|
|
305
|
+
* retirada, a guarda decide a execução, e a function passaria a agir onde não pode ser retirada.
|
|
306
|
+
*/
|
|
307
|
+
export const unguardedSharedProblems = (subjects) => subjects
|
|
308
|
+
.filter((subject) => subject.shared)
|
|
309
|
+
.flatMap((subject) => exportedTriggers(subject).flatMap((trigger) => {
|
|
310
|
+
const serves = subject.serves[trigger.name];
|
|
311
|
+
if (serves === undefined || serves.length === 0)
|
|
312
|
+
return [];
|
|
313
|
+
if (trigger.guards.length === 0) {
|
|
314
|
+
return [`${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger declared as `
|
|
315
|
+
+ `serving ${serves.join(', ')} — guard it with ${ANY_GATE}([${serves.map((id) => `'${id}'`).join(', ')}], …) `
|
|
316
|
+
+ 'or it fires for every office in the project, including the ones that installed none of them'];
|
|
317
|
+
}
|
|
318
|
+
const stranger = trigger.guards.filter((id) => !serves.includes(id));
|
|
319
|
+
if (stranger.length === 0)
|
|
320
|
+
return [];
|
|
321
|
+
return [`${subject.label} guards "${trigger.name}" for ${stranger.join(', ')}, which it does not `
|
|
322
|
+
+ `declare serving in microteia.serves (${serves.join(', ')}) — the two lists answer the same `
|
|
323
|
+
+ 'question, and one of them is wrong'];
|
|
324
|
+
}));
|
|
325
|
+
/**
|
|
326
|
+
* Callable de extensão sem a guarda de instalação.
|
|
327
|
+
*
|
|
328
|
+
* "Uma callable órfã é inerte" era verdade de INTERFACE, e não de segurança — e era a frase que o guia
|
|
329
|
+
* dizia. O deploy concede a `allUsers` o papel `roles/run.invoker` em cada callable; o que sobra depois é
|
|
330
|
+
* a validação do token do Firebase. Então a tela ir embora com a extensão não fecha nada: o endpoint
|
|
331
|
+
* segue no ar aceitando o token de QUALQUER usuário do projeto, de qualquer escritório, tendo ou não a
|
|
332
|
+
* extensão.
|
|
333
|
+
*
|
|
334
|
+
* E o escritório chega no PAYLOAD, porque `useCallable` manda só o que a tela passou. Sem a guarda, um
|
|
335
|
+
* usuário do escritório A chama a function apontando para o B — e o molde gerado conferia apenas se
|
|
336
|
+
* havia `request.auth`.
|
|
337
|
+
*
|
|
338
|
+
* A comum fica de fora: `requireInstalledExtension` pede UM id, e a comum serve vários. É lacuna
|
|
339
|
+
* conhecida e está registrada — a guarda "alguma das que eu sirvo" existe para gatilho, não para
|
|
340
|
+
* callable.
|
|
341
|
+
*/
|
|
342
|
+
export const unguardedCallableProblems = (subjects) => subjects
|
|
343
|
+
.filter((subject) => !subject.shared)
|
|
344
|
+
.flatMap((subject) => exportedCallables(subject)
|
|
345
|
+
.filter((callable) => callable.gatedFor === null)
|
|
346
|
+
.map((callable) => `${subject.label} exports "${callable.name}", a callable with no installation `
|
|
347
|
+
+ `check — call ${CALLABLE_GUARD}('<this extension's id>', tenantId, request) before you read the `
|
|
348
|
+
+ 'payload. The deploy makes every callable publicly invocable, so it answers any signed-in user '
|
|
349
|
+
+ 'of this project, from any office, whether or not they installed the extension'));
|
|
350
|
+
/**
|
|
351
|
+
* Portão aberto para o id ERRADO.
|
|
352
|
+
*
|
|
353
|
+
* É o defeito do copiar-e-colar entre extensões, e o sintoma dele é o oposto do gatilho sem portão: a
|
|
354
|
+
* function sobe, o gatilho dispara, e o handler nunca roda em escritório nenhum. Nada falha — a extensão
|
|
355
|
+
* apenas não faz o que promete, e a causa está numa string.
|
|
356
|
+
*
|
|
357
|
+
* Devolve os casos, e não as frases, porque quem chama precisa dos dois: a mensagem para relatar e o id
|
|
358
|
+
* da extensão para pendurar. Deduzir o id de volta a partir do texto é o que faz um relatório recusar a
|
|
359
|
+
* extensão errada quando o nome de uma aparece dentro da mensagem de outra.
|
|
360
|
+
*
|
|
361
|
+
* Só se pergunta quando o nome tem UM reivindicante: com dois, `functionClaimProblems` já recusou antes,
|
|
362
|
+
* e apontar um deles como o dono certo seria inventar uma resposta.
|
|
363
|
+
*/
|
|
364
|
+
export const misgatedTriggers = (subjects, claimants) => {
|
|
365
|
+
const wrong = (subject, declarations, via) => declarations.flatMap((trigger) => {
|
|
366
|
+
const owner = claimants.get(trigger.name);
|
|
367
|
+
if (trigger.gatedFor === null || owner === undefined || owner === trigger.gatedFor)
|
|
368
|
+
return [];
|
|
369
|
+
return [{ label: subject.label, name: trigger.name, gatedFor: trigger.gatedFor, owner, via }];
|
|
370
|
+
});
|
|
371
|
+
return subjects
|
|
372
|
+
.filter((subject) => !subject.shared)
|
|
373
|
+
.flatMap((subject) => [
|
|
374
|
+
...wrong(subject, exportedTriggers(subject), GATE),
|
|
375
|
+
...wrong(subject, exportedUnscoped(subject), OFFICE_LOOP),
|
|
376
|
+
...wrong(subject, exportedCallables(subject), CALLABLE_GUARD),
|
|
377
|
+
]);
|
|
378
|
+
};
|
|
379
|
+
/** Como uma guarda de id errado aparece num relatório. */
|
|
380
|
+
export const describeMisgated = (entry) => `${entry.label} passes "${entry.gatedFor}" to ${entry.via} for "${entry.name}", but the extension that `
|
|
381
|
+
+ `claims it is "${entry.owner}" — the guard never opens, so nothing this function does ever happens`;
|
|
382
|
+
/**
|
|
383
|
+
* Quem reivindica cada nome, quando é um só. Duas extensões pedindo o mesmo nome é problema de outra
|
|
384
|
+
* verificação, e o nome sai daqui em vez de receber um dono escolhido a esmo.
|
|
385
|
+
*/
|
|
386
|
+
export const singleClaimants = (claims) => {
|
|
387
|
+
const owners = new Map();
|
|
388
|
+
const disputed = new Set();
|
|
389
|
+
for (const claim of claims) {
|
|
390
|
+
for (const name of claim.backend.functions ?? []) {
|
|
391
|
+
const previous = owners.get(name);
|
|
392
|
+
if (previous !== undefined && previous !== claim.pluginId)
|
|
393
|
+
disputed.add(name);
|
|
394
|
+
owners.set(name, claim.pluginId);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
for (const name of disputed)
|
|
398
|
+
owners.delete(name);
|
|
399
|
+
return owners;
|
|
400
|
+
};
|
package/lib/console/init.js
CHANGED
|
@@ -31,6 +31,7 @@ export const init = async (args) => {
|
|
|
31
31
|
firebase: { projectId: null, region: DEFAULT_FUNCTIONS_REGION },
|
|
32
32
|
kernel: { version: null, deployed: null },
|
|
33
33
|
backend: { functions: null, parameters: null, indexes: null },
|
|
34
|
+
login: { extensions: null },
|
|
34
35
|
};
|
|
35
36
|
await writeEnvironment(file, environment);
|
|
36
37
|
// O diretório de trabalho guarda o pacote desempacotado e a configuração resolvida. Ele é local à
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O CAMINHO ÚNICO até o que um escritório instalou.
|
|
3
|
+
*
|
|
4
|
+
* Estava dentro do `provision`, e é dele que a leitura nasceu: para saber quais Cloud Functions subir,
|
|
5
|
+
* o comando precisa do `manifest.backend` de cada extensão instalada. O `export-catalog` faz a mesma
|
|
6
|
+
* pergunta com outro propósito, e um segundo caminho até o mesmo documento seria uma segunda chance de
|
|
7
|
+
* discordar — a forma do documento, o que conta como instalada, o que fazer com a desativada.
|
|
8
|
+
*
|
|
9
|
+
* Então o caminho é este, e o `provision` também passa por aqui.
|
|
10
|
+
*/
|
|
11
|
+
/** Um escritório do projeto. O `name` existe para que escolher um `--tenant` não seja adivinhar um id. */
|
|
12
|
+
export interface TenantSummary {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
export interface InstalledExtensionRecord {
|
|
17
|
+
tenantId: string;
|
|
18
|
+
documentPath: string;
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
/** Desligada no escritório: o pacote continua ali, e nada dele está no ar. */
|
|
23
|
+
active: boolean;
|
|
24
|
+
/** O manifesto cru da cópia instalada — é dele que sai `backend`, e nada aqui o interpreta. */
|
|
25
|
+
manifest: Record<string, unknown> | undefined;
|
|
26
|
+
/** O documento inteiro, decodificado, para quem precisa de mais que o manifesto. */
|
|
27
|
+
data: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export declare const listTenants: (projectId: string) => Promise<TenantSummary[]>;
|
|
30
|
+
/**
|
|
31
|
+
* As extensões instaladas num escritório.
|
|
32
|
+
*
|
|
33
|
+
* A DESATIVADA vem junto, marcada. Quem decide o que fazer com ela é quem chamou: para o `provision`
|
|
34
|
+
* ela não exige backend nenhum; para o catálogo ela ainda explica de onde veio uma tela que continua
|
|
35
|
+
* gravada no banco.
|
|
36
|
+
*/
|
|
37
|
+
export declare const readInstalledExtensions: (projectId: string, tenantId: string) => Promise<InstalledExtensionRecord[]>;
|