@aglyn/plugins-redirects 1.0.0-beta.244 → 1.0.0-beta.246
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 +2 -0
- package/package.json +11 -11
- package/src/lib/declarations.console-server.d.ts +8 -0
- package/src/lib/declarations.console-server.js +27 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/model/redirect-draft-content.d.ts +79 -0
- package/src/lib/model/redirect-draft-content.js +187 -0
- package/src/lib/model/redirect-draft-content.js.map +1 -0
- package/src/lib/model/redirects-record-routes.d.ts +30 -0
- package/src/lib/model/redirects-record-routes.js +46 -0
- package/src/lib/model/redirects-record-routes.js.map +1 -0
- package/src/lib/plugin.js +4 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/redirect-ai-capability.d.ts +47 -0
- package/src/lib/server/redirect-ai-capability.js +128 -0
- package/src/lib/server/redirect-ai-capability.js.map +1 -0
- package/src/lib/server/redirect-ai-writer.console.d.ts +18 -0
- package/src/lib/server/redirect-ai-writer.console.js +71 -0
- package/src/lib/server/redirect-ai-writer.console.js.map +1 -0
- package/src/lib/server/redirect-ai-writer.d.ts +46 -0
- package/src/lib/server/redirect-ai-writer.js +333 -0
- package/src/lib/server/redirect-ai-writer.js.map +1 -0
- package/src/lib/transfer/redirects-transfer.d.ts +34 -0
- package/src/lib/transfer/redirects-transfer.js +5 -5
- package/src/lib/transfer/redirects-transfer.js.map +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { registerPluginAiCapability } from "@aglyn/aglyn/plugin-manager/plugin-ai-capabilities";
|
|
17
|
+
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
18
|
+
import { REDIRECT_DRAFT_ACTIONS, REDIRECT_DRAFT_DESTINATION_MAX, REDIRECT_DRAFT_RESOURCE, REDIRECT_DRAFT_SOURCE_MAX } from "../model/redirect-draft-content.js";
|
|
19
|
+
import { REDIRECT_KINDS, REDIRECT_STATUS_CODES } from "../model/redirects.js";
|
|
20
|
+
/**
|
|
21
|
+
* WHAT AN AI BUILD CAN DO WITH REDIRECTS (AGL-3745): create, change and remove
|
|
22
|
+
* a site's redirect rules — "redirect /old-menu to /menu", "send /shop to my
|
|
23
|
+
* store page", "stop redirecting /promo".
|
|
24
|
+
*
|
|
25
|
+
* The capability the AI plugin's build planner offers; its item is written by
|
|
26
|
+
* this plugin's `redirect` writer (`redirect-ai-writer.ts`), so every rule is
|
|
27
|
+
* the redirects page's: the publishing role, the `redirects` feature, the
|
|
28
|
+
* `redirectsPerHost` allowance on a create, lockdown, the page's validation,
|
|
29
|
+
* duplicates and loops, and a published page's path kept unless the person
|
|
30
|
+
* asked. No model runs: the planner fills the arguments and they ARE the
|
|
31
|
+
* change, so an item costs no credits.
|
|
32
|
+
*
|
|
33
|
+
* Free has no redirects (`redirectsPerHost: 0`, no `redirects` feature), so a
|
|
34
|
+
* Free workspace's build is never offered one. No page block depends on it.
|
|
35
|
+
*
|
|
36
|
+
* A rule is live once written (`live`): a redirect has no draft state, and the
|
|
37
|
+
* console's own Save writes it live. The build's plan card is the person's
|
|
38
|
+
* agreement to it, so the intent says so.
|
|
39
|
+
*/ /** The operation's name in a build plan. */ export const REDIRECT_AI_OP = 'redirect';
|
|
40
|
+
/** The writer's content for an item: its arguments, the status code read as the number the rule stores. */ export function redirectDraftContentFromArgs(args) {
|
|
41
|
+
const content = {};
|
|
42
|
+
for (const [key, value] of Object.entries(args)){
|
|
43
|
+
if (value === undefined || value === null || value === '') continue;
|
|
44
|
+
content[key] = key === 'statusCode' && typeof value === 'string' ? Number(value) : value;
|
|
45
|
+
}
|
|
46
|
+
return content;
|
|
47
|
+
}
|
|
48
|
+
export const redirectAiCapability = {
|
|
49
|
+
op: REDIRECT_AI_OP,
|
|
50
|
+
noun: 'redirect',
|
|
51
|
+
where: 'Redirects',
|
|
52
|
+
intents: [
|
|
53
|
+
'redirects: send an old or short address on the site to a page it has, or to an https:// address; change where a redirect goes; stop a redirect. Live as soon as the build runs'
|
|
54
|
+
],
|
|
55
|
+
argsSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
action: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: 'create a new redirect, update the one at source, or remove the one at source.',
|
|
61
|
+
enum: REDIRECT_DRAFT_ACTIONS
|
|
62
|
+
},
|
|
63
|
+
source: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'The address that redirects, as a site path like /old-menu (for update and remove, the path the redirect has now). With kind regex, a pattern over the path.',
|
|
66
|
+
maxLength: REDIRECT_DRAFT_SOURCE_MAX
|
|
67
|
+
},
|
|
68
|
+
destination: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: 'Where visitors are sent: the path of a page the site has, from the site inventory (/menu), or an https:// address the request gives. Never a path or address the request and the inventory do not name. Required for create.',
|
|
71
|
+
maxLength: REDIRECT_DRAFT_DESTINATION_MAX
|
|
72
|
+
},
|
|
73
|
+
newSource: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description: 'update only: the new path the redirect answers at, when the request moves it.',
|
|
76
|
+
maxLength: REDIRECT_DRAFT_SOURCE_MAX
|
|
77
|
+
},
|
|
78
|
+
kind: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'exact (the default) matches the path only; prefix also matches every path under it (/blog and /blog/post); regex is a pattern. For update and remove, give it only when the site has two redirects at that path.',
|
|
81
|
+
enum: REDIRECT_KINDS
|
|
82
|
+
},
|
|
83
|
+
statusCode: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: '302 (the default) while a move may change; 301 when the request says the move is permanent; 307 and 308 keep a form submission as it was sent.',
|
|
86
|
+
enum: REDIRECT_STATUS_CODES.map(String)
|
|
87
|
+
},
|
|
88
|
+
priority: {
|
|
89
|
+
type: 'integer',
|
|
90
|
+
description: 'Evaluation order, lower first; leave it out for the default, 100.'
|
|
91
|
+
},
|
|
92
|
+
enabled: {
|
|
93
|
+
type: 'boolean',
|
|
94
|
+
description: 'false to keep the redirect but switch it off; leave it out otherwise.'
|
|
95
|
+
},
|
|
96
|
+
replacesLivePage: {
|
|
97
|
+
type: 'boolean',
|
|
98
|
+
description: 'true only when the request asks to redirect the address of a page the site publishes, so the redirect takes that page\'s place. A redirect from a published page\'s path is refused without it.'
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
required: [
|
|
102
|
+
'action',
|
|
103
|
+
'source'
|
|
104
|
+
],
|
|
105
|
+
additionalProperties: false
|
|
106
|
+
},
|
|
107
|
+
maxPerPlan: 10,
|
|
108
|
+
// Free includes no redirects: neither the feature nor a slot.
|
|
109
|
+
freeAllowed: false,
|
|
110
|
+
feature: 'redirects',
|
|
111
|
+
quota: 'redirectsPerHost',
|
|
112
|
+
draftResource: REDIRECT_DRAFT_RESOURCE,
|
|
113
|
+
live: true,
|
|
114
|
+
// Written without a model: the planner already filled the arguments.
|
|
115
|
+
estimateCredits: ()=>0,
|
|
116
|
+
degrade: 'omit',
|
|
117
|
+
draftContent: (item)=>redirectDraftContentFromArgs(item.args)
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Registers the capability; the console's server declarations call it beside
|
|
121
|
+
* the writer, since only the console runs AI jobs (AGL-3026). Idempotent.
|
|
122
|
+
*/ export function registerRedirectAiCapability() {
|
|
123
|
+
registerPluginAiCapability(redirectAiCapability, {
|
|
124
|
+
pluginId: BUNDLE_ID
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
//# sourceMappingURL=redirect-ai-capability.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/server/redirect-ai-capability.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginAiCapability,\n type PluginAiCapability,\n type PluginAiCapabilityArgs,\n} from '@aglyn/aglyn/plugin-manager/plugin-ai-capabilities'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n REDIRECT_DRAFT_ACTIONS,\n REDIRECT_DRAFT_DESTINATION_MAX,\n REDIRECT_DRAFT_RESOURCE,\n REDIRECT_DRAFT_SOURCE_MAX,\n} from '../model/redirect-draft-content'\nimport { REDIRECT_KINDS, REDIRECT_STATUS_CODES } from '../model/redirects'\n\n/**\n * WHAT AN AI BUILD CAN DO WITH REDIRECTS (AGL-3745): create, change and remove\n * a site's redirect rules — \"redirect /old-menu to /menu\", \"send /shop to my\n * store page\", \"stop redirecting /promo\".\n *\n * The capability the AI plugin's build planner offers; its item is written by\n * this plugin's `redirect` writer (`redirect-ai-writer.ts`), so every rule is\n * the redirects page's: the publishing role, the `redirects` feature, the\n * `redirectsPerHost` allowance on a create, lockdown, the page's validation,\n * duplicates and loops, and a published page's path kept unless the person\n * asked. No model runs: the planner fills the arguments and they ARE the\n * change, so an item costs no credits.\n *\n * Free has no redirects (`redirectsPerHost: 0`, no `redirects` feature), so a\n * Free workspace's build is never offered one. No page block depends on it.\n *\n * A rule is live once written (`live`): a redirect has no draft state, and the\n * console's own Save writes it live. The build's plan card is the person's\n * agreement to it, so the intent says so.\n */\n\n/** The operation's name in a build plan. */\nexport const REDIRECT_AI_OP = 'redirect'\n\n/** The writer's content for an item: its arguments, the status code read as the number the rule stores. */\nexport function redirectDraftContentFromArgs(args: PluginAiCapabilityArgs): Readonly<Record<string, unknown>> {\n const content: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(args)) {\n if (value === undefined || value === null || value === '') continue\n content[key] = key === 'statusCode' && typeof value === 'string' ? Number(value) : value\n }\n return content\n}\n\nexport const redirectAiCapability: PluginAiCapability = {\n op: REDIRECT_AI_OP,\n noun: 'redirect',\n where: 'Redirects',\n intents: [\n 'redirects: send an old or short address on the site to a page it has, or to an https:// address; change where a redirect goes; stop a redirect. Live as soon as the build runs',\n ],\n argsSchema: {\n type: 'object',\n properties: {\n action: {\n type: 'string',\n description: 'create a new redirect, update the one at source, or remove the one at source.',\n enum: REDIRECT_DRAFT_ACTIONS,\n },\n source: {\n type: 'string',\n description:\n 'The address that redirects, as a site path like /old-menu (for update and remove, the path the redirect has now). With kind regex, a pattern over the path.',\n maxLength: REDIRECT_DRAFT_SOURCE_MAX,\n },\n destination: {\n type: 'string',\n description:\n 'Where visitors are sent: the path of a page the site has, from the site inventory (/menu), or an https:// address the request gives. Never a path or address the request and the inventory do not name. Required for create.',\n maxLength: REDIRECT_DRAFT_DESTINATION_MAX,\n },\n newSource: {\n type: 'string',\n description: 'update only: the new path the redirect answers at, when the request moves it.',\n maxLength: REDIRECT_DRAFT_SOURCE_MAX,\n },\n kind: {\n type: 'string',\n description:\n 'exact (the default) matches the path only; prefix also matches every path under it (/blog and /blog/post); regex is a pattern. For update and remove, give it only when the site has two redirects at that path.',\n enum: REDIRECT_KINDS,\n },\n statusCode: {\n type: 'string',\n description:\n '302 (the default) while a move may change; 301 when the request says the move is permanent; 307 and 308 keep a form submission as it was sent.',\n enum: REDIRECT_STATUS_CODES.map(String),\n },\n priority: {\n type: 'integer',\n description: 'Evaluation order, lower first; leave it out for the default, 100.',\n },\n enabled: {\n type: 'boolean',\n description: 'false to keep the redirect but switch it off; leave it out otherwise.',\n },\n replacesLivePage: {\n type: 'boolean',\n description:\n 'true only when the request asks to redirect the address of a page the site publishes, so the redirect takes that page\\'s place. A redirect from a published page\\'s path is refused without it.',\n },\n },\n required: ['action', 'source'],\n additionalProperties: false,\n },\n maxPerPlan: 10,\n // Free includes no redirects: neither the feature nor a slot.\n freeAllowed: false,\n feature: 'redirects',\n quota: 'redirectsPerHost',\n draftResource: REDIRECT_DRAFT_RESOURCE,\n live: true,\n // Written without a model: the planner already filled the arguments.\n estimateCredits: () => 0,\n degrade: 'omit',\n draftContent: (item) => redirectDraftContentFromArgs(item.args),\n}\n\n/**\n * Registers the capability; the console's server declarations call it beside\n * the writer, since only the console runs AI jobs (AGL-3026). Idempotent.\n */\nexport function registerRedirectAiCapability(): void {\n registerPluginAiCapability(redirectAiCapability, { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginAiCapability","BUNDLE_ID","REDIRECT_DRAFT_ACTIONS","REDIRECT_DRAFT_DESTINATION_MAX","REDIRECT_DRAFT_RESOURCE","REDIRECT_DRAFT_SOURCE_MAX","REDIRECT_KINDS","REDIRECT_STATUS_CODES","REDIRECT_AI_OP","redirectDraftContentFromArgs","args","content","key","value","Object","entries","undefined","Number","redirectAiCapability","op","noun","where","intents","argsSchema","type","properties","action","description","enum","source","maxLength","destination","newSource","kind","statusCode","map","String","priority","enabled","replacesLivePage","required","additionalProperties","maxPerPlan","freeAllowed","feature","quota","draftResource","live","estimateCredits","degrade","draftContent","item","registerRedirectAiCapability","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,0BAA0B,QAGrB,qDAAoD;AAC3D,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SACEC,sBAAsB,EACtBC,8BAA8B,EAC9BC,uBAAuB,EACvBC,yBAAyB,QACpB,qCAAiC;AACxC,SAASC,cAAc,EAAEC,qBAAqB,QAAQ,wBAAoB;AAE1E;;;;;;;;;;;;;;;;;;;CAmBC,GAED,0CAA0C,GAC1C,OAAO,MAAMC,iBAAiB,WAAU;AAExC,yGAAyG,GACzG,OAAO,SAASC,6BAA6BC,IAA4B;IACvE,MAAMC,UAAmC,CAAC;IAC1C,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAACL,MAAO;QAC/C,IAAIG,UAAUG,aAAaH,UAAU,QAAQA,UAAU,IAAI;QAC3DF,OAAO,CAACC,IAAI,GAAGA,QAAQ,gBAAgB,OAAOC,UAAU,WAAWI,OAAOJ,SAASA;IACrF;IACA,OAAOF;AACT;AAEA,OAAO,MAAMO,uBAA2C;IACtDC,IAAIX;IACJY,MAAM;IACNC,OAAO;IACPC,SAAS;QACP;KACD;IACDC,YAAY;QACVC,MAAM;QACNC,YAAY;YACVC,QAAQ;gBACNF,MAAM;gBACNG,aAAa;gBACbC,MAAM1B;YACR;YACA2B,QAAQ;gBACNL,MAAM;gBACNG,aACE;gBACFG,WAAWzB;YACb;YACA0B,aAAa;gBACXP,MAAM;gBACNG,aACE;gBACFG,WAAW3B;YACb;YACA6B,WAAW;gBACTR,MAAM;gBACNG,aAAa;gBACbG,WAAWzB;YACb;YACA4B,MAAM;gBACJT,MAAM;gBACNG,aACE;gBACFC,MAAMtB;YACR;YACA4B,YAAY;gBACVV,MAAM;gBACNG,aACE;gBACFC,MAAMrB,sBAAsB4B,GAAG,CAACC;YAClC;YACAC,UAAU;gBACRb,MAAM;gBACNG,aAAa;YACf;YACAW,SAAS;gBACPd,MAAM;gBACNG,aAAa;YACf;YACAY,kBAAkB;gBAChBf,MAAM;gBACNG,aACE;YACJ;QACF;QACAa,UAAU;YAAC;YAAU;SAAS;QAC9BC,sBAAsB;IACxB;IACAC,YAAY;IACZ,8DAA8D;IAC9DC,aAAa;IACbC,SAAS;IACTC,OAAO;IACPC,eAAe1C;IACf2C,MAAM;IACN,qEAAqE;IACrEC,iBAAiB,IAAM;IACvBC,SAAS;IACTC,cAAc,CAACC,OAAS1C,6BAA6B0C,KAAKzC,IAAI;AAChE,EAAC;AAED;;;CAGC,GACD,OAAO,SAAS0C;IACdpD,2BAA2BkB,sBAAsB;QAAEmC,UAAUpD;IAAU;AACzE"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginResourceDraftWriter } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts';
|
|
18
|
+
export declare function redirectAiWriter(): PluginResourceDraftWriter;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { dropPluginSiteCache } from "@aglyn/aglyn/plugin-manager/plugin-site-cache";
|
|
17
|
+
import { firebaseAdmin, logHostActivity } from "@aglyn/tenant-data-admin";
|
|
18
|
+
import { findUserByUidAcrossPools } from "@aglyn/tenant-data-admin/server/auth-pools";
|
|
19
|
+
import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
|
|
20
|
+
import { createRedirectAiWriter } from "./redirect-ai-writer.js";
|
|
21
|
+
/**
|
|
22
|
+
* The `redirect` writer (AGL-3745) over the console's Admin SDK, made once per
|
|
23
|
+
* process on its first call. The declarations file reaches it by a relative
|
|
24
|
+
* `import()`, so the console's boot loads neither this nor the Admin SDK.
|
|
25
|
+
*/ let writer = null;
|
|
26
|
+
export function redirectAiWriter() {
|
|
27
|
+
writer != null ? writer : writer = createRedirectAiWriter({
|
|
28
|
+
firestore: ()=>firebaseAdmin.app().firestore(),
|
|
29
|
+
// The verdict every console write door asks: the platform, the workspace,
|
|
30
|
+
// the site and the member, for a write.
|
|
31
|
+
lockdown: async ({ uid, org, host })=>Boolean(await getLockdownVerdict({
|
|
32
|
+
staff: false,
|
|
33
|
+
uid,
|
|
34
|
+
org: org,
|
|
35
|
+
host: host,
|
|
36
|
+
intent: 'write'
|
|
37
|
+
})),
|
|
38
|
+
// The create route's announcement, for every path a change touches.
|
|
39
|
+
announcePaths: async (hostId, paths)=>{
|
|
40
|
+
await dropPluginSiteCache({
|
|
41
|
+
hostIds: [
|
|
42
|
+
hostId
|
|
43
|
+
],
|
|
44
|
+
paths: {
|
|
45
|
+
[hostId]: [
|
|
46
|
+
...paths
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
reason: 'redirect rules were changed'
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
// The create route's activity line for a created rule.
|
|
53
|
+
logCreated: async (hostId, actorUid, ruleId, declared)=>{
|
|
54
|
+
var _declared_activityType;
|
|
55
|
+
const email = await findUserByUidAcrossPools(actorUid).then((found)=>{
|
|
56
|
+
var _ref;
|
|
57
|
+
return (_ref = found == null ? void 0 : found.record.email) != null ? _ref : null;
|
|
58
|
+
}).catch(()=>null);
|
|
59
|
+
await logHostActivity(hostId, {
|
|
60
|
+
uid: actorUid,
|
|
61
|
+
email
|
|
62
|
+
}, `Created ${declared.activityNoun}`, {
|
|
63
|
+
type: (_declared_activityType = declared.activityType) != null ? _declared_activityType : 'content',
|
|
64
|
+
id: ruleId
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return writer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//# sourceMappingURL=redirect-ai-writer.console.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/server/redirect-ai-writer.console.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { dropPluginSiteCache } from '@aglyn/aglyn/plugin-manager/plugin-site-cache'\nimport type { PluginResourceDraftWriter } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\nimport type { HostActivityTarget } from '@aglyn/tenant-data-admin'\nimport { firebaseAdmin, logHostActivity } from '@aglyn/tenant-data-admin'\nimport { findUserByUidAcrossPools } from '@aglyn/tenant-data-admin/server/auth-pools'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { createRedirectAiWriter } from './redirect-ai-writer'\n\n/**\n * The `redirect` writer (AGL-3745) over the console's Admin SDK, made once per\n * process on its first call. The declarations file reaches it by a relative\n * `import()`, so the console's boot loads neither this nor the Admin SDK.\n */\nlet writer: PluginResourceDraftWriter | null = null\n\nexport function redirectAiWriter(): PluginResourceDraftWriter {\n writer ??= createRedirectAiWriter({\n firestore: () => firebaseAdmin.app().firestore() as unknown as FirebaseFirestore.Firestore,\n // The verdict every console write door asks: the platform, the workspace,\n // the site and the member, for a write.\n lockdown: async ({ uid, org, host }) =>\n Boolean(\n await getLockdownVerdict({ staff: false, uid, org: org as never, host: host as never, intent: 'write' }),\n ),\n // The create route's announcement, for every path a change touches.\n announcePaths: async (hostId, paths) => {\n await dropPluginSiteCache({ hostIds: [hostId], paths: { [hostId]: [...paths] }, reason: 'redirect rules were changed' })\n },\n // The create route's activity line for a created rule.\n logCreated: async (hostId, actorUid, ruleId, declared) => {\n const email = await findUserByUidAcrossPools(actorUid)\n .then((found) => found?.record.email ?? null)\n .catch(() => null)\n await logHostActivity(\n hostId,\n { uid: actorUid, email },\n `Created ${declared.activityNoun}`,\n { type: (declared.activityType ?? 'content') as HostActivityTarget['type'], id: ruleId },\n )\n },\n })\n return writer\n}\n"],"names":["dropPluginSiteCache","firebaseAdmin","logHostActivity","findUserByUidAcrossPools","getLockdownVerdict","createRedirectAiWriter","writer","redirectAiWriter","firestore","app","lockdown","uid","org","host","Boolean","staff","intent","announcePaths","hostId","paths","hostIds","reason","logCreated","actorUid","ruleId","declared","email","then","found","record","catch","activityNoun","type","activityType","id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,mBAAmB,QAAQ,gDAA+C;AAGnF,SAASC,aAAa,EAAEC,eAAe,QAAQ,2BAA0B;AACzE,SAASC,wBAAwB,QAAQ,6CAA4C;AACrF,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,sBAAsB,QAAQ,0BAAsB;AAE7D;;;;CAIC,GACD,IAAIC,SAA2C;AAE/C,OAAO,SAASC;IACdD,iBAAAA,SAAAA,SAAWD,uBAAuB;QAChCG,WAAW,IAAMP,cAAcQ,GAAG,GAAGD,SAAS;QAC9C,0EAA0E;QAC1E,wCAAwC;QACxCE,UAAU,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAEC,IAAI,EAAE,GACjCC,QACE,MAAMV,mBAAmB;gBAAEW,OAAO;gBAAOJ;gBAAKC,KAAKA;gBAAcC,MAAMA;gBAAeG,QAAQ;YAAQ;QAE1G,oEAAoE;QACpEC,eAAe,OAAOC,QAAQC;YAC5B,MAAMnB,oBAAoB;gBAAEoB,SAAS;oBAACF;iBAAO;gBAAEC,OAAO;oBAAE,CAACD,OAAO,EAAE;2BAAIC;qBAAM;gBAAC;gBAAGE,QAAQ;YAA8B;QACxH;QACA,uDAAuD;QACvDC,YAAY,OAAOJ,QAAQK,UAAUC,QAAQC;gBAQhCA;YAPX,MAAMC,QAAQ,MAAMvB,yBAAyBoB,UAC1CI,IAAI,CAAC,CAACC;;+BAAUA,yBAAAA,MAAOC,MAAM,CAACH,KAAK,mBAAI;eACvCI,KAAK,CAAC,IAAM;YACf,MAAM5B,gBACJgB,QACA;gBAAEP,KAAKY;gBAAUG;YAAM,GACvB,CAAC,QAAQ,EAAED,SAASM,YAAY,EAAE,EAClC;gBAAEC,IAAI,GAAGP,yBAAAA,SAASQ,YAAY,YAArBR,yBAAyB;gBAA0CS,IAAIV;YAAO;QAE3F;IACF;IACA,OAAOlB;AACT"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { ResolvedPluginHostResource } from '@aglyn/aglyn/plugin-manager/plugin-host-resources';
|
|
18
|
+
import type { PluginDraftContext, PluginResourceDraftWriter } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts';
|
|
19
|
+
type Firestore = FirebaseFirestore.Firestore;
|
|
20
|
+
type StoredRule = Record<string, unknown>;
|
|
21
|
+
/** The page's refusal for a member without the publishing role. */
|
|
22
|
+
export declare const REDIRECT_AI_ROLE_REFUSAL = "Changing a redirect needs a publishing role \u2014 ask an editor or admin";
|
|
23
|
+
/** The page's refusal for a plan without redirects. */
|
|
24
|
+
export declare const REDIRECT_AI_PLAN_REFUSAL = "URL redirects require a Starter plan \u2014 see Billing to upgrade";
|
|
25
|
+
/** What a member is told while a lockdown refuses writes to the site. */
|
|
26
|
+
export declare const REDIRECT_AI_LOCKED_REFUSAL = "This site is not accepting changes right now.";
|
|
27
|
+
/** The create route's refusal at the plan's allowance. */
|
|
28
|
+
export declare function redirectAiLimitRefusal(limit: number): string;
|
|
29
|
+
/** Whether a lockdown refuses this member's write to this site; `true` refuses. */
|
|
30
|
+
export type RedirectAiLockdownCheck = (input: {
|
|
31
|
+
uid: string;
|
|
32
|
+
org: PluginDraftContext['org'];
|
|
33
|
+
host: StoredRule;
|
|
34
|
+
}) => Promise<boolean>;
|
|
35
|
+
export interface RedirectAiWriterDeps {
|
|
36
|
+
firestore: () => Firestore;
|
|
37
|
+
lockdown: RedirectAiLockdownCheck;
|
|
38
|
+
/** The `redirect` kind's declaration; the create route's otherwise. */
|
|
39
|
+
declaration?: () => ResolvedPluginHostResource;
|
|
40
|
+
/** Drops the site's cached pages at these paths, best effort. */
|
|
41
|
+
announcePaths?(hostId: string, paths: readonly string[]): Promise<void>;
|
|
42
|
+
/** Writes the site activity line for a created rule, best effort. */
|
|
43
|
+
logCreated?(hostId: string, actorUid: string, ruleId: string, declared: ResolvedPluginHostResource): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export declare function createRedirectAiWriter(deps: RedirectAiWriterDeps): PluginResourceDraftWriter;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { hostRoleCanPublish } from "@aglyn/aglyn/app-utils/organizations";
|
|
18
|
+
import { checkEntitlement, checkQuota } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
19
|
+
import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
20
|
+
import { checkRedirectDraftContent, readRedirectDraftContent } from "../model/redirect-draft-content.js";
|
|
21
|
+
import { REDIRECT_DEFAULT_PRIORITY, findDuplicateRedirect, isExternalRedirectDestination, normalizeRedirectSource, redirectSourceIsLivePage, walkRedirectChain } from "../model/redirects.js";
|
|
22
|
+
import { announcedPath, redirectDeclaration, redirectRuleValues, ruleProblem, storedFields, withDefaults } from "../transfer/redirects-transfer.js";
|
|
23
|
+
import { REDIRECT_FIELD } from "../transfer/redirects-transfer-fields.js";
|
|
24
|
+
/** The page's refusal for a member without the publishing role. */ export const REDIRECT_AI_ROLE_REFUSAL = 'Changing a redirect needs a publishing role — ask an editor or admin';
|
|
25
|
+
/** The page's refusal for a plan without redirects. */ export const REDIRECT_AI_PLAN_REFUSAL = 'URL redirects require a Starter plan — see Billing to upgrade';
|
|
26
|
+
/** What a member is told while a lockdown refuses writes to the site. */ export const REDIRECT_AI_LOCKED_REFUSAL = 'This site is not accepting changes right now.';
|
|
27
|
+
/** The create route's refusal at the plan's allowance. */ export function redirectAiLimitRefusal(limit) {
|
|
28
|
+
return `Your plan includes ${limit} redirects — upgrade in Billing for more`;
|
|
29
|
+
}
|
|
30
|
+
/** What a caller is told about a rule: what it is, and that it is live. */ function factsOf(action, data) {
|
|
31
|
+
const values = redirectRuleValues('', data);
|
|
32
|
+
return {
|
|
33
|
+
action,
|
|
34
|
+
source: values['source'],
|
|
35
|
+
destination: values['destination'],
|
|
36
|
+
kind: values['kind'],
|
|
37
|
+
statusCode: values['statusCode'],
|
|
38
|
+
priority: values['priority'],
|
|
39
|
+
enabled: action === 'remove' ? false : values['enabled'],
|
|
40
|
+
removed: action === 'remove',
|
|
41
|
+
live: true
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function recordOf(id, action, data) {
|
|
45
|
+
var _data_source, _data_destination;
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
name: `${String((_data_source = data['source']) != null ? _data_source : '')} → ${String((_data_destination = data['destination']) != null ? _data_destination : '')}`,
|
|
49
|
+
versionId: null,
|
|
50
|
+
facts: factsOf(action, data)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function isLive(data) {
|
|
54
|
+
return Boolean(data) && !(data == null ? void 0 : data['deletedAt']);
|
|
55
|
+
}
|
|
56
|
+
/** A from-path as a rule of `kind` stores it: a pattern trimmed, a path normalized. */ function storedSourceOf(kind, source) {
|
|
57
|
+
return kind === 'regex' ? source.trim() || null : normalizeRedirectSource(source);
|
|
58
|
+
}
|
|
59
|
+
export function createRedirectAiWriter(deps) {
|
|
60
|
+
var _deps_declaration;
|
|
61
|
+
const declaration = (_deps_declaration = deps.declaration) != null ? _deps_declaration : redirectDeclaration;
|
|
62
|
+
const hostRef = (hostId)=>deps.firestore().collection('hosts').doc(hostId);
|
|
63
|
+
const rulesOf = (hostId)=>hostRef(hostId).collection('redirects');
|
|
64
|
+
async function refusalFor(host, context) {
|
|
65
|
+
var _host_data, _data_memberRoles, _context_org;
|
|
66
|
+
if (!host.exists) return {
|
|
67
|
+
status: 404,
|
|
68
|
+
error: 'Unknown site'
|
|
69
|
+
};
|
|
70
|
+
const data = (_host_data = host.data()) != null ? _host_data : {};
|
|
71
|
+
const role = ((_data_memberRoles = data['memberRoles']) != null ? _data_memberRoles : {})[context.uid];
|
|
72
|
+
if (!hostRoleCanPublish(role)) return {
|
|
73
|
+
status: 403,
|
|
74
|
+
error: REDIRECT_AI_ROLE_REFUSAL
|
|
75
|
+
};
|
|
76
|
+
const declared = declaration();
|
|
77
|
+
if (declared.entitlement && !checkEntitlement((_context_org = context.org) != null ? _context_org : {}, declared.entitlement)) {
|
|
78
|
+
return {
|
|
79
|
+
status: 403,
|
|
80
|
+
error: REDIRECT_AI_PLAN_REFUSAL
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (await deps.lockdown({
|
|
84
|
+
uid: context.uid,
|
|
85
|
+
org: context.org,
|
|
86
|
+
host: data
|
|
87
|
+
})) {
|
|
88
|
+
return {
|
|
89
|
+
status: 403,
|
|
90
|
+
error: REDIRECT_AI_LOCKED_REFUSAL
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/** Every rule document of the site, soft-deleted ones included. */ async function readAllRules(hostId) {
|
|
96
|
+
const snapshot = await rulesOf(hostId).get();
|
|
97
|
+
return snapshot.docs.map((doc)=>{
|
|
98
|
+
var _doc_data;
|
|
99
|
+
return {
|
|
100
|
+
id: doc.id,
|
|
101
|
+
data: (_doc_data = doc.data()) != null ? _doc_data : {}
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** The live rules at a from-path, of `kind` when it is given. */ function rulesAt(all, source, kind) {
|
|
106
|
+
return all.filter(({ data })=>{
|
|
107
|
+
const ruleKind = typeof data['kind'] === 'string' && data['kind'] ? data['kind'] : 'exact';
|
|
108
|
+
if (kind && ruleKind !== kind) return false;
|
|
109
|
+
return data['source'] === storedSourceOf(ruleKind, source);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Why the rule a change leaves cannot be saved among the site's live rules, or `null`. */ function setProblem(rows, rule) {
|
|
113
|
+
if (findDuplicateRedirect(rows, rule)) return `A rule for ${rule.source} already exists`;
|
|
114
|
+
const walk = walkRedirectChain(rows, rule);
|
|
115
|
+
if (walk.loop) return 'That destination chains back to this rule — a redirect loop';
|
|
116
|
+
if (walk.cycle) return 'That destination leads into a redirect loop other rules already make';
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
function livePageProblem(host, source, kind, asked) {
|
|
120
|
+
if (asked || kind === 'regex' || !redirectSourceIsLivePage(host['screens'], source)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return `${source} is a published page, and a redirect there would take its place. Redirect it only when the person asks for that page to send visitors elsewhere.`;
|
|
124
|
+
}
|
|
125
|
+
async function announce(hostId, paths) {
|
|
126
|
+
const unique = [
|
|
127
|
+
...new Set(paths.filter((path)=>Boolean(path)))
|
|
128
|
+
];
|
|
129
|
+
if (unique.length && deps.announcePaths) await deps.announcePaths(hostId, unique).catch(()=>undefined);
|
|
130
|
+
}
|
|
131
|
+
const refuse = (status, error)=>({
|
|
132
|
+
ok: false,
|
|
133
|
+
status,
|
|
134
|
+
error
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
refusal: async (context)=>refusalFor(await hostRef(context.hostId).get(), context),
|
|
138
|
+
check: (content)=>checkRedirectDraftContent(content),
|
|
139
|
+
read: async ({ hostId, id })=>{
|
|
140
|
+
var _snapshot_data;
|
|
141
|
+
const snapshot = await rulesOf(hostId).doc(id).get();
|
|
142
|
+
if (!snapshot.exists) return null;
|
|
143
|
+
const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
144
|
+
return recordOf(id, isLive(data) ? 'create' : 'remove', data);
|
|
145
|
+
},
|
|
146
|
+
write: async (request)=>{
|
|
147
|
+
var _hostSnapshot_data, _ref, _fields_kind, _current_approvalField;
|
|
148
|
+
var _declared_externalDestination;
|
|
149
|
+
const read = readRedirectDraftContent(request.content);
|
|
150
|
+
if (read.ok === false) return refuse(400, read.problems[0]);
|
|
151
|
+
const change = read.value;
|
|
152
|
+
const hostSnapshot = await hostRef(request.hostId).get();
|
|
153
|
+
const refusal = await refusalFor(hostSnapshot, request);
|
|
154
|
+
if (refusal) return _extends({
|
|
155
|
+
ok: false
|
|
156
|
+
}, refusal);
|
|
157
|
+
const host = (_hostSnapshot_data = hostSnapshot.data()) != null ? _hostSnapshot_data : {};
|
|
158
|
+
const declared = declaration();
|
|
159
|
+
const approvalField = (_ref = (_declared_externalDestination = declared.externalDestination) == null ? void 0 : _declared_externalDestination.approvedByField) != null ? _ref : REDIRECT_FIELD.approvedBy;
|
|
160
|
+
const stampFor = (destination)=>declared.externalDestination && isExternalRedirectDestination(String(destination != null ? destination : '')) ? request.uid : null;
|
|
161
|
+
const at = Timestamp.fromDate(request.now);
|
|
162
|
+
const all = await readAllRules(request.hostId);
|
|
163
|
+
const live = all.filter(({ data })=>isLive(data));
|
|
164
|
+
const rows = live.map(({ id, data })=>({
|
|
165
|
+
$id: id,
|
|
166
|
+
source: data['source'],
|
|
167
|
+
destination: data['destination'],
|
|
168
|
+
kind: data['kind']
|
|
169
|
+
}));
|
|
170
|
+
if (change.action === 'create') {
|
|
171
|
+
var _change_kind, _change_statusCode, _change_priority, _change_enabled, _fields_kind1;
|
|
172
|
+
const ref = rulesOf(request.hostId).doc(request.id);
|
|
173
|
+
const before = all.find((one)=>one.id === request.id);
|
|
174
|
+
if (before) return _extends({
|
|
175
|
+
ok: true,
|
|
176
|
+
replayed: true
|
|
177
|
+
}, recordOf(request.id, 'create', before.data));
|
|
178
|
+
const rule = withDefaults({
|
|
179
|
+
source: change.source,
|
|
180
|
+
destination: change.destination,
|
|
181
|
+
kind: (_change_kind = change.kind) != null ? _change_kind : 'exact',
|
|
182
|
+
statusCode: (_change_statusCode = change.statusCode) != null ? _change_statusCode : 302,
|
|
183
|
+
priority: (_change_priority = change.priority) != null ? _change_priority : REDIRECT_DEFAULT_PRIORITY,
|
|
184
|
+
enabled: (_change_enabled = change.enabled) != null ? _change_enabled : true
|
|
185
|
+
});
|
|
186
|
+
const problem = ruleProblem(rule, host);
|
|
187
|
+
if (problem) return refuse(400, problem.detail);
|
|
188
|
+
const fields = storedFields(rule, declared.fields);
|
|
189
|
+
const source = String(fields['source']);
|
|
190
|
+
const kind = String((_fields_kind1 = fields['kind']) != null ? _fields_kind1 : 'exact');
|
|
191
|
+
const shadow = livePageProblem(host, source, kind, change.replacesLivePage);
|
|
192
|
+
if (shadow) return refuse(409, shadow);
|
|
193
|
+
const clash = setProblem(rows, {
|
|
194
|
+
$id: request.id,
|
|
195
|
+
source,
|
|
196
|
+
destination: String(fields['destination']),
|
|
197
|
+
kind
|
|
198
|
+
});
|
|
199
|
+
if (clash) return refuse(409, clash);
|
|
200
|
+
const stamp = stampFor(fields['destination']);
|
|
201
|
+
const written = _extends({}, fields, stamp ? {
|
|
202
|
+
[approvalField]: stamp
|
|
203
|
+
} : {});
|
|
204
|
+
const db = deps.firestore();
|
|
205
|
+
const outcome = await db.runTransaction(async (tx)=>{
|
|
206
|
+
var _existing_data;
|
|
207
|
+
const existing = await tx.get(ref);
|
|
208
|
+
if (existing.exists) return {
|
|
209
|
+
existed: (_existing_data = existing.data()) != null ? _existing_data : {}
|
|
210
|
+
};
|
|
211
|
+
if (declared.quotaKey) {
|
|
212
|
+
var _request_org;
|
|
213
|
+
// Every document, soft-deleted ones included, as the create route counts.
|
|
214
|
+
const used = (await tx.get(rulesOf(request.hostId).count())).data().count;
|
|
215
|
+
const quota = checkQuota((_request_org = request.org) != null ? _request_org : {}, declared.quotaKey, Number(used) || 0);
|
|
216
|
+
if (!quota.allowed) return {
|
|
217
|
+
refusal: redirectAiLimitRefusal(quota.limit)
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
tx.create(ref, _extends({}, written, {
|
|
221
|
+
createdAt: at,
|
|
222
|
+
updatedAt: at,
|
|
223
|
+
createdBy: request.uid
|
|
224
|
+
}));
|
|
225
|
+
return {};
|
|
226
|
+
});
|
|
227
|
+
if (outcome.refusal) return refuse(403, outcome.refusal);
|
|
228
|
+
if (outcome.existed) return _extends({
|
|
229
|
+
ok: true,
|
|
230
|
+
replayed: true
|
|
231
|
+
}, recordOf(request.id, 'create', outcome.existed));
|
|
232
|
+
if (deps.logCreated) {
|
|
233
|
+
await deps.logCreated(request.hostId, request.uid, ref.id, declared).catch(()=>undefined);
|
|
234
|
+
}
|
|
235
|
+
await announce(request.hostId, [
|
|
236
|
+
announcedPath(fields)
|
|
237
|
+
]);
|
|
238
|
+
return _extends({
|
|
239
|
+
ok: true,
|
|
240
|
+
replayed: false
|
|
241
|
+
}, recordOf(ref.id, 'create', written));
|
|
242
|
+
}
|
|
243
|
+
// An update or a remove names a rule the site has.
|
|
244
|
+
const found = rulesAt(live, change.source, change.kind);
|
|
245
|
+
if (found.length > 1) {
|
|
246
|
+
return refuse(409, `The site has more than one redirect at ${change.source}; say which match mode (exact, prefix or regex) to change`);
|
|
247
|
+
}
|
|
248
|
+
const target = found[0];
|
|
249
|
+
if (!target) {
|
|
250
|
+
if (change.action === 'remove') {
|
|
251
|
+
const gone = rulesAt(all, change.source, change.kind)[0];
|
|
252
|
+
if (gone) return _extends({
|
|
253
|
+
ok: true,
|
|
254
|
+
replayed: true
|
|
255
|
+
}, recordOf(gone.id, 'remove', gone.data));
|
|
256
|
+
}
|
|
257
|
+
return refuse(404, `The site has no redirect from ${change.source}`);
|
|
258
|
+
}
|
|
259
|
+
const ref = rulesOf(request.hostId).doc(target.id);
|
|
260
|
+
if (change.action === 'remove') {
|
|
261
|
+
// The page's Delete: marked deleted and off, so the serve path drops it.
|
|
262
|
+
await ref.set({
|
|
263
|
+
deletedAt: at,
|
|
264
|
+
enabled: false
|
|
265
|
+
}, {
|
|
266
|
+
merge: true
|
|
267
|
+
});
|
|
268
|
+
await announce(request.hostId, [
|
|
269
|
+
announcedPath(target.data)
|
|
270
|
+
]);
|
|
271
|
+
return _extends({
|
|
272
|
+
ok: true,
|
|
273
|
+
replayed: false
|
|
274
|
+
}, recordOf(target.id, 'remove', target.data));
|
|
275
|
+
}
|
|
276
|
+
const current = redirectRuleValues(target.id, target.data);
|
|
277
|
+
const rule = withDefaults(_extends({}, current, change.newSource !== undefined ? {
|
|
278
|
+
source: change.newSource
|
|
279
|
+
} : {}, change.destination !== undefined ? {
|
|
280
|
+
destination: change.destination
|
|
281
|
+
} : {}, change.statusCode !== undefined ? {
|
|
282
|
+
statusCode: change.statusCode
|
|
283
|
+
} : {}, change.priority !== undefined ? {
|
|
284
|
+
priority: change.priority
|
|
285
|
+
} : {}, change.enabled !== undefined ? {
|
|
286
|
+
enabled: change.enabled
|
|
287
|
+
} : {}));
|
|
288
|
+
const problem = ruleProblem(rule, host);
|
|
289
|
+
if (problem) return refuse(400, problem.detail);
|
|
290
|
+
const fields = storedFields(rule, declared.fields);
|
|
291
|
+
const source = String(fields['source']);
|
|
292
|
+
const kind = String((_fields_kind = fields['kind']) != null ? _fields_kind : 'exact');
|
|
293
|
+
if (source !== current['source']) {
|
|
294
|
+
const shadow = livePageProblem(host, source, kind, change.replacesLivePage);
|
|
295
|
+
if (shadow) return refuse(409, shadow);
|
|
296
|
+
}
|
|
297
|
+
const clash = setProblem(rows, {
|
|
298
|
+
$id: target.id,
|
|
299
|
+
source,
|
|
300
|
+
destination: String(fields['destination']),
|
|
301
|
+
kind
|
|
302
|
+
});
|
|
303
|
+
if (clash) return refuse(409, clash);
|
|
304
|
+
const stamp = stampFor(fields['destination']);
|
|
305
|
+
const unchanged = Object.entries(fields).every(([key, value])=>current[key] === value) && ((_current_approvalField = current[approvalField]) != null ? _current_approvalField : null) === stamp;
|
|
306
|
+
const after = _extends({}, target.data, fields, stamp ? {
|
|
307
|
+
[approvalField]: stamp
|
|
308
|
+
} : {});
|
|
309
|
+
if (unchanged) return _extends({
|
|
310
|
+
ok: true,
|
|
311
|
+
replayed: true
|
|
312
|
+
}, recordOf(target.id, 'update', after));
|
|
313
|
+
// The page's edit: every field the form owns, the stamp set or removed,
|
|
314
|
+
// `updatedAt`, merged so `lastHitAt` stays.
|
|
315
|
+
await ref.set(_extends({}, fields, {
|
|
316
|
+
[approvalField]: stamp != null ? stamp : FieldValue.delete(),
|
|
317
|
+
updatedAt: at
|
|
318
|
+
}), {
|
|
319
|
+
merge: true
|
|
320
|
+
});
|
|
321
|
+
await announce(request.hostId, [
|
|
322
|
+
announcedPath(fields),
|
|
323
|
+
announcedPath(target.data)
|
|
324
|
+
]);
|
|
325
|
+
return _extends({
|
|
326
|
+
ok: true,
|
|
327
|
+
replayed: false
|
|
328
|
+
}, recordOf(target.id, 'update', after));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
//# sourceMappingURL=redirect-ai-writer.js.map
|