Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-connector-scopes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `ScopeConfig` for a connector's `scopes` — declare `required` scopes, an optional `description` (friendly permission bullets shown at connect time), and `optional` scope groups the user can toggle. Auth now succeeds even when optional scopes are declined; connectors detect the result via the granted `token.scopes`.
48 changes: 37 additions & 11 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,16 +147,35 @@ type SyncOptions = {
*/
export class GoogleCalendar extends Connector<GoogleCalendar> {
static readonly PROVIDER = AuthProvider.Google;
static readonly SCOPES = [
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
static readonly EVENTS_SCOPE =
"https://www.googleapis.com/auth/calendar.events";
static readonly CALENDAR_LIST_SCOPE =
"https://www.googleapis.com/auth/calendar.calendarlist.readonly";

readonly provider = AuthProvider.Google;
readonly scopes = Integrations.MergeScopes(
GoogleCalendar.SCOPES,
GoogleContacts.SCOPES
);
readonly scopes = {
required: [GoogleCalendar.EVENTS_SCOPE],
description: [
"Reads your events to add them to your agenda",
"Writes your event RSVPs",
],
optional: [
{
id: "contacts",
label: "Add names to events using contacts",
scopes: GoogleContacts.SCOPES,
default: true,
},
{
id: "calendars",
label: "List all calendars",
description:
"List all calendars so you can choose which to sync. If disabled, only your primary calendar will be synced.",
scopes: [GoogleCalendar.CALENDAR_LIST_SCOPE],
default: true,
},
],
};
readonly linkTypes = [
{
type: "event",
Expand DownExpand Up@@ -209,6 +228,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
_auth: Authorization,
token: AuthToken
): Promise<Channel[]> {
// Listing the user's calendars needs the (optional) calendar-list scope.
// If the user didn't grant it, sync only the primary calendar — calling
// calendarList without the scope 403s and would wrongly flag the
// connection for re-auth.
if (!token.scopes.includes(GoogleCalendar.CALENDAR_LIST_SCOPE)) {
return [{ id: "primary", title: "Calendar", enabledByDefault: true }];
}
const api = new GoogleApi(token.token);
const calendars = await this.listCalendarsWithApi(api);
// Default to syncing the user's OWN calendars (their primary + any
Expand DownExpand Up@@ -1388,9 +1414,9 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
const batch = Array.from(linksBySource.values());
if (batch.length > 0) {
// Enrich attendee/organizer contacts with names + avatars from the
// user's Google Contacts and "other contacts". Calendar already
// merges GoogleContacts.SCOPES into its scopes, so the People API
// is reachable without a separate connector install.
// user's Google Contacts and "other contacts". The People API is
// reachable when the user granted the optional contacts scope;
// enrichLinkContactsFromGoogle already no-ops when the scope is absent.
try {
const token = await this.tools.integrations.get(calendarId);
if (token) {
Expand Down
32 changes: 30 additions & 2 deletions twister/src/connector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,33 @@ export type CreateLinkDraft = {
inviteEmails?: string[];
};

/** An optional OAuth scope group the user can toggle at connect time. */
export type OptionalScopeGroup = {
/** Stable id used to track the user's selection. */
id: string;
/** Value-forward switch label, e.g. "Add names to events using contacts". */
label: string;
/** Optional secondary line shown under the label. */
description?: string;
/** The OAuth scope strings this group grants. */
scopes: string[];
/** Whether the group is requested by default (switch on). */
default: boolean;
};

/**
* Structured scope declaration. `required` scopes must be granted — auth fails
* and re-prompts if any is declined. `optional` groups are requested by default
* but auth still succeeds if the user declines them; the connector should detect
* the absence via the granted `token.scopes` and degrade gracefully.
*/
export type ScopeConfig = {
required: string[];
/** Friendly bullets describing what the always-on (required) access does. */
description?: string[];
optional?: OptionalScopeGroup[];
};

/**
* Base class for connectors — twists that sync data from external services.
*
Expand DownExpand Up@@ -226,8 +253,9 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
/** The OAuth provider this connector authenticates with. */
readonly provider?: AuthProvider;

/** OAuth scopes to request for this connector. */
readonly scopes?: string[];
/** OAuth scopes to request for this connector — a flat list (all required), or
* a {@link ScopeConfig} declaring required + optional scope groups. */
readonly scopes?: string[] | ScopeConfig;

// ---- Auth model ----

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-connector-scopes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `ScopeConfig` for a connector's `scopes` — declare `required` scopes, an optional `description` (friendly permission bullets shown at connect time), and `optional` scope groups the user can toggle. Auth now succeeds even when optional scopes are declined; connectors detect the result via the granted `token.scopes`.
48 changes: 37 additions & 11 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,16 +147,35 @@ type SyncOptions = {
*/
export class GoogleCalendar extends Connector<GoogleCalendar> {
static readonly PROVIDER = AuthProvider.Google;
static readonly SCOPES = [
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
static readonly EVENTS_SCOPE =
"https://www.googleapis.com/auth/calendar.events";
static readonly CALENDAR_LIST_SCOPE =
"https://www.googleapis.com/auth/calendar.calendarlist.readonly";

readonly provider = AuthProvider.Google;
readonly scopes = Integrations.MergeScopes(
GoogleCalendar.SCOPES,
GoogleContacts.SCOPES
);
readonly scopes = {
required: [GoogleCalendar.EVENTS_SCOPE],
description: [
"Reads your events to add them to your agenda",
"Writes your event RSVPs",
],
optional: [
{
id: "contacts",
label: "Add names to events using contacts",
scopes: GoogleContacts.SCOPES,
default: true,
},
{
id: "calendars",
label: "List all calendars",
description:
"List all calendars so you can choose which to sync. If disabled, only your primary calendar will be synced.",
scopes: [GoogleCalendar.CALENDAR_LIST_SCOPE],
default: true,
},
],
};
readonly linkTypes = [
{
type: "event",
Expand DownExpand Up@@ -209,6 +228,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
_auth: Authorization,
token: AuthToken
): Promise<Channel[]> {
// Listing the user's calendars needs the (optional) calendar-list scope.
// If the user didn't grant it, sync only the primary calendar — calling
// calendarList without the scope 403s and would wrongly flag the
// connection for re-auth.
if (!token.scopes.includes(GoogleCalendar.CALENDAR_LIST_SCOPE)) {
return [{ id: "primary", title: "Calendar", enabledByDefault: true }];
}
const api = new GoogleApi(token.token);
const calendars = await this.listCalendarsWithApi(api);
// Default to syncing the user's OWN calendars (their primary + any
Expand DownExpand Up@@ -1388,9 +1414,9 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
const batch = Array.from(linksBySource.values());
if (batch.length > 0) {
// Enrich attendee/organizer contacts with names + avatars from the
// user's Google Contacts and "other contacts". Calendar already
// merges GoogleContacts.SCOPES into its scopes, so the People API
// is reachable without a separate connector install.
// user's Google Contacts and "other contacts". The People API is
// reachable when the user granted the optional contacts scope;
// enrichLinkContactsFromGoogle already no-ops when the scope is absent.
try {
const token = await this.tools.integrations.get(calendarId);
if (token) {
Expand Down
32 changes: 30 additions & 2 deletions twister/src/connector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,33 @@ export type CreateLinkDraft = {
inviteEmails?: string[];
};

/** An optional OAuth scope group the user can toggle at connect time. */
export type OptionalScopeGroup = {
/** Stable id used to track the user's selection. */
id: string;
/** Value-forward switch label, e.g. "Add names to events using contacts". */
label: string;
/** Optional secondary line shown under the label. */
description?: string;
/** The OAuth scope strings this group grants. */
scopes: string[];
/** Whether the group is requested by default (switch on). */
default: boolean;
};

/**
* Structured scope declaration. `required` scopes must be granted — auth fails
* and re-prompts if any is declined. `optional` groups are requested by default
* but auth still succeeds if the user declines them; the connector should detect
* the absence via the granted `token.scopes` and degrade gracefully.
*/
export type ScopeConfig = {
required: string[];
/** Friendly bullets describing what the always-on (required) access does. */
description?: string[];
optional?: OptionalScopeGroup[];
};

/**
* Base class for connectors — twists that sync data from external services.
*
Expand DownExpand Up@@ -226,8 +253,9 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
/** The OAuth provider this connector authenticates with. */
readonly provider?: AuthProvider;

/** OAuth scopes to request for this connector. */
readonly scopes?: string[];
/** OAuth scopes to request for this connector — a flat list (all required), or
* a {@link ScopeConfig} declaring required + optional scope groups. */
readonly scopes?: string[] | ScopeConfig;

// ---- Auth model ----

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-connector-scopes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `ScopeConfig` for a connector's `scopes` — declare `required` scopes, an optional `description` (friendly permission bullets shown at connect time), and `optional` scope groups the user can toggle. Auth now succeeds even when optional scopes are declined; connectors detect the result via the granted `token.scopes`.
48 changes: 37 additions & 11 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,16 +147,35 @@ type SyncOptions = {
*/
export class GoogleCalendar extends Connector<GoogleCalendar> {
static readonly PROVIDER = AuthProvider.Google;
static readonly SCOPES = [
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
static readonly EVENTS_SCOPE =
"https://www.googleapis.com/auth/calendar.events";
static readonly CALENDAR_LIST_SCOPE =
"https://www.googleapis.com/auth/calendar.calendarlist.readonly";

readonly provider = AuthProvider.Google;
readonly scopes = Integrations.MergeScopes(
GoogleCalendar.SCOPES,
GoogleContacts.SCOPES
);
readonly scopes = {
required: [GoogleCalendar.EVENTS_SCOPE],
description: [
"Reads your events to add them to your agenda",
"Writes your event RSVPs",
],
optional: [
{
id: "contacts",
label: "Add names to events using contacts",
scopes: GoogleContacts.SCOPES,
default: true,
},
{
id: "calendars",
label: "List all calendars",
description:
"List all calendars so you can choose which to sync. If disabled, only your primary calendar will be synced.",
scopes: [GoogleCalendar.CALENDAR_LIST_SCOPE],
default: true,
},
],
};
readonly linkTypes = [
{
type: "event",
Expand DownExpand Up@@ -209,6 +228,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
_auth: Authorization,
token: AuthToken
): Promise<Channel[]> {
// Listing the user's calendars needs the (optional) calendar-list scope.
// If the user didn't grant it, sync only the primary calendar — calling
// calendarList without the scope 403s and would wrongly flag the
// connection for re-auth.
if (!token.scopes.includes(GoogleCalendar.CALENDAR_LIST_SCOPE)) {
return [{ id: "primary", title: "Calendar", enabledByDefault: true }];
}
const api = new GoogleApi(token.token);
const calendars = await this.listCalendarsWithApi(api);
// Default to syncing the user's OWN calendars (their primary + any
Expand DownExpand Up@@ -1388,9 +1414,9 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
const batch = Array.from(linksBySource.values());
if (batch.length > 0) {
// Enrich attendee/organizer contacts with names + avatars from the
// user's Google Contacts and "other contacts". Calendar already
// merges GoogleContacts.SCOPES into its scopes, so the People API
// is reachable without a separate connector install.
// user's Google Contacts and "other contacts". The People API is
// reachable when the user granted the optional contacts scope;
// enrichLinkContactsFromGoogle already no-ops when the scope is absent.
try {
const token = await this.tools.integrations.get(calendarId);
if (token) {
Expand Down
32 changes: 30 additions & 2 deletions twister/src/connector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,33 @@ export type CreateLinkDraft = {
inviteEmails?: string[];
};

/** An optional OAuth scope group the user can toggle at connect time. */
export type OptionalScopeGroup = {
/** Stable id used to track the user's selection. */
id: string;
/** Value-forward switch label, e.g. "Add names to events using contacts". */
label: string;
/** Optional secondary line shown under the label. */
description?: string;
/** The OAuth scope strings this group grants. */
scopes: string[];
/** Whether the group is requested by default (switch on). */
default: boolean;
};

/**
* Structured scope declaration. `required` scopes must be granted — auth fails
* and re-prompts if any is declined. `optional` groups are requested by default
* but auth still succeeds if the user declines them; the connector should detect
* the absence via the granted `token.scopes` and degrade gracefully.
*/
export type ScopeConfig = {
required: string[];
/** Friendly bullets describing what the always-on (required) access does. */
description?: string[];
optional?: OptionalScopeGroup[];
};

/**
* Base class for connectors — twists that sync data from external services.
*
Expand DownExpand Up@@ -226,8 +253,9 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
/** The OAuth provider this connector authenticates with. */
readonly provider?: AuthProvider;

/** OAuth scopes to request for this connector. */
readonly scopes?: string[];
/** OAuth scopes to request for this connector — a flat list (all required), or
* a {@link ScopeConfig} declaring required + optional scope groups. */
readonly scopes?: string[] | ScopeConfig;

// ---- Auth model ----

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-connector-scopes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `ScopeConfig` for a connector's `scopes` — declare `required` scopes, an optional `description` (friendly permission bullets shown at connect time), and `optional` scope groups the user can toggle. Auth now succeeds even when optional scopes are declined; connectors detect the result via the granted `token.scopes`.
48 changes: 37 additions & 11 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,16 +147,35 @@ type SyncOptions = {
*/
export class GoogleCalendar extends Connector<GoogleCalendar> {
static readonly PROVIDER = AuthProvider.Google;
static readonly SCOPES = [
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
static readonly EVENTS_SCOPE =
"https://www.googleapis.com/auth/calendar.events";
static readonly CALENDAR_LIST_SCOPE =
"https://www.googleapis.com/auth/calendar.calendarlist.readonly";

readonly provider = AuthProvider.Google;
readonly scopes = Integrations.MergeScopes(
GoogleCalendar.SCOPES,
GoogleContacts.SCOPES
);
readonly scopes = {
required: [GoogleCalendar.EVENTS_SCOPE],
description: [
"Reads your events to add them to your agenda",
"Writes your event RSVPs",
],
optional: [
{
id: "contacts",
label: "Add names to events using contacts",
scopes: GoogleContacts.SCOPES,
default: true,
},
{
id: "calendars",
label: "List all calendars",
description:
"List all calendars so you can choose which to sync. If disabled, only your primary calendar will be synced.",
scopes: [GoogleCalendar.CALENDAR_LIST_SCOPE],
default: true,
},
],
};
readonly linkTypes = [
{
type: "event",
Expand DownExpand Up@@ -209,6 +228,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
_auth: Authorization,
token: AuthToken
): Promise<Channel[]> {
// Listing the user's calendars needs the (optional) calendar-list scope.
// If the user didn't grant it, sync only the primary calendar — calling
// calendarList without the scope 403s and would wrongly flag the
// connection for re-auth.
if (!token.scopes.includes(GoogleCalendar.CALENDAR_LIST_SCOPE)) {
return [{ id: "primary", title: "Calendar", enabledByDefault: true }];
}
const api = new GoogleApi(token.token);
const calendars = await this.listCalendarsWithApi(api);
// Default to syncing the user's OWN calendars (their primary + any
Expand DownExpand Up@@ -1388,9 +1414,9 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
const batch = Array.from(linksBySource.values());
if (batch.length > 0) {
// Enrich attendee/organizer contacts with names + avatars from the
// user's Google Contacts and "other contacts". Calendar already
// merges GoogleContacts.SCOPES into its scopes, so the People API
// is reachable without a separate connector install.
// user's Google Contacts and "other contacts". The People API is
// reachable when the user granted the optional contacts scope;
// enrichLinkContactsFromGoogle already no-ops when the scope is absent.
try {
const token = await this.tools.integrations.get(calendarId);
if (token) {
Expand Down
32 changes: 30 additions & 2 deletions twister/src/connector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,33 @@ export type CreateLinkDraft = {
inviteEmails?: string[];
};

/** An optional OAuth scope group the user can toggle at connect time. */
export type OptionalScopeGroup = {
/** Stable id used to track the user's selection. */
id: string;
/** Value-forward switch label, e.g. "Add names to events using contacts". */
label: string;
/** Optional secondary line shown under the label. */
description?: string;
/** The OAuth scope strings this group grants. */
scopes: string[];
/** Whether the group is requested by default (switch on). */
default: boolean;
};

/**
* Structured scope declaration. `required` scopes must be granted — auth fails
* and re-prompts if any is declined. `optional` groups are requested by default
* but auth still succeeds if the user declines them; the connector should detect
* the absence via the granted `token.scopes` and degrade gracefully.
*/
export type ScopeConfig = {
required: string[];
/** Friendly bullets describing what the always-on (required) access does. */
description?: string[];
optional?: OptionalScopeGroup[];
};

/**
* Base class for connectors — twists that sync data from external services.
*
Expand DownExpand Up@@ -226,8 +253,9 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
/** The OAuth provider this connector authenticates with. */
readonly provider?: AuthProvider;

/** OAuth scopes to request for this connector. */
readonly scopes?: string[];
/** OAuth scopes to request for this connector — a flat list (all required), or
* a {@link ScopeConfig} declaring required + optional scope groups. */
readonly scopes?: string[] | ScopeConfig;

// ---- Auth model ----

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-connector-scopes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `ScopeConfig` for a connector's `scopes` — declare `required` scopes, an optional `description` (friendly permission bullets shown at connect time), and `optional` scope groups the user can toggle. Auth now succeeds even when optional scopes are declined; connectors detect the result via the granted `token.scopes`.
48 changes: 37 additions & 11 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,16 +147,35 @@ type SyncOptions = {
*/
export class GoogleCalendar extends Connector<GoogleCalendar> {
static readonly PROVIDER = AuthProvider.Google;
static readonly SCOPES = [
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
static readonly EVENTS_SCOPE =
"https://www.googleapis.com/auth/calendar.events";
static readonly CALENDAR_LIST_SCOPE =
"https://www.googleapis.com/auth/calendar.calendarlist.readonly";

readonly provider = AuthProvider.Google;
readonly scopes = Integrations.MergeScopes(
GoogleCalendar.SCOPES,
GoogleContacts.SCOPES
);
readonly scopes = {
required: [GoogleCalendar.EVENTS_SCOPE],
description: [
"Reads your events to add them to your agenda",
"Writes your event RSVPs",
],
optional: [
{
id: "contacts",
label: "Add names to events using contacts",
scopes: GoogleContacts.SCOPES,
default: true,
},
{
id: "calendars",
label: "List all calendars",
description:
"List all calendars so you can choose which to sync. If disabled, only your primary calendar will be synced.",
scopes: [GoogleCalendar.CALENDAR_LIST_SCOPE],
default: true,
},
],
};
readonly linkTypes = [
{
type: "event",
Expand DownExpand Up@@ -209,6 +228,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
_auth: Authorization,
token: AuthToken
): Promise<Channel[]> {
// Listing the user's calendars needs the (optional) calendar-list scope.
// If the user didn't grant it, sync only the primary calendar — calling
// calendarList without the scope 403s and would wrongly flag the
// connection for re-auth.
if (!token.scopes.includes(GoogleCalendar.CALENDAR_LIST_SCOPE)) {
return [{ id: "primary", title: "Calendar", enabledByDefault: true }];
}
const api = new GoogleApi(token.token);
const calendars = await this.listCalendarsWithApi(api);
// Default to syncing the user's OWN calendars (their primary + any
Expand DownExpand Up@@ -1388,9 +1414,9 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
const batch = Array.from(linksBySource.values());
if (batch.length > 0) {
// Enrich attendee/organizer contacts with names + avatars from the
// user's Google Contacts and "other contacts". Calendar already
// merges GoogleContacts.SCOPES into its scopes, so the People API
// is reachable without a separate connector install.
// user's Google Contacts and "other contacts". The People API is
// reachable when the user granted the optional contacts scope;
// enrichLinkContactsFromGoogle already no-ops when the scope is absent.
try {
const token = await this.tools.integrations.get(calendarId);
if (token) {
Expand Down
32 changes: 30 additions & 2 deletions twister/src/connector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,33 @@ export type CreateLinkDraft = {
inviteEmails?: string[];
};

/** An optional OAuth scope group the user can toggle at connect time. */
export type OptionalScopeGroup = {
/** Stable id used to track the user's selection. */
id: string;
/** Value-forward switch label, e.g. "Add names to events using contacts". */
label: string;
/** Optional secondary line shown under the label. */
description?: string;
/** The OAuth scope strings this group grants. */
scopes: string[];
/** Whether the group is requested by default (switch on). */
default: boolean;
};

/**
* Structured scope declaration. `required` scopes must be granted — auth fails
* and re-prompts if any is declined. `optional` groups are requested by default
* but auth still succeeds if the user declines them; the connector should detect
* the absence via the granted `token.scopes` and degrade gracefully.
*/
export type ScopeConfig = {
required: string[];
/** Friendly bullets describing what the always-on (required) access does. */
description?: string[];
optional?: OptionalScopeGroup[];
};

/**
* Base class for connectors — twists that sync data from external services.
*
Expand DownExpand Up@@ -226,8 +253,9 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
/** The OAuth provider this connector authenticates with. */
readonly provider?: AuthProvider;

/** OAuth scopes to request for this connector. */
readonly scopes?: string[];
/** OAuth scopes to request for this connector — a flat list (all required), or
* a {@link ScopeConfig} declaring required + optional scope groups. */
readonly scopes?: string[] | ScopeConfig;

// ---- Auth model ----

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-connector-scopes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `ScopeConfig` for a connector's `scopes` — declare `required` scopes, an optional `description` (friendly permission bullets shown at connect time), and `optional` scope groups the user can toggle. Auth now succeeds even when optional scopes are declined; connectors detect the result via the granted `token.scopes`.
48 changes: 37 additions & 11 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,16 +147,35 @@ type SyncOptions = {
*/
export class GoogleCalendar extends Connector<GoogleCalendar> {
static readonly PROVIDER = AuthProvider.Google;
static readonly SCOPES = [
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
static readonly EVENTS_SCOPE =
"https://www.googleapis.com/auth/calendar.events";
static readonly CALENDAR_LIST_SCOPE =
"https://www.googleapis.com/auth/calendar.calendarlist.readonly";

readonly provider = AuthProvider.Google;
readonly scopes = Integrations.MergeScopes(
GoogleCalendar.SCOPES,
GoogleContacts.SCOPES
);
readonly scopes = {
required: [GoogleCalendar.EVENTS_SCOPE],
description: [
"Reads your events to add them to your agenda",
"Writes your event RSVPs",
],
optional: [
{
id: "contacts",
label: "Add names to events using contacts",
scopes: GoogleContacts.SCOPES,
default: true,
},
{
id: "calendars",
label: "List all calendars",
description:
"List all calendars so you can choose which to sync. If disabled, only your primary calendar will be synced.",
scopes: [GoogleCalendar.CALENDAR_LIST_SCOPE],
default: true,
},
],
};
readonly linkTypes = [
{
type: "event",
Expand DownExpand Up@@ -209,6 +228,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
_auth: Authorization,
token: AuthToken
): Promise<Channel[]> {
// Listing the user's calendars needs the (optional) calendar-list scope.
// If the user didn't grant it, sync only the primary calendar — calling
// calendarList without the scope 403s and would wrongly flag the
// connection for re-auth.
if (!token.scopes.includes(GoogleCalendar.CALENDAR_LIST_SCOPE)) {
return [{ id: "primary", title: "Calendar", enabledByDefault: true }];
}
const api = new GoogleApi(token.token);
const calendars = await this.listCalendarsWithApi(api);
// Default to syncing the user's OWN calendars (their primary + any
Expand DownExpand Up@@ -1388,9 +1414,9 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
const batch = Array.from(linksBySource.values());
if (batch.length > 0) {
// Enrich attendee/organizer contacts with names + avatars from the
// user's Google Contacts and "other contacts". Calendar already
// merges GoogleContacts.SCOPES into its scopes, so the People API
// is reachable without a separate connector install.
// user's Google Contacts and "other contacts". The People API is
// reachable when the user granted the optional contacts scope;
// enrichLinkContactsFromGoogle already no-ops when the scope is absent.
try {
const token = await this.tools.integrations.get(calendarId);
if (token) {
Expand Down
32 changes: 30 additions & 2 deletions twister/src/connector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,33 @@ export type CreateLinkDraft = {
inviteEmails?: string[];
};

/** An optional OAuth scope group the user can toggle at connect time. */
export type OptionalScopeGroup = {
/** Stable id used to track the user's selection. */
id: string;
/** Value-forward switch label, e.g. "Add names to events using contacts". */
label: string;
/** Optional secondary line shown under the label. */
description?: string;
/** The OAuth scope strings this group grants. */
scopes: string[];
/** Whether the group is requested by default (switch on). */
default: boolean;
};

/**
* Structured scope declaration. `required` scopes must be granted — auth fails
* and re-prompts if any is declined. `optional` groups are requested by default
* but auth still succeeds if the user declines them; the connector should detect
* the absence via the granted `token.scopes` and degrade gracefully.
*/
export type ScopeConfig = {
required: string[];
/** Friendly bullets describing what the always-on (required) access does. */
description?: string[];
optional?: OptionalScopeGroup[];
};

/**
* Base class for connectors — twists that sync data from external services.
*
Expand DownExpand Up@@ -226,8 +253,9 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
/** The OAuth provider this connector authenticates with. */
readonly provider?: AuthProvider;

/** OAuth scopes to request for this connector. */
readonly scopes?: string[];
/** OAuth scopes to request for this connector — a flat list (all required), or
* a {@link ScopeConfig} declaring required + optional scope groups. */
readonly scopes?: string[] | ScopeConfig;

// ---- Auth model ----

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-connector-scopes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `ScopeConfig` for a connector's `scopes` — declare `required` scopes, an optional `description` (friendly permission bullets shown at connect time), and `optional` scope groups the user can toggle. Auth now succeeds even when optional scopes are declined; connectors detect the result via the granted `token.scopes`.
48 changes: 37 additions & 11 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,16 +147,35 @@ type SyncOptions = {
*/
export class GoogleCalendar extends Connector<GoogleCalendar> {
static readonly PROVIDER = AuthProvider.Google;
static readonly SCOPES = [
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
static readonly EVENTS_SCOPE =
"https://www.googleapis.com/auth/calendar.events";
static readonly CALENDAR_LIST_SCOPE =
"https://www.googleapis.com/auth/calendar.calendarlist.readonly";

readonly provider = AuthProvider.Google;
readonly scopes = Integrations.MergeScopes(
GoogleCalendar.SCOPES,
GoogleContacts.SCOPES
);
readonly scopes = {
required: [GoogleCalendar.EVENTS_SCOPE],
description: [
"Reads your events to add them to your agenda",
"Writes your event RSVPs",
],
optional: [
{
id: "contacts",
label: "Add names to events using contacts",
scopes: GoogleContacts.SCOPES,
default: true,
},
{
id: "calendars",
label: "List all calendars",
description:
"List all calendars so you can choose which to sync. If disabled, only your primary calendar will be synced.",
scopes: [GoogleCalendar.CALENDAR_LIST_SCOPE],
default: true,
},
],
};
readonly linkTypes = [
{
type: "event",
Expand DownExpand Up@@ -209,6 +228,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
_auth: Authorization,
token: AuthToken
): Promise<Channel[]> {
// Listing the user's calendars needs the (optional) calendar-list scope.
// If the user didn't grant it, sync only the primary calendar — calling
// calendarList without the scope 403s and would wrongly flag the
// connection for re-auth.
if (!token.scopes.includes(GoogleCalendar.CALENDAR_LIST_SCOPE)) {
return [{ id: "primary", title: "Calendar", enabledByDefault: true }];
}
const api = new GoogleApi(token.token);
const calendars = await this.listCalendarsWithApi(api);
// Default to syncing the user's OWN calendars (their primary + any
Expand DownExpand Up@@ -1388,9 +1414,9 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
const batch = Array.from(linksBySource.values());
if (batch.length > 0) {
// Enrich attendee/organizer contacts with names + avatars from the
// user's Google Contacts and "other contacts". Calendar already
// merges GoogleContacts.SCOPES into its scopes, so the People API
// is reachable without a separate connector install.
// user's Google Contacts and "other contacts". The People API is
// reachable when the user granted the optional contacts scope;
// enrichLinkContactsFromGoogle already no-ops when the scope is absent.
try {
const token = await this.tools.integrations.get(calendarId);
if (token) {
Expand Down
32 changes: 30 additions & 2 deletions twister/src/connector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,33 @@ export type CreateLinkDraft = {
inviteEmails?: string[];
};

/** An optional OAuth scope group the user can toggle at connect time. */
export type OptionalScopeGroup = {
/** Stable id used to track the user's selection. */
id: string;
/** Value-forward switch label, e.g. "Add names to events using contacts". */
label: string;
/** Optional secondary line shown under the label. */
description?: string;
/** The OAuth scope strings this group grants. */
scopes: string[];
/** Whether the group is requested by default (switch on). */
default: boolean;
};

/**
* Structured scope declaration. `required` scopes must be granted — auth fails
* and re-prompts if any is declined. `optional` groups are requested by default
* but auth still succeeds if the user declines them; the connector should detect
* the absence via the granted `token.scopes` and degrade gracefully.
*/
export type ScopeConfig = {
required: string[];
/** Friendly bullets describing what the always-on (required) access does. */
description?: string[];
optional?: OptionalScopeGroup[];
};

/**
* Base class for connectors — twists that sync data from external services.
*
Expand DownExpand Up@@ -226,8 +253,9 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
/** The OAuth provider this connector authenticates with. */
readonly provider?: AuthProvider;

/** OAuth scopes to request for this connector. */
readonly scopes?: string[];
/** OAuth scopes to request for this connector — a flat list (all required), or
* a {@link ScopeConfig} declaring required + optional scope groups. */
readonly scopes?: string[] | ScopeConfig;

// ---- Auth model ----

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-connector-scopes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `ScopeConfig` for a connector's `scopes` — declare `required` scopes, an optional `description` (friendly permission bullets shown at connect time), and `optional` scope groups the user can toggle. Auth now succeeds even when optional scopes are declined; connectors detect the result via the granted `token.scopes`.
48 changes: 37 additions & 11 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,16 +147,35 @@ type SyncOptions = {
*/
export class GoogleCalendar extends Connector<GoogleCalendar> {
static readonly PROVIDER = AuthProvider.Google;
static readonly SCOPES = [
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
static readonly EVENTS_SCOPE =
"https://www.googleapis.com/auth/calendar.events";
static readonly CALENDAR_LIST_SCOPE =
"https://www.googleapis.com/auth/calendar.calendarlist.readonly";

readonly provider = AuthProvider.Google;
readonly scopes = Integrations.MergeScopes(
GoogleCalendar.SCOPES,
GoogleContacts.SCOPES
);
readonly scopes = {
required: [GoogleCalendar.EVENTS_SCOPE],
description: [
"Reads your events to add them to your agenda",
"Writes your event RSVPs",
],
optional: [
{
id: "contacts",
label: "Add names to events using contacts",
scopes: GoogleContacts.SCOPES,
default: true,
},
{
id: "calendars",
label: "List all calendars",
description:
"List all calendars so you can choose which to sync. If disabled, only your primary calendar will be synced.",
scopes: [GoogleCalendar.CALENDAR_LIST_SCOPE],
default: true,
},
],
};
readonly linkTypes = [
{
type: "event",
Expand DownExpand Up@@ -209,6 +228,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
_auth: Authorization,
token: AuthToken
): Promise<Channel[]> {
// Listing the user's calendars needs the (optional) calendar-list scope.
// If the user didn't grant it, sync only the primary calendar — calling
// calendarList without the scope 403s and would wrongly flag the
// connection for re-auth.
if (!token.scopes.includes(GoogleCalendar.CALENDAR_LIST_SCOPE)) {
return [{ id: "primary", title: "Calendar", enabledByDefault: true }];
}
const api = new GoogleApi(token.token);
const calendars = await this.listCalendarsWithApi(api);
// Default to syncing the user's OWN calendars (their primary + any
Expand DownExpand Up@@ -1388,9 +1414,9 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
const batch = Array.from(linksBySource.values());
if (batch.length > 0) {
// Enrich attendee/organizer contacts with names + avatars from the
// user's Google Contacts and "other contacts". Calendar already
// merges GoogleContacts.SCOPES into its scopes, so the People API
// is reachable without a separate connector install.
// user's Google Contacts and "other contacts". The People API is
// reachable when the user granted the optional contacts scope;
// enrichLinkContactsFromGoogle already no-ops when the scope is absent.
try {
const token = await this.tools.integrations.get(calendarId);
if (token) {
Expand Down
32 changes: 30 additions & 2 deletions twister/src/connector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,33 @@ export type CreateLinkDraft = {
inviteEmails?: string[];
};

/** An optional OAuth scope group the user can toggle at connect time. */
export type OptionalScopeGroup = {
/** Stable id used to track the user's selection. */
id: string;
/** Value-forward switch label, e.g. "Add names to events using contacts". */
label: string;
/** Optional secondary line shown under the label. */
description?: string;
/** The OAuth scope strings this group grants. */
scopes: string[];
/** Whether the group is requested by default (switch on). */
default: boolean;
};

/**
* Structured scope declaration. `required` scopes must be granted — auth fails
* and re-prompts if any is declined. `optional` groups are requested by default
* but auth still succeeds if the user declines them; the connector should detect
* the absence via the granted `token.scopes` and degrade gracefully.
*/
export type ScopeConfig = {
required: string[];
/** Friendly bullets describing what the always-on (required) access does. */
description?: string[];
optional?: OptionalScopeGroup[];
};

/**
* Base class for connectors — twists that sync data from external services.
*
Expand DownExpand Up@@ -226,8 +253,9 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
/** The OAuth provider this connector authenticates with. */
readonly provider?: AuthProvider;

/** OAuth scopes to request for this connector. */
readonly scopes?: string[];
/** OAuth scopes to request for this connector — a flat list (all required), or
* a {@link ScopeConfig} declaring required + optional scope groups. */
readonly scopes?: string[] | ScopeConfig;

// ---- Auth model ----

Expand Down
Loading