@teleporthq/teleport-plugin-next-workflows 0.45.8 → 0.45.9
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/__tests__/newsletter-plugin.test.ts +165 -0
- package/dist/cjs/newsletter/helper-code.js +2 -2
- package/dist/cjs/newsletter/routes-code.d.ts.map +1 -1
- package/dist/cjs/newsletter/routes-code.js +64 -31
- package/dist/cjs/newsletter/routes-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/newsletter/helper-code.js +2 -2
- package/dist/esm/newsletter/routes-code.d.ts.map +1 -1
- package/dist/esm/newsletter/routes-code.js +64 -31
- package/dist/esm/newsletter/routes-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/newsletter/helper-code.ts +2 -2
- package/src/newsletter/routes-code.ts +67 -31
|
@@ -723,6 +723,171 @@ describe('the newsletter routes', () => {
|
|
|
723
723
|
expect(ok.body).toEqual({ ok: true, email: 'wait@site.test' })
|
|
724
724
|
const bad = await route(app, 'confirm', { method: 'POST', body: { token: 'x.y' } })
|
|
725
725
|
expect(bad.body).toEqual({ ok: false })
|
|
726
|
+
const queriesBefore = app.db.queries.length
|
|
727
|
+
const none = await route(app, 'confirm', { method: 'POST', body: {} })
|
|
728
|
+
expect(none.statusCode).toBe(200)
|
|
729
|
+
expect(none.body).toEqual({ ok: false })
|
|
730
|
+
expect(app.db.queries).toHaveLength(queriesBefore)
|
|
731
|
+
app.db.failWith = { message: 'connection refused', code: 'ECONNREFUSED' }
|
|
732
|
+
const down = await route(app, 'confirm', { method: 'POST', body: { token } })
|
|
733
|
+
expect(down.statusCode).toBe(503)
|
|
734
|
+
expect(down.body).toEqual({
|
|
735
|
+
error: 'We could not confirm the subscription. Please try again in a moment.',
|
|
736
|
+
})
|
|
737
|
+
const put = await route(app, 'confirm', { method: 'PUT', body: { token } })
|
|
738
|
+
expect(put.statusCode).toBe(405)
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it("confirm: the emailed link to the site's confirm route shows a button; only its POST confirms", async () => {
|
|
742
|
+
const app = loadApp({
|
|
743
|
+
settings: {
|
|
744
|
+
pages: { confirm: '/api/newsletter/confirm', unsubscribe: '/api/newsletter/unsubscribe' },
|
|
745
|
+
},
|
|
746
|
+
})
|
|
747
|
+
await app.helper.subscribeNewsletterPerson({
|
|
748
|
+
db: app.db,
|
|
749
|
+
email: 'click@reader.test',
|
|
750
|
+
signUpPoint: 'block',
|
|
751
|
+
req: REQ,
|
|
752
|
+
})
|
|
753
|
+
expect(app.sent[0].html).toContain('https://studio.example/api/newsletter/confirm?t=')
|
|
754
|
+
const token = linkToken(app.sent[0].html)
|
|
755
|
+
// The person's click, and a mail client's link scanner opening the same
|
|
756
|
+
// link first, subscribe nobody.
|
|
757
|
+
for (let opened = 0; opened < 2; opened++) {
|
|
758
|
+
const page = await route(app, 'confirm', { method: 'GET', query: { t: token } })
|
|
759
|
+
expect(page.statusCode).toBe(200)
|
|
760
|
+
expect(page.headers['Content-Type']).toBe('text/html; charset=utf-8')
|
|
761
|
+
expect(page.headers['Cache-Control']).toBe('no-store')
|
|
762
|
+
expect(page.body).toContain('<h1>Confirm your subscription</h1>')
|
|
763
|
+
expect(page.body).toContain(
|
|
764
|
+
'<form method="post" action="/api/newsletter/confirm"><input type="hidden" name="t" value="' +
|
|
765
|
+
token +
|
|
766
|
+
'"><button type="submit">Confirm subscription</button></form>'
|
|
767
|
+
)
|
|
768
|
+
}
|
|
769
|
+
expect(app.db.rows[0].newsletter_status).toBe('pending')
|
|
770
|
+
expect(app.db.writes().map((query) => query.sql)).toEqual([NEWSLETTER_SQL.insertPending])
|
|
771
|
+
expect(app.sent).toHaveLength(1)
|
|
772
|
+
|
|
773
|
+
const pressButton = () =>
|
|
774
|
+
route(app, 'confirm', {
|
|
775
|
+
method: 'POST',
|
|
776
|
+
headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
|
|
777
|
+
body: { t: token },
|
|
778
|
+
})
|
|
779
|
+
const done = await pressButton()
|
|
780
|
+
expect(done.statusCode).toBe(200)
|
|
781
|
+
expect(done.headers['Content-Type']).toBe('text/html; charset=utf-8')
|
|
782
|
+
expect(done.body).toContain('<h1>You're subscribed</h1>')
|
|
783
|
+
expect(app.db.rows[0]).toMatchObject({
|
|
784
|
+
newsletter_status: 'subscribed',
|
|
785
|
+
marketing_consent: true,
|
|
786
|
+
})
|
|
787
|
+
expect(app.events.map((event) => event.type)).toEqual(['newsletter-subscribed'])
|
|
788
|
+
expect(app.sent).toHaveLength(2)
|
|
789
|
+
expect(app.sent[1].html).toContain('https://studio.example/api/newsletter/unsubscribe?t=')
|
|
790
|
+
// The same link used again: the same answer, nothing happens twice.
|
|
791
|
+
const again = await pressButton()
|
|
792
|
+
expect(again.body).toContain('<h1>You're subscribed</h1>')
|
|
793
|
+
expect(app.events).toHaveLength(1)
|
|
794
|
+
expect(app.sent).toHaveLength(2)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('confirm: a bad, expired or wrong link answers a plain page and confirms nothing', async () => {
|
|
798
|
+
const app = loadApp()
|
|
799
|
+
app.db.rows.push({ id: 'u-p', email: 'wait@site.test', newsletter_status: 'pending' })
|
|
800
|
+
const eightDaysAgo = Math.floor(Date.now() / 1000) - 8 * 24 * 3600
|
|
801
|
+
const notALink =
|
|
802
|
+
'This confirmation link does not work. A link works for 7 days: sign up again to get a new one.'
|
|
803
|
+
const badLinks = [
|
|
804
|
+
'',
|
|
805
|
+
'x.y',
|
|
806
|
+
'"><script>alert(1)</script>',
|
|
807
|
+
app.helper.signNewsletterToken('wait@site.test', 'confirm', eightDaysAgo),
|
|
808
|
+
app.helper.signNewsletterToken('wait@site.test', 'unsubscribe'),
|
|
809
|
+
loadApp({ env: { NEXTAUTH_SECRET: 'another-site' } }).helper.signNewsletterToken(
|
|
810
|
+
'wait@site.test',
|
|
811
|
+
'confirm'
|
|
812
|
+
),
|
|
813
|
+
]
|
|
814
|
+
const expectNotALinkPage = (res: Row) => {
|
|
815
|
+
expect(res.statusCode).toBe(200)
|
|
816
|
+
expect(res.headers['Content-Type']).toBe('text/html; charset=utf-8')
|
|
817
|
+
expect(res.body).toContain('<h1>Link not valid</h1>')
|
|
818
|
+
expect(res.body).toContain(notALink)
|
|
819
|
+
expect(res.body).not.toContain('<form')
|
|
820
|
+
expect(res.body).not.toContain('<script>')
|
|
821
|
+
}
|
|
822
|
+
expectNotALinkPage(await route(app, 'confirm', { method: 'GET' }))
|
|
823
|
+
for (const t of badLinks) {
|
|
824
|
+
expectNotALinkPage(await route(app, 'confirm', { method: 'GET', query: { t } }))
|
|
825
|
+
expectNotALinkPage(
|
|
826
|
+
await route(app, 'confirm', {
|
|
827
|
+
method: 'POST',
|
|
828
|
+
headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
|
|
829
|
+
body: { t },
|
|
830
|
+
})
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
expect(app.db.writes()).toEqual([])
|
|
834
|
+
expect(app.db.rows[0].newsletter_status).toBe('pending')
|
|
835
|
+
expect(app.sent).toEqual([])
|
|
836
|
+
|
|
837
|
+
// The database down while the button is pressed: a plain page, not the error.
|
|
838
|
+
app.db.failWith = { message: 'connection refused', code: 'ECONNREFUSED' }
|
|
839
|
+
const down = await route(app, 'confirm', {
|
|
840
|
+
method: 'POST',
|
|
841
|
+
headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
|
|
842
|
+
body: { t: app.helper.signNewsletterToken('wait@site.test', 'confirm') },
|
|
843
|
+
})
|
|
844
|
+
expect(down.statusCode).toBe(503)
|
|
845
|
+
expect(down.body).toContain('<h1>Please try again</h1>')
|
|
846
|
+
expect(down.body).not.toContain('connection refused')
|
|
847
|
+
expect(down.body).not.toContain('ECONNREFUSED')
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('confirm: a project that still has its own confirm page keeps linking to it, and its step confirms', async () => {
|
|
851
|
+
const app = loadApp()
|
|
852
|
+
await app.helper.subscribeNewsletterPerson({
|
|
853
|
+
db: app.db,
|
|
854
|
+
email: 'page@reader.test',
|
|
855
|
+
signUpPoint: 'footer',
|
|
856
|
+
req: REQ,
|
|
857
|
+
})
|
|
858
|
+
expect(app.sent[0].html).toContain('https://studio.example/newsletter/confirm#t=')
|
|
859
|
+
expect(app.sent[0].html).not.toContain('/api/newsletter/confirm')
|
|
860
|
+
const realFetch = (global as any).fetch
|
|
861
|
+
;(global as any).fetch = async (
|
|
862
|
+
url: string,
|
|
863
|
+
init: { method: string; headers: Row; body: string }
|
|
864
|
+
) => {
|
|
865
|
+
expect(url).toBe('https://studio.example/api/newsletter/confirm')
|
|
866
|
+
const res = await route(app, 'confirm', {
|
|
867
|
+
method: init.method,
|
|
868
|
+
headers: { host: 'studio.example', 'content-type': init.headers['Content-Type'] },
|
|
869
|
+
body: JSON.parse(init.body),
|
|
870
|
+
})
|
|
871
|
+
return {
|
|
872
|
+
ok: res.statusCode < 400,
|
|
873
|
+
json: async () => {
|
|
874
|
+
expect(typeof res.body).toBe('object')
|
|
875
|
+
return res.body
|
|
876
|
+
},
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
const confirmStep = loadHandler('newsletter-confirm')
|
|
881
|
+
expect(
|
|
882
|
+
await confirmStep(
|
|
883
|
+
{ token: linkToken(app.sent[0].html) },
|
|
884
|
+
{ __baseUrl: 'https://studio.example' }
|
|
885
|
+
)
|
|
886
|
+
).toEqual({ ok: true, email: 'page@reader.test' })
|
|
887
|
+
} finally {
|
|
888
|
+
;(global as any).fetch = realFetch
|
|
889
|
+
}
|
|
890
|
+
expect(app.db.rows[0].newsletter_status).toBe('subscribed')
|
|
726
891
|
})
|
|
727
892
|
})
|
|
728
893
|
|
|
@@ -444,8 +444,8 @@ function canSendNewsletterEmail() {
|
|
|
444
444
|
|
|
445
445
|
// A link to one of the site's pages carries the token in its fragment: a
|
|
446
446
|
// fragment never reaches a server, a CDN log or a Referer (the page's first
|
|
447
|
-
// head script keeps it). The site's own unsubscribe
|
|
448
|
-
// server, so a link to
|
|
447
|
+
// head script keeps it). The site's own confirm and unsubscribe routes read it
|
|
448
|
+
// on the server, so a link to one of them carries it in the query.
|
|
449
449
|
function pageLink(path, token) {
|
|
450
450
|
return path + (path.indexOf('/api/') === 0 ? '?t=' : '#t=') + encodeURIComponent(token);
|
|
451
451
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes-code.d.ts","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"routes-code.d.ts","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":"AA4BA,6DAA6D;AAC7D,eAAO,MAAM,6BAA6B,KAAK,CAAA;AAoB/C,eAAO,MAAM,gCAAgC,QAAO,MAuCnD,CAAA;AA8BD,eAAO,MAAM,8BAA8B,QAAO,MAmDjD,CAAA;AAED,eAAO,MAAM,kCAAkC,QAAO,MAkDrD,CAAA"}
|
|
@@ -11,12 +11,13 @@ exports.generateNewsletterUnsubscribeRoute = exports.generateNewsletterConfirmRo
|
|
|
11
11
|
* `footer`, `popup`, `form`), else `block`; errors are logged by code and
|
|
12
12
|
* message only (never a database row); every answer is `{ ok: true, pending }`, after at least
|
|
13
13
|
* `NEWSLETTER_MIN_RESPONSE_MS`, whatever happened to the address.
|
|
14
|
-
* - `confirm` — POST `{ token }` → `{ ok, email? }
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* - `confirm` and `unsubscribe` — POST `{ token }` → `{ ok, email? }`; a GET
|
|
15
|
+
* with `?t=` (a click in a mail client) answers a small page whose button
|
|
16
|
+
* POSTs it, so a link scanner that opens every link confirms and
|
|
17
|
+
* unsubscribes nobody.
|
|
18
18
|
*/
|
|
19
19
|
const security_runtime_code_1 = require("../security-runtime/security-runtime-code");
|
|
20
|
+
const helper_code_1 = require("./helper-code");
|
|
20
21
|
const newsletter_scope_1 = require("./newsletter-scope");
|
|
21
22
|
/** From `pages/api/newsletter/` to the project root. */
|
|
22
23
|
const RELATIVE_PREFIX = '../../..';
|
|
@@ -79,32 +80,7 @@ ${methodGuard(['POST'])}
|
|
|
79
80
|
};
|
|
80
81
|
`;
|
|
81
82
|
exports.generateNewsletterSubscribeRoute = generateNewsletterSubscribeRoute;
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
module.exports = async function handler(req, res) {
|
|
85
|
-
${methodGuard(['POST'])}
|
|
86
|
-
if (!(await __security.rateLimitRequest(req, res, { scope: 'newsletter-confirm', limit: ${exports.NEWSLETTER_LINK_VISITOR_LIMIT}, windowSeconds: 3600 }))) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
var body = readBody(req);
|
|
90
|
-
var token = typeof body.token === 'string' ? body.token : '';
|
|
91
|
-
if (!token) {
|
|
92
|
-
return res.status(200).json({ ok: false });
|
|
93
|
-
}
|
|
94
|
-
try {
|
|
95
|
-
var result = await __newsletter.withNewsletterDb(function (db) {
|
|
96
|
-
return __newsletter.confirmNewsletter({ db: db, token: token, req: req });
|
|
97
|
-
});
|
|
98
|
-
return res.status(200).json(result.ok ? { ok: true, email: result.email } : { ok: false });
|
|
99
|
-
} catch (err) {
|
|
100
|
-
return __security.sendClientError(res, 503, 'We could not confirm the subscription. Please try again in a moment.', __newsletter.safeError(err));
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
`;
|
|
104
|
-
exports.generateNewsletterConfirmRoute = generateNewsletterConfirmRoute;
|
|
105
|
-
const generateNewsletterUnsubscribeRoute = () => `${ROUTE_PREAMBLE}
|
|
106
|
-
|
|
107
|
-
function escapeHtml(value) {
|
|
83
|
+
const LINK_PAGE_CODE = `function escapeHtml(value) {
|
|
108
84
|
return String(value == null ? '' : value)
|
|
109
85
|
.replace(/&/g, '&')
|
|
110
86
|
.replace(/</g, '<')
|
|
@@ -128,7 +104,64 @@ function sendPage(res, status, title, message, form) {
|
|
|
128
104
|
'button{font:inherit;padding:10px 18px;border:0;border-radius:6px;background:#111;color:#fff;cursor:pointer}</style>' +
|
|
129
105
|
'</head><body><h1>' + escapeHtml(title) + '</h1><p>' + escapeHtml(message) + '</p>' + (form || '') + '</body></html>'
|
|
130
106
|
);
|
|
131
|
-
}
|
|
107
|
+
}`;
|
|
108
|
+
const CONFIRM_LINK_DAYS = helper_code_1.newsletterTokenSpec.confirmMaxAgeSeconds / 86400;
|
|
109
|
+
const generateNewsletterConfirmRoute = () => `${ROUTE_PREAMBLE}
|
|
110
|
+
|
|
111
|
+
${LINK_PAGE_CODE}
|
|
112
|
+
|
|
113
|
+
var NOT_A_LINK = 'This confirmation link does not work. A link works for ${CONFIRM_LINK_DAYS} days: sign up again to get a new one.';
|
|
114
|
+
|
|
115
|
+
module.exports = async function handler(req, res) {
|
|
116
|
+
${methodGuard(['GET', 'POST'])}
|
|
117
|
+
if (!(await __security.rateLimitRequest(req, res, { scope: 'newsletter-confirm', limit: ${exports.NEWSLETTER_LINK_VISITOR_LIMIT}, windowSeconds: 3600 }))) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (req.method === 'GET') {
|
|
121
|
+
// Opening the link changes nothing (a mail client's link scanner opens it
|
|
122
|
+
// too, and double opt-in means the person's own action): the button does.
|
|
123
|
+
var linkToken = req.query && typeof req.query.t === 'string' ? req.query.t : '';
|
|
124
|
+
if (!__newsletter.verifyNewsletterToken(linkToken, 'confirm')) {
|
|
125
|
+
return sendPage(res, 200, 'Link not valid', NOT_A_LINK, '');
|
|
126
|
+
}
|
|
127
|
+
return sendPage(
|
|
128
|
+
res,
|
|
129
|
+
200,
|
|
130
|
+
'Confirm your subscription',
|
|
131
|
+
'Receive the newsletter at this address?',
|
|
132
|
+
'<form method="post" action="${newsletter_scope_1.NEWSLETTER_CONFIRM_URL}"><input type="hidden" name="t" value="' +
|
|
133
|
+
escapeHtml(linkToken) + '"><button type="submit">Confirm subscription</button></form>'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
var body = readBody(req);
|
|
137
|
+
var fromForm = String((req.headers && req.headers['content-type']) || '').indexOf('application/x-www-form-urlencoded') !== -1;
|
|
138
|
+
var token = typeof body.token === 'string' ? body.token : typeof body.t === 'string' ? body.t : '';
|
|
139
|
+
var result = { ok: false };
|
|
140
|
+
try {
|
|
141
|
+
if (token) {
|
|
142
|
+
result = await __newsletter.withNewsletterDb(function (db) {
|
|
143
|
+
return __newsletter.confirmNewsletter({ db: db, token: token, req: req });
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (fromForm) {
|
|
148
|
+
console.error('[newsletter] confirm failed:', __newsletter.safeError(err));
|
|
149
|
+
return sendPage(res, 503, 'Please try again', 'We could not confirm your subscription just now. Please try again in a moment.', '');
|
|
150
|
+
}
|
|
151
|
+
return __security.sendClientError(res, 503, 'We could not confirm the subscription. Please try again in a moment.', __newsletter.safeError(err));
|
|
152
|
+
}
|
|
153
|
+
if (fromForm) {
|
|
154
|
+
return result.ok
|
|
155
|
+
? sendPage(res, 200, "You're subscribed", 'Thank you for confirming. You will receive the newsletter at this address.', '')
|
|
156
|
+
: sendPage(res, 200, 'Link not valid', NOT_A_LINK, '');
|
|
157
|
+
}
|
|
158
|
+
return res.status(200).json(result.ok ? { ok: true, email: result.email } : { ok: false });
|
|
159
|
+
};
|
|
160
|
+
`;
|
|
161
|
+
exports.generateNewsletterConfirmRoute = generateNewsletterConfirmRoute;
|
|
162
|
+
const generateNewsletterUnsubscribeRoute = () => `${ROUTE_PREAMBLE}
|
|
163
|
+
|
|
164
|
+
${LINK_PAGE_CODE}
|
|
132
165
|
|
|
133
166
|
var NOT_A_LINK = 'This unsubscribe link does not work. Use the link in a newer email from us.';
|
|
134
167
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes-code.js","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;GAcG;AACH,qFAAoF;AACpF,
|
|
1
|
+
{"version":3,"file":"routes-code.js","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;GAcG;AACH,qFAAoF;AACpF,+CAAmD;AACnD,yDAM2B;AAE3B,wDAAwD;AACxD,MAAM,eAAe,GAAG,UAAU,CAAA;AAElC,6DAA6D;AAChD,QAAA,6BAA6B,GAAG,EAAE,CAAA;AAE/C,MAAM,cAAc,GAAG,6BAA6B,IAAA,gDAAwB,EAAC,eAAe,CAAC;8BAC/D,IAAA,4CAAyB,EAAC,eAAe,CAAC;;;;;;;;EAQtE,CAAA;AAEF,MAAM,WAAW,GAAG,CAAC,OAAiB,EAAU,EAAE,CAAC,SAAS,OAAO;KAChE,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,mBAAmB,MAAM,GAAG,CAAC;KAC7C,IAAI,CAAC,MAAM,CAAC;8BACe,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC;;IAE5C,CAAA;AAEG,MAAM,gCAAgC,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;;EAG7E,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;OAChB,2CAAwB;mDACoB,2CAAwB;8EACG,2CAAwB,aAAa,2CAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiC1I,CAAA;AAvCY,QAAA,gCAAgC,oCAuC5C;AAED,MAAM,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;EAwBrB,CAAA;AAEF,MAAM,iBAAiB,GAAG,iCAAmB,CAAC,oBAAoB,GAAG,KAAK,CAAA;AAEnE,MAAM,8BAA8B,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;EAE3E,cAAc;;2EAE2D,iBAAiB;;;EAG1F,WAAW,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;4FAC8D,qCAA6B;;;;;;;;;;;;;;;qCAepF,yCAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4B1D,CAAA;AAnDY,QAAA,8BAA8B,kCAmD1C;AAEM,MAAM,kCAAkC,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;EAE/E,cAAc;;;;;EAKd,WAAW,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;gGACkE,qCAA6B;;;;;;;;;;;;;;qCAcxF,6CAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4B9D,CAAA;AAlDY,QAAA,kCAAkC,sCAkD9C"}
|